How to Use RefCell<T> for Interior Mutability in Rust

Use RefCell<T> to enable interior mutability by wrapping data and checking borrow rules at runtime instead of compile time.

When immutable references need to mutate

You are building a UI framework. You have a Button struct that tracks how many times it has been clicked. The rendering engine iterates over a list of widgets and calls a draw method on each one. The engine passes &self to draw because it wants to render the button without taking ownership or mutating the widget list.

Inside draw, you want to increment a click counter. You write self.clicks += 1. The compiler rejects you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). The borrow checker sees that self is borrowed immutably by the engine, so it refuses to allow a mutable borrow of self.clicks.

You need to mutate the inside of the button while the outside remains immutable. This is the exact problem RefCell<T> solves. It provides interior mutability, allowing you to change a value through an immutable reference by deferring borrow checking to runtime.

How RefCell works

Rust's borrow checker enforces two rules at compile time: you can have one mutable reference or many immutable references, but not both at the same time. RefCell<T> keeps these rules, but it checks them at runtime instead of compile time.

Think of the borrow checker as a strict librarian who checks your ID before you enter the library. RefCell is like a library where the librarian checks your ID at the door to the specific bookshelf. You can walk around the library freely, but when you try to check out a book, the system verifies that no one else is reading it. If someone is, you get turned away immediately.

RefCell wraps a value and tracks active borrows using a small counter. When you call borrow(), it increments an immutable borrow count. When you call borrow_mut(), it checks that the immutable count is zero and no mutable borrow is active. If the check passes, it grants access. If the check fails, the program panics.

use std::cell::RefCell;

struct Counter {
    // RefCell wraps the value to allow mutation via &self
    count: RefCell<u32>,
}

impl Counter {
    fn new() -> Self {
        Counter {
            count: RefCell::new(0),
        }
    }

    // Takes &self, but mutates the inner count
    fn increment(&self) {
        // borrow_mut() checks at runtime and returns a guard
        let mut c = self.count.borrow_mut();
        *c += 1;
    }

    fn get(&self) -> u32 {
        // borrow() checks at runtime and returns an immutable guard
        let c = self.count.borrow();
        *c
    }
}

The compiler sees &self and sees borrow_mut(). It does not see a violation because RefCell hides the mutability. The type system sees RefCell<u32>, not u32. The mutation happens inside the black box. At runtime, borrow_mut() performs the check. This trade-off gives you flexibility at the cost of a runtime check and the risk of a panic.

Trust the borrow checker first. RefCell is an escape hatch, not a default.

The guard mechanism

borrow() and borrow_mut() do not return raw references. They return guard types: Ref<T> and RefMut<T>. These guards are crucial. They are RAII wrappers that track the borrow and automatically release it when they go out of scope.

When you call borrow_mut(), the guard increments a mutable flag. The guard dereferences to &mut T, so you can use it like a mutable reference. When the guard drops, it decrements the flag. This ensures that borrows cannot leak. You cannot extract a raw reference from a RefCell and hold it forever. The guard enforces the lifetime.

use std::cell::RefCell;

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

    {
        // Guard is created here
        let mut guard = data.borrow_mut();
        guard.push(4);
        // Guard is dropped here, releasing the borrow
    }

    // Borrow is released, so this works
    let immutable_guard = data.borrow();
    println!("First element: {}", immutable_guard[0]);
}

If you try to create a second borrow while a guard is active, the runtime check fails.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(5);
    let v1 = data.borrow();
    // PANIC: already borrowed as immutable
    let v2 = data.borrow_mut();
}

The panic message tells you exactly what went wrong. RefCell panics are bugs. If your code panics on borrow, you have a logic error in your borrow scopes. Fix the scope, do not catch the panic.

Realistic example: Shared graph nodes

RefCell shines in data structures where ownership is shared or circular. Graphs are a classic case. Nodes point to other nodes, and those nodes might point back. Rust's ownership model struggles with cycles because there is no clear owner to clean up the data.

The standard solution is Rc<RefCell<T>>. Rc provides shared ownership with reference counting. RefCell provides interior mutability. Together, they let you share and mutate data dynamically. This pattern is the go-to for graphs, UI trees, and parsers where multiple parts of the structure reference the same node.

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

struct Node {
    value: i32,
    // RefCell allows mutating children even when the node is shared
    children: RefCell<Vec<Rc<Node>>>,
}

impl Node {
    fn new(value: i32) -> Self {
        Node {
            value,
            children: RefCell::new(Vec::new()),
        }
    }

    // Add a child through a shared reference
    fn add_child(&self, child: Rc<Node>) {
        // borrow_mut() allows pushing to the vector
        self.children.borrow_mut().push(child);
    }

    fn print_tree(&self, indent: usize) {
        let prefix = "  ".repeat(indent);
        println!("{}Node({})", prefix, self.value);

        // borrow() allows reading children
        let children = self.children.borrow();
        for child in children.iter() {
            child.print_tree(indent + 1);
        }
    }
}

fn main() {
    let root = Rc::new(Node::new(1));
    let child = Rc::new(Node::new(2));

    // Add child to root
    root.add_child(child.clone());

    // Print the tree
    root.print_tree(0);
}

Convention aside: hide the RefCell in public APIs. Expose methods like add_child that use borrow internally. Do not expose RefCell fields directly. Callers should not see the guards. They should see clean methods that return values or results. The RefCell is an implementation detail, not part of the interface.

Panics and try_borrow

The borrow() and borrow_mut() methods panic on conflict. In production code, panics are often unacceptable. You need to handle errors gracefully. RefCell provides try_borrow() and try_borrow_mut() for this purpose.

These methods return Result instead of panicking. try_borrow() returns Result<Ref<T>, BorrowError>. try_borrow_mut() returns Result<RefMut<T>, BorrowMutError>. You can match on the result and handle the conflict.

use std::cell::RefCell;

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

    match data.try_borrow_mut() {
        Ok(mut g) => {
            *g = 100;
        }
        Err(_) => {
            println!("Cannot mutate: already borrowed immutably");
        }
    }
}

Use try_borrow when you are building libraries or systems where a borrow conflict is a recoverable error. Use borrow in application code where a conflict indicates a programming mistake that should crash.

RefCell panics are your conscience at runtime. Listen to them.

Performance and overhead

RefCell is cheap, but it is not free. It maintains a counter to track borrows. On a 64-bit system, this adds a few bytes to the size of the value. The borrow check involves a load and a branch instruction. In tight loops, this overhead adds up.

Profile before using RefCell in hot paths. If your code spends significant time in borrow checks, consider restructuring to use compile-time borrowing. RefCell is best for data that is mutated infrequently or where the flexibility outweighs the cost.

RefCell is also not thread-safe. It does not use atomic operations. If you try to share a RefCell across threads, the compiler stops you with a trait bound error. This is a feature. It prevents data races by design. If you need interior mutability across threads, use Mutex<T> instead.

RefCell vs Cell

Rust has two types for interior mutability: RefCell<T> and Cell<T>. They serve different purposes.

Cell<T> works only with types that implement Copy or can be cloned cheaply. It does not allow borrowing. You cannot get a reference to the inside of a Cell. You can only get the value and set a new value. Cell is faster than RefCell because it has no borrow tracking. It is ideal for simple flags, counters, or caches where you replace the whole value.

RefCell<T> works with any type. It allows borrowing, so you can pass references to functions. It has runtime borrow tracking. Use RefCell when you need to borrow the value or when the type is not Copy.

use std::cell::Cell;

struct Flag {
    // Cell is efficient for simple Copy types
    is_active: Cell<bool>,
}

impl Flag {
    fn toggle(&self) {
        // get() and set() replace the value
        self.is_active.set(!self.is_active.get());
    }
}

Decision matrix

Use RefCell<T> when you need to mutate a value through an immutable reference and the borrow checker rejects your code at compile time.

Use Cell<T> when the value implements Copy and you only need to replace the whole value, not borrow it.

Use Mutex<T> when you need interior mutability across threads; RefCell is not thread-safe.

Use plain &mut T when you can restructure your code to satisfy the borrow checker; RefCell adds runtime overhead and panic risk.

Use Rc<RefCell<T>> when you need shared ownership with mutation, like in a graph or UI tree.

Where to go next