RefCell<T> in Rust: Borrow Rules & vs Mutex Guide

Max WellsMax WellsFounder of Rustify

TL;DR: RefCell<T> moves Rust's borrow-checking rules from compile time to runtime. It lets you obtain a mutable borrow (borrow_mut()) from an immutable reference; something the compiler normally forbids. This is safe because RefCell panics if you violate the rules at runtime. Use it when you need mutability in a context where the compiler can't prove it's safe, and you're certain only one mutable borrow will exist at a time.


What Is Interior Mutability?

Interior mutability is a design pattern in Rust that lets you mutate data even when you only have an immutable reference to it; by moving borrow checking to runtime.

Normally, Rust enforces at compile time: either one mutable reference or any number of immutable references, never both. RefCell<T> enforces these same rules, but at runtime via a borrow counter inside the cell.

use std::cell::RefCell;
 
fn main() {
    let data = RefCell::new(vec![1, 2, 3]);
 
    // borrow(); immutable borrow (Ref<T>)
    let r1 = data.borrow();
    let r2 = data.borrow(); // Multiple immutable borrows: OK
    println!("{:?} {:?}", *r1, *r2);
    drop(r1);
    drop(r2);
 
    // borrow_mut(); mutable borrow (RefMut<T>)
    data.borrow_mut().push(4);
    println!("{:?}", data.borrow()); // [1, 2, 3, 4]
}

What Happens If You Violate the Borrow Rules?

If you call borrow_mut() while an immutable or mutable borrow is active, RefCell panics at runtime with "already borrowed".

use std::cell::RefCell;
 
fn main() {
    let data = RefCell::new(42);
    let _r = data.borrow();       // active immutable borrow
 
    // PANIC: already borrowed: BorrowMutError
    let _w = data.borrow_mut();
}

Use try_borrow() and try_borrow_mut() to get a Result instead of panicking:

match data.try_borrow_mut() {
    Ok(mut val) => *val += 1,
    Err(_) => println!("already borrowed, skipping"),
}

When Should You Use RefCell?

Use RefCell when you need mutation behind a shared reference; typically when combined with Rc<T> for single-threaded shared mutable state.

The classic pattern is Rc<RefCell<T>>:

use std::rc::Rc;
use std::cell::RefCell;
 
#[derive(Debug)]
struct SharedCounter {
    count: i32,
}
 
fn main() {
    let counter = Rc::new(RefCell::new(SharedCounter { count: 0 }));
 
    let a = Rc::clone(&counter);
    let b = Rc::clone(&counter);
 
    a.borrow_mut().count += 1;
    b.borrow_mut().count += 1;
 
    println!("{:?}", counter.borrow()); // SharedCounter { count: 2 }
}

Other valid use cases:

  • Implementing graph/tree nodes with back-references
  • Caching computed values in an otherwise immutable struct
  • Mock objects in tests that need to record calls

How Is RefCell Different From Mutex?

RefCell is for single-threaded interior mutability. Mutex is for multi-threaded shared mutable access. They enforce the same borrow rules but in different contexts.

RefCell<T>Mutex<T>
Thread-safe❌ (!Send, !Sync)
Borrow checkingRuntime (panics)Runtime (blocking/async)
Combined withRc<T>Arc<T>
BlockingNeverMay block (waiting for lock)
OverheadTiny counter incrementLock acquisition (more expensive)
// Single-threaded: Rc<RefCell<T>>
let shared = Rc::new(RefCell::new(0));
 
// Multi-threaded: Arc<Mutex<T>>
let shared = Arc::new(Mutex::new(0));

What Are Cell<T> and OnceCell<T>?

Cell<T> and OnceCell<T> are lighter interior mutability primitives for simpler cases.

use std::cell::{Cell, OnceCell};
 
// Cell<T>; for Copy types, no borrow tracking needed
let x = Cell::new(5);
x.set(10);
println!("{}", x.get()); // 10
 
// OnceCell<T>; lazy initialization, write once
let cell: OnceCell<String> = OnceCell::new();
cell.set("hello".to_string()).unwrap();
println!("{}", cell.get().unwrap()); // "hello"

Use Cell for simple Copy values. Use RefCell when you need borrows of non-Copy data. Use OnceCell for lazy one-time initialization.


Frequently Asked Questions

Yes; RefCell is 100% safe Rust. It cannot cause undefined behavior. The worst outcome is a panic at runtime if you violate borrow rules. This is why you should only use it when you can reason that borrows won't overlap.

With caution. Don't hold a RefCell borrow across .await; it's !Send and the executor may move the task to another thread. For async shared state, use tokio::sync::Mutex instead.

UnsafeCell<T> is the primitive building block; it's the only way to get a *mut T from a shared reference. Everything else (RefCell, Cell, Mutex) is built on top of it. You should never need UnsafeCell directly in safe code.

Try restructuring first. RefCell adds runtime overhead and panic risk. If restructuring your ownership to avoid shared mutability isn't feasible (e.g., callback-heavy code, recursive structures), then RefCell is the right tool.


Sources


  • Rc: Paired with RefCell for single-threaded shared mutable state
  • Arc: Thread-safe counterpart; combined with Mutex instead
  • Mutex: The multi-threaded alternative to RefCell
  • Borrow Checker: The compile-time system RefCell defers to runtime
  • Interior Mutability: RefCell is the canonical single-threaded interior mutability type
  • RwLock: RwLock is the multi-reader counterpart once code moves from single-threaded to multi-threaded mutation

Keep Reading

Ready to Land a $120k+ Rust Job in the US or Europe?