Skip to content
Codeloom
Rust

Rust Interior Mutability: Cell and RefCell

Understand Rust's interior mutability pattern using Cell and RefCell, when to use each, and how they let you mutate data behind shared references.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What interior mutability means in Rust
  • How Cell works for Copy types
  • How RefCell adds runtime borrow checking
  • Combining RefCell with Rc for shared mutable state

Prerequisites

  • Basic Rust knowledge
  • Ownership and borrowing rules

Rust’s borrow checker enforces a simple rule: you can have either one mutable reference or any number of shared references, but never both at the same time. This works well most of the time, but some patterns need to mutate data through a shared reference. That is where interior mutability comes in.

Interior mutability is a design pattern that lets you mutate data even when there are immutable references to it. The standard library provides two primary types for this: Cell<T> and RefCell<T>. Both use unsafe code internally but expose a safe API.

Cell: Copy-Based Interior Mutability

Cell<T> works with types that implement Copy. Instead of giving you a reference to the inner value, it lets you get and set the value by copying it in and out.

use std::cell::Cell;

fn main() {
    let value = Cell::new(42);

    // Get copies the value out
    println!("Before: {}", value.get());

    // Set replaces the value
    value.set(100);
    println!("After: {}", value.get());
}

Because Cell never hands out references to its interior, there is no risk of dangling references or aliased mutation. The trade-off is that it only works with Copy types, so you cannot use it with String, Vec, or other heap-allocated types.

A practical use case is a counter that gets updated inside a closure that captures by shared reference:

use std::cell::Cell;

fn main() {
    let call_count = Cell::new(0u32);

    let increment = || {
        call_count.set(call_count.get() + 1);
    };

    increment();
    increment();
    increment();

    println!("Called {} times", call_count.get()); // 3
}

Another common pattern is a cached computation:

use std::cell::Cell;

struct Sensor {
    raw_reading: f64,
    calibration_offset: Cell<f64>,
}

impl Sensor {
    fn new(raw: f64) -> Self {
        Sensor {
            raw_reading: raw,
            calibration_offset: Cell::new(0.0),
        }
    }

    fn calibrate(&self, offset: f64) {
        // We can mutate even though self is &self, not &mut self
        self.calibration_offset.set(offset);
    }

    fn reading(&self) -> f64 {
        self.raw_reading + self.calibration_offset.get()
    }
}

fn main() {
    let sensor = Sensor::new(25.0);
    sensor.calibrate(-1.5);
    println!("Calibrated reading: {}", sensor.reading()); // 23.5
}

RefCell: Runtime Borrow Checking

RefCell<T> works with any type, including non-Copy types. It enforces the borrowing rules at runtime instead of compile time. You call borrow() to get a shared reference and borrow_mut() to get a mutable reference. If you violate the rules (for example, calling borrow_mut() while a borrow() is active), the program panics.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    // Immutable borrow
    {
        let borrowed = data.borrow();
        println!("Length: {}", borrowed.len());
    } // borrowed is dropped here

    // Mutable borrow
    {
        let mut borrowed = data.borrow_mut();
        borrowed.push(4);
    }

    println!("Final: {:?}", data.borrow()); // [1, 2, 3, 4]
}

If you try to borrow mutably while an immutable borrow is active, RefCell panics:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(42);

    let _shared = data.borrow();
    // This will panic at runtime!
    // let _mutable = data.borrow_mut();
}

To avoid panics, you can use try_borrow() and try_borrow_mut(), which return a Result instead:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(42);

    let _shared = data.borrow();

    match data.try_borrow_mut() {
        Ok(mut val) => *val = 100,
        Err(e) => println!("Cannot borrow mutably: {e}"),
    }
}

Combining Rc and RefCell

The most powerful pattern combines Rc<RefCell<T>>. Rc gives you shared ownership, and RefCell gives you interior mutability. Together, they let multiple owners mutate the same data.

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Database {
    records: Vec<String>,
}

impl Database {
    fn new() -> Self {
        Database { records: vec![] }
    }

    fn insert(&mut self, record: String) {
        self.records.push(record);
    }
}

fn main() {
    let db = Rc::new(RefCell::new(Database::new()));

    // Multiple owners, all can mutate
    let writer1 = Rc::clone(&db);
    let writer2 = Rc::clone(&db);

    writer1.borrow_mut().insert("Alice".into());
    writer2.borrow_mut().insert("Bob".into());

    println!("Database: {:?}", db.borrow());
}

This pattern is especially useful for tree and graph structures where nodes need to mutate their children or siblings:

use std::cell::RefCell;
use std::rc::Rc;

type Link = Option<Rc<RefCell<TreeNode>>>;

#[derive(Debug)]
struct TreeNode {
    value: i32,
    left: Link,
    right: Link,
}

impl TreeNode {
    fn new(value: i32) -> Rc<RefCell<Self>> {
        Rc::new(RefCell::new(TreeNode {
            value,
            left: None,
            right: None,
        }))
    }
}

fn main() {
    let root = TreeNode::new(1);
    let left = TreeNode::new(2);
    let right = TreeNode::new(3);

    root.borrow_mut().left = Some(Rc::clone(&left));
    root.borrow_mut().right = Some(Rc::clone(&right));

    // Mutate a child through shared access
    left.borrow_mut().value = 20;

    println!("Root: {:?}", root.borrow());
}

Cell vs RefCell: When to Use Which

FeatureCell<T>RefCell<T>
Types supportedCopy types onlyAny type
APIget() / set()borrow() / borrow_mut()
Borrow checkingNone needed (copies values)Runtime
Panic riskNoneYes, on borrow violations
OverheadVery lowSmall (tracks active borrows)
Thread-safeNoNo

Use Cell when you are working with small Copy types like integers, floats, or booleans. It has less overhead and cannot panic.

Use RefCell when you need to mutate non-Copy types like String, Vec, or custom structs. Accept the runtime borrow-checking cost and be disciplined about keeping borrows short-lived.

Thread-Safe Alternatives

Neither Cell nor RefCell is thread-safe. For multi-threaded scenarios, use these alternatives:

  • Mutex<T> is the thread-safe equivalent of RefCell. It locks the data for exclusive access and blocks other threads until the lock is released.
  • RwLock<T> allows multiple readers or one writer, similar to Rust’s compile-time borrow rules but enforced at runtime with locking.
  • AtomicU32, AtomicBool, etc. are thread-safe equivalents of Cell for primitive types.

Wrapping Up

Interior mutability is Rust’s escape hatch for situations where the compile-time borrow checker is too strict. Cell handles Copy types with zero risk of panics. RefCell handles any type but moves borrow checking to runtime. Pair RefCell with Rc for shared mutable state in single-threaded code. When you move to multiple threads, switch to Mutex or RwLock. Use these types deliberately and keep their scope as small as possible.