Rust normally enforces borrowing at compile time: either one mutable reference or any number of immutable references may exist at a given moment. That rule prevents data races and many aliasing bugs before the program runs.

Sometimes the compiler cannot prove that a safe mutation pattern is valid, even though the program can enforce the rule dynamically. RefCell<T> provides interior mutability for those cases by moving borrow checking from compile time to runtime.

What RefCell changes

A RefCell<T> owns a value and exposes two main borrowing methods:

use std::cell::RefCell;

let numbers = RefCell::new(vec![1, 2]);

numbers.borrow_mut().push(3);
println!("{:?}", numbers.borrow());

borrow() returns an immutable guard, while borrow_mut() returns a mutable guard. The same core rule still applies:

  • many immutable borrows are allowed;
  • one mutable borrow is allowed;
  • immutable and mutable borrows may not overlap.

The difference is enforcement time. Violating the rule causes a panic at runtime rather than a compile-time error.

Why interior mutability exists

Consider an object exposed through an immutable reference that still needs to update internal bookkeeping:

use std::cell::RefCell;

struct Counter {
    hits: RefCell<u64>,
}

impl Counter {
    fn record(&self) {
        *self.hits.borrow_mut() += 1;
    }
}

The public method takes &self, yet the implementation can mutate hits because the mutability is managed inside the RefCell.

This can be useful for caches, test doubles, lazy bookkeeping, graph-like structures, and APIs where outward immutability is part of the design.

Borrow guards determine the lifetime of the borrow

Runtime borrows remain active as long as their guards live.

This code panics:

use std::cell::RefCell;

let value = RefCell::new(String::from("hello"));
let read = value.borrow();
let mut write = value.borrow_mut();

write.push('!');
println!("{read}");

The immutable guard read is still alive when borrow_mut() runs.

Keep guard lifetimes narrow:

let value = RefCell::new(String::from("hello"));

{
    let read = value.borrow();
    println!("{read}");
}

value.borrow_mut().push('!');

Small scopes make the runtime borrowing rules easier to reason about.

Use try_borrow when failure is expected

A panic is appropriate only when an overlapping borrow represents a programming bug.

When contention is a normal condition, use the non-panicking variants:

if let Ok(mut value) = cell.try_borrow_mut() {
    value.push(3);
}

try_borrow() and try_borrow_mut() return a result, allowing the caller to handle an unavailable borrow explicitly.

RefCell is not a thread-synchronization primitive

RefCell<T> is for single-threaded interior mutability. It does not make shared data safe across threads.

For concurrent shared mutation, synchronization types such as Mutex<T> or RwLock<T> are usually more appropriate. They coordinate access across threads rather than merely checking borrow rules inside one thread.

The conceptual similarity is useful: both approaches provide controlled access through guards, but they solve different problems.

A common pairing with Rc

Graph and tree structures sometimes need multiple owners plus mutation:

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

let shared = Rc::new(RefCell::new(vec![1]));
let other = Rc::clone(&shared);

shared.borrow_mut().push(2);
println!("{:?}", other.borrow());

Rc<T> supplies shared ownership in a single thread, while RefCell<T> supplies runtime-checked mutation.

This combination is powerful but can also make ownership relationships harder to follow. Cycles of strong Rc references can leak memory, so graph structures often need Weak references for back-links.

Prefer ordinary mutability when it works

Do not reach for RefCell just to silence borrow-checker errors.

First consider whether the design can use:

  • an ordinary &mut self method;
  • clearer ownership transfer;
  • a narrower scope for existing borrows;
  • a different data structure;
  • returning owned values instead of storing shared state.

Compile-time borrowing is safer because invalid access patterns cannot reach production. RefCell is a deliberate trade: more flexibility in exchange for runtime failure risk.

Good use cases

RefCell is often reasonable when:

  • mutation is an internal implementation detail;
  • the program is single-threaded;
  • an API must expose &self while maintaining internal state;
  • test infrastructure needs to record calls through an immutable interface;
  • a recursive or graph structure needs controlled shared mutation.

The design should still make it easy to see when borrows begin and end.

Common pitfalls

Holding a borrow across too much code

Long-lived guards increase the chance of an overlapping borrow. Extract the needed value or narrow the scope.

Nesting method calls that borrow the same RefCell

A method that already holds a mutable borrow can call another method that tries to borrow again and panic unexpectedly. Keep borrowing boundaries visible.

Using RefCell for multithreaded state

Choose synchronization primitives designed for cross-thread access.

Treating runtime borrow errors as normal control flow

If overlapping borrows happen frequently by design, reconsider the data model instead of relying on panics.

Use interior mutability intentionally

RefCell does not bypass Rust’s borrowing model. It enforces the same exclusivity rule later, while the program is running.

That makes it valuable when runtime structure contains information the compiler cannot easily express. Keep borrows short, prefer ordinary compile-time mutability when possible, and use RefCell where interior mutation is a clear property of the design rather than a workaround for unclear ownership.