Rust Interior Mutability: RefCell vs Cell vs Mutex — When to Use Each

Rust’s borrow checker is the single most powerful thing the language gives you — and also the single biggest source of "why won’t this compile" frustration when you’re starting out. Most of the time the rules are clear: one owner, any number of shared &T references or one exclusive &mut T, never both.

Then you hit a design that looks totally reasonable on paper — a cache inside a struct you want to share, a UI widget that needs to update its own render state, a logger that lives in a static — and the compiler flatly refuses. You haven’t broken any real invariant. You just need to mutate something through a shared reference.

That’s interior mutability. Rust has three main tools for it: Cell, RefCell, and Mutex. They’re not interchangeable — each one buys safety at a different cost, and picking the wrong one is a real performance and correctness issue.

This article covers all three with actual runnable examples, explains exactly what each one buys you, and gives you the mental model to pick the right one first time.


The Problem Interior Mutability Solves

Consider a struct that caches an expensive computation:

struct Config {
    raw: String,
    parsed: Option<ParsedConfig>, // lazily populated
}

impl Config {
    fn get(&self) -> &ParsedConfig {
        if self.parsed.is_none() {
            self.parsed = Some(parse(&self.raw)); // ERROR: cannot mutate via `&self`
        }
        self.parsed.as_ref().unwrap()
    }
}

The logic is sound. You’re not exposing mutation to the caller. But the borrow checker sees &self and rejects the write on self.parsed. The semantically correct code is illegal under Rust’s surface rules.

Your options without interior mutability are: change get to &mut self (now callers need exclusive access even for a read), or pre-populate the cache eagerly (may be expensive or impossible). Both are often the wrong trade-off.

Interior mutability is the escape hatch the language provides for exactly this case. It moves the borrow check from compile time to somewhere else — either runtime (for single-threaded code) or a synchronization primitive (for multi-threaded).


Cell<T> — Zero-Cost for Copy Types

Cell<T> is the simplest interior mutability primitive. It wraps a value and lets you get and set it through a shared reference — but only for types that implement Copy.

use std::cell::Cell;

struct HitCounter {
    hits: Cell<u32>,
}

impl HitCounter {
    fn new() -> Self {
        Self { hits: Cell::new(0) }
    }

    fn record(&self) {
        self.hits.set(self.hits.get() + 1);
    }

    fn count(&self) -> u32 {
        self.hits.get()
    }
}

fn main() {
    let counter = HitCounter::new();
    counter.record();
    counter.record();
    println!("Hits: {}", counter.count()); // Hits: 2
}

No mut, no locks, no overhead. Cell is essentially a typed UnsafeCell wrapper that enforces one rule: you can never get a reference to the inner value. You can only copy values in and out. This is what makes it safe — there’s no aliasing because you never hold a live reference to the interior.

When Cell fits:

  • The contained type is Copy (integers, booleans, small structs)
  • Single-threaded code only (Cell is !Sync)
  • You want zero runtime cost

Cell is also available with a replace method (atomic swap returning old value) and swap (exchange two cells). For booleans and small counters it’s exactly the right tool.

Gotcha: Cell is !Sync

Cell<T> cannot be shared across threads. The compiler will catch this, but it’s a common confusion when people try to stash a Cell in an Arc:

use std::sync::Arc;
use std::cell::Cell;

let shared = Arc::new(Cell::new(0u32)); // compiles...
let clone = Arc::clone(&shared);

std::thread::spawn(move || {
    clone.set(42); // ERROR: `Cell<u32>` cannot be shared between threads safely
});

The error is correct. Cell has no synchronization — if two threads could access it simultaneously you’d have a data race. For anything crossing a thread boundary, you need Mutex or atomics.


RefCell<T> — Runtime Borrow Checking

RefCell<T> is for non-Copy types. Instead of copying values in and out, it hands you references to the interior — but it tracks borrows at runtime and panics if you violate the rules.

Think of RefCell as moving the borrow checker from the compiler to the runtime. The same rules apply (one &mut T or any number of &T, never both), but violations become panics instead of compile errors.

use std::cell::RefCell;

struct Config {
    raw: String,
    parsed: RefCell<Option<Vec<String>>>,
}

impl Config {
    fn new(raw: String) -> Self {
        Self {
            raw,
            parsed: RefCell::new(None),
        }
    }

    fn get_parsed(&self) -> Vec<String> {
        // Lazily parse on first call
        if self.parsed.borrow().is_none() {
            let result = self.raw.split(',')
                .map(|s| s.trim().to_owned())
                .collect();
            *self.parsed.borrow_mut() = Some(result);
        }
        self.parsed.borrow().as_ref().unwrap().clone()
    }
}

fn main() {
    let cfg = Config::new("alpha, beta, gamma".to_owned());
    println!("{:?}", cfg.get_parsed()); // ["alpha", "beta", "gamma"]
    println!("{:?}", cfg.get_parsed()); // same, but cached
}

borrow() returns a Ref<T> — a smart pointer that holds a shared borrow and decrements the internal counter when dropped. borrow_mut() returns a RefMut<T> — an exclusive borrow. The RefCell tracks outstanding borrows at runtime.

There’s also try_borrow() and try_borrow_mut() which return Result instead of panicking. Use these in any code where a borrow conflict is reasonably possible (recursive calls, event handlers).

Real-World Pattern: Graph Nodes

RefCell is practically mandatory when you’re building graph-like structures with shared ownership:

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

type NodeRef = Rc<RefCell<Node>>;

struct Node {
    value: i32,
    children: Vec<NodeRef>,
}

impl Node {
    fn new(value: i32) -> NodeRef {
        Rc::new(RefCell::new(Node { value, children: vec![] }))
    }

    fn add_child(parent: &NodeRef, child: NodeRef) {
        parent.borrow_mut().children.push(child);
    }
}

fn sum_tree(node: &NodeRef) -> i32 {
    let n = node.borrow();
    n.value + n.children.iter().map(sum_tree).sum::<i32>()
}

fn main() {
    let root = Node::new(1);
    let a = Node::new(2);
    let b = Node::new(3);
    Node::add_child(&root, a);
    Node::add_child(&root, b);
    println!("Sum: {}", sum_tree(&root)); // Sum: 6
}

Rc<RefCell<T>> is idiomatic Rust for shared, mutable, single-threaded ownership. You’ll see this pattern constantly in GUI code, parsers, and tree structures.

Gotcha: Panics on Overlapping Borrows

This compiles and then explodes at runtime:

let cell = RefCell::new(vec![1, 2, 3]);
let borrow1 = cell.borrow();
let borrow2 = cell.borrow_mut(); // PANICS: already borrowed

The compiler won’t catch this. The Ref from borrow() is still live when you call borrow_mut(), and RefCell panics. This is the main risk. In practice, limit the scope of borrows explicitly — drop them before acquiring a conflicting one, or use the try_borrow variants and handle the error path.

Gotcha: RefCell is !Sync too

Same story as Cell. RefCell is single-threaded only. The runtime borrow counter is not atomic. Trying to put a RefCell in an Arc will fail to compile.


Mutex<T> — Thread-Safe Interior Mutability

For concurrent code, Mutex<T> is the standard tool. It wraps your data and forces every access — read or write — to go through a lock. Only one thread can hold the lock at a time.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0u64));
    let mut handles = vec![];

    for _ in 0..8 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1_000_000 {
                *c.lock().unwrap() += 1;
            }
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("Final count: {}", *counter.lock().unwrap()); // 8000000
}

lock() blocks until the lock is acquired and returns a MutexGuard<T> — a smart pointer that deref-coerces to &mut T. When the guard is dropped, the lock is released. This is the RAII pattern: no explicit unlock needed.

Arc<Mutex<T>> is the bread and butter of shared mutable state in Rust’s multithreaded code. Arc is the thread-safe reference counter (Rc is not), and Mutex is the interior mutability mechanism.

Real-World Pattern: Shared Cache

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

#[derive(Clone)]
struct Cache {
    store: Arc<Mutex<HashMap<String, String>>>,
}

impl Cache {
    fn new() -> Self {
        Cache { store: Arc::new(Mutex::new(HashMap::new())) }
    }

    fn set(&self, key: String, val: String) {
        self.store.lock().unwrap().insert(key, val);
    }

    fn get(&self, key: &str) -> Option<String> {
        self.store.lock().unwrap().get(key).cloned()
    }
}

fn main() {
    let cache = Cache::new();

    let c1 = cache.clone();
    let writer = thread::spawn(move || {
        c1.set("greeting".to_owned(), "hello".to_owned());
    });

    writer.join().unwrap();
    println!("{:?}", cache.get("greeting")); // Some("hello")
}

Gotcha: Mutex Poisoning

If a thread panics while holding a Mutex, the mutex becomes poisoned. Subsequent lock() calls return Err(PoisonError). The unwrap() on a poisoned lock will panic the calling thread too — which is often what you want (the data is in an inconsistent state), but not always.

let m = Arc::new(Mutex::new(0));
let m2 = Arc::clone(&m);

// This thread panics while holding the lock
let _ = std::thread::spawn(move || {
    let _guard = m2.lock().unwrap();
    panic!("oops");
}).join(); // join returns Err, thread panicked

// Now trying to lock returns PoisonError
match m.lock() {
    Ok(val) => println!("Got: {}", val),
    Err(poisoned) => {
        println!("Mutex poisoned, recovering inner value");
        let val = poisoned.into_inner(); // you can still access the data
        println!("Value: {}", *val);
    }
}

In long-running services, you usually want to recover: call into_inner() on the PoisonError, inspect whether the data is still valid, and continue. Blindly unwrap()-ing is acceptable in code where a poisoned lock is truly unrecoverable, but know what you’re signing up for.

Gotcha: Deadlocks

Rust prevents data races but does nothing to prevent deadlocks. This is a real footgun:

let lock_a = Mutex::new(1);
let lock_b = Mutex::new(2);

// Thread 1 acquires A then B
// Thread 2 acquires B then A
// Classic deadlock

The fix is always consistent lock ordering — if multiple mutexes must be held simultaneously, every thread acquires them in the same order. Tools like parking_lot‘s try_lock with timeout or structured concurrency patterns help here.

RwLock<T> — When Reads Are Dominant

Mutex is an exclusive lock — even reads block each other. If your shared data is read far more often than it’s written, RwLock<T> is worth reaching for:

use std::sync::RwLock;

let config = RwLock::new(HashMap::<String, String>::new());

// Multiple readers concurrently
let r1 = config.read().unwrap();
let r2 = config.read().unwrap(); // both can hold at once
drop(r1);
drop(r2);

// Exclusive writer
let mut w = config.write().unwrap(); // waits for all readers to drop
w.insert("key".to_owned(), "val".to_owned());

The tradeoff: RwLock is more complex internally and can suffer from writer starvation — if readers constantly arrive, the waiting writer never gets the lock. On most platforms std::sync::RwLock uses the OS primitives which do give writers priority, but it’s worth checking if you’re seeing stalls.


The Decision Table

Here’s how to pick without overthinking it:

Situation Tool
Copy type, single thread Cell<T>
Non-Copy type, single thread, borrow conflicts unlikely RefCell<T>
Non-Copy type, single thread, borrow conflicts possible RefCell<T> + try_borrow
Any type, multiple threads, write-heavy Arc<Mutex<T>>
Any type, multiple threads, read-heavy Arc<RwLock<T>>
High-contention integer counter AtomicU64 / AtomicUsize

If you’re in single-threaded territory, RefCell is almost always the right call unless you know the type is Copy and you want zero overhead. The compiler enforces the !Sync bound, so you can’t accidentally use it across threads.

If you’re in multi-threaded territory and find yourself wanting RefCell, stop — you need Mutex.


Production-Ready Patterns

Minimize lock scope. The most common Mutex mistake is holding the guard longer than necessary:

// Bad: guard held across the expensive_computation call
let result = {
    let guard = data.lock().unwrap();
    expensive_computation(&guard.field) // other threads blocked this whole time
};

// Good: extract what you need, drop the guard
let value = data.lock().unwrap().field.clone();
let result = expensive_computation(&value);

Never call unwrap() on lock() in a library. Libraries that panic on mutex poison make the caller’s error handling impossible. Propagate the error or call into_inner() and let the caller decide.

Consider parking_lot for serious concurrent code. The parking_lot crate’s Mutex and RwLock are faster than std’s, don’t poison, and offer try_lock_for(Duration). Drop-in replacements in most cases.

For hot paths with simple types, reach for atomics first. A Mutex<u64> works, but AtomicU64 has much lower overhead and no poisoning risk. Use Ordering::Relaxed for pure counters, SeqCst when the counter’s value controls logic visible to other threads.

use std::sync::atomic::{AtomicU64, Ordering};

static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0);

fn handle_request() {
    REQUEST_COUNT.fetch_add(1, Ordering::Relaxed);
    // ...
}

OnceLock for lazy statics. If you just need a thread-safe, once-initialized value, std::sync::OnceLock (stable since 1.70) is cleaner than wrapping in Mutex<Option<T>>:

use std::sync::OnceLock;

static CONFIG: OnceLock<String> = OnceLock::new();

fn get_config() -> &'static String {
    CONFIG.get_or_init(|| load_config_from_disk())
}

Putting It All Together

Interior mutability isn’t a hack around the borrow checker — it’s a deliberate part of the ownership model. The borrow checker enforces the rules at compile time where it can, and these types push the enforcement to runtime or to hardware synchronization primitives where compile-time checking isn’t possible.

Cell — Copy types, zero cost, single thread.
RefCell — any type, runtime borrow check, single thread, will panic if you misuse it.
Mutex — any type, OS lock, safe across threads, can deadlock.

The mental model that makes all three click: every time you use one of these types, you’re writing a contract that says "I, the programmer, know this access is safe, and here is my mechanism to enforce it." Cell says "I never hold a reference." RefCell says "I check at runtime." Mutex says "I serialize access across threads."

Pick the weakest guarantee that actually works for your use case. A Mutex where a Cell would do is needless overhead. A Cell where a Mutex is needed is a data race waiting to explode.

👁 Views: 225,562 · Unique visitors: 59,958