Interior Mutability — Cell, RefCell, and Mutex Finally Explained

rust dev.to

The first time I ran into this, I had a struct with a method that took &self, and inside that method I just wanted to bump a counter by one.

struct Logger {
    count: u32,
}

impl Logger {
    fn log(&self, message: &str) {
        self.count += 1; // ❌ won't compile
        println!("[{}] {}", self.count, message);
    }
}
Enter fullscreen mode Exit fullscreen mode

Rust said no.

error[E0596]: cannot borrow `self.count` as mutable,
as it is behind a `&` reference
Enter fullscreen mode Exit fullscreen mode

I remember thinking — it's ONE number. I'm not doing anything dangerous. Why is the compiler being like this?

Every explanation I found jumped straight into "interior mutability allows mutation through an immutable reference using unsafe internally, abstracted safely." Cool sentence. Meant nothing to me at the time.

So let's do this the same way we did closures. Real life first. Rust second.

A Real Life Example First

Imagine you own a notebook, and you've decided the notebook itself is not allowed to be edited — that's the rule you've set. But you still need a way to track how many times people have flipped through it.

You've got three options for handling this.

Option 1 — The Sticky Note

You tape a small sticky note to the cover. Anyone holding the notebook can peel it off, write a new number, and stick it back — without ever "opening" the notebook itself. It's fast, nobody needs permission, and there's no risk because it's just a number being swapped out.

Option 2 — The Sign-In Sheet Inside

You keep a sign-in sheet clipped inside the notebook. Anyone can flip to it and write on it — but only one person can be writing on that sheet at a time. If two people try to grab a pen for it simultaneously, you stop them and make them wait their turn. You enforce this yourself, on the spot, every time someone reaches for it.

Option 3 — The Shared Notebook Across Two Offices

Now imagine the notebook isn't just passed around one room — it's shared between two separate offices, and people in both offices need to write in it. A sticky note isn't safe here, because two people in two different offices could grab it at the exact same instant. You need an actual lock on the sign-in sheet — something that physically stops a second person from touching it until the first person is done.

That's it. That's the whole idea.

📝 Sticky Note = Cell<T> — swap the value out, no borrowing involved

📋 Sign-In Sheet = RefCell<T> — borrow it, but rules get enforced on the spot

🔒 Locked Sheet = Mutex<T> — same idea, but safe across threads

Keep that in your head. Now let's bring it into Rust.

What "Interior Mutability" Actually Means

Rust's normal rule is simple: if you have &self, you can't mutate anything inside it. Mutation requires &mut self.

Interior mutability is the name for a small set of types that break this rule — safely — by moving the check from compile time to a different point: either "no references handed out at all" (Cell), or "checked the instant you ask for a borrow" (RefCell, Mutex).

The type still enforces Rust's core rule — no mutable access while something else is reading. It just enforces it at a different moment than the compiler normally does.

Cell<T> — The Sticky Note

use std::cell::Cell;

struct Logger {
    count: Cell<u32>,
}

impl Logger {
    fn log(&self, message: &str) {
        self.count.set(self.count.get() + 1);
        println!("[{}] {}", self.count.get(), message);
    }
}

fn main() {
    let logger = Logger { count: Cell::new(0) };
    logger.log("server started");
    logger.log("listening on port 8080");
}
Enter fullscreen mode Exit fullscreen mode

Output:

[1] server started
[2] listening on port 8080
Enter fullscreen mode Exit fullscreen mode

No borrowing happens here at all. .get() copies the value out. .set() writes a new value in. There's never a reference to fight over — which is exactly why this is the cheapest, simplest option, and also why it only works for small, Copy types like numbers, booleans, and simple enums.

The sticky note analogy: nobody ever "holds" the note while reading it — they just glance at the number and swap it out. Since nobody's holding onto it, there's nothing to conflict over.

RefCell<T> — The Sign-In Sheet

Cell falls apart the moment you want to store something bigger — a Vec, a String, a whole struct. You don't want to copy a Vec every time you read it. You want to actually borrow it, the normal Rust way — you just want to do that borrowing through a shared reference.

That's RefCell<T>.

use std::cell::RefCell;

struct Cache {
    items: RefCell<Vec<String>>,
}

impl Cache {
    fn add(&self, item: String) {
        self.items.borrow_mut().push(item);
    }

    fn print_all(&self) {
        for item in self.items.borrow().iter() {
            println!("{}", item);
        }
    }
}

fn main() {
    let cache = Cache { items: RefCell::new(vec![]) };
    cache.add(String::from("first"));
    cache.add(String::from("second"));
    cache.print_all();
}
Enter fullscreen mode Exit fullscreen mode

Output:

first
second
Enter fullscreen mode Exit fullscreen mode

.borrow() gives you shared, read-only access. .borrow_mut() gives you exclusive, mutable access. Under the hood, RefCell keeps a tiny counter tracking how many borrows are currently active — exactly like someone standing next to the sign-in sheet, watching who's holding the pen.

Try to grab the pen twice at once, and it stops you:

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

let first = cache.borrow_mut();
let second = cache.borrow_mut(); // 💥 panics at runtime
Enter fullscreen mode Exit fullscreen mode
thread 'main' panicked at 'already borrowed: BorrowMutError'
Enter fullscreen mode Exit fullscreen mode

This is the trade you're making. A mistake that the compiler would normally catch before your code even runs now shows up as a panic while your program is running. That's a real cost, not a free lunch — RefCell isn't a way to trick the compiler, it's a way to move the same check somewhere else, at the price of it being able to fail live instead of at compile time.

Where this shows up in real code: trees, graphs, and anything where a child needs to reach back and touch its parent, or siblings need to notify each other. This is common enough to have its own name — Rc<RefCell<T>>, which we'll get to in a second.

Why Doesn't RefCell Just Work Across Threads Too?

Because its "sign-in sheet" counter is a plain number, and two threads incrementing a plain number at the same time is a data race — the exact thing Rust exists to prevent.

Rust actually catches this at compile time. Try to share a RefCell across threads and you get:

error[E0277]: `RefCell<u32>` cannot be shared between threads safely
Enter fullscreen mode Exit fullscreen mode

Which brings us to office #3.

Mutex<T> — The Locked Sign-In Sheet

Mutex<T> solves the exact same problem as RefCell — mutation through a shared reference — but backs it with an actual lock, so it's safe when multiple threads reach for it at the same time.

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

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

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

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

    println!("Result: {}", *counter.lock().unwrap());
}
Enter fullscreen mode Exit fullscreen mode

Output:

Result: 10
Enter fullscreen mode Exit fullscreen mode

.lock() is the equivalent of .borrow_mut() — except instead of a quick counter check, it's an actual OS-level lock. If another thread already holds it, your thread physically waits until it's free. Same idea as the sign-in sheet, just enforced with a real lock instead of an honor system, because now two different "offices" (threads) are involved.

Notice the Arc wrapping the Mutex. That's not optional — and it's worth its own explanation.

Rc<RefCell<T>> and Arc<Mutex<T>> — The Combo You'll Actually See

Here's the part that trips people up: RefCell and Mutex solve mutation. They don't solve sharing. If you want multiple parts of your program to own the same data, you still need something to give you multiple owners in the first place.

That's what Rc and Arc are for — reference-counted pointers.

Single-threaded — Rc<RefCell<T>>

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

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

    let a = Rc::clone(&shared);
    let b = Rc::clone(&shared);

    a.borrow_mut().push(4);
    b.borrow_mut().push(5);

    println!("{:?}", shared.borrow());
}
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

Rc hands out multiple owners of the same notebook. RefCell lets any of those owners actually write in it, with the sign-in sheet enforcing turns. Together, they cover a case plain ownership rules can't: multiple things needing to both own and mutate the same data.

Multi-threaded — Arc<Mutex<T>>

Same pattern, thread-safe versions of both pieces — this is the one from the counter example above. Arc is Rc with an atomic (thread-safe) counter. Mutex is RefCell with a real lock instead of a soft check. If you've read almost any multithreaded Rust code, you've seen Arc<Mutex<T>> sitting somewhere in it — it's the default answer to "multiple threads need to share and mutate this."

Quick Reference — The One Table You Need

Type Access style Cost Real life
Cell<T> Copy in, copy out Cheapest — no runtime check The sticky note — swap the number, no borrowing
RefCell<T> Borrow, checked at runtime Panics if rules are broken The sign-in sheet — one pen at a time, on the honor system
Mutex<T> Borrow, thread-safe locking Real OS lock, some overhead The locked sheet — safe across two offices

When to Actually Reach for Each One

Use Cell<T> when:

  • The value is small and Copy — counters, flags, simple IDs
  • You never need to hand out a reference to it, only read/write the whole value

Use RefCell<T> when:

  • You're single-threaded
  • Multiple owners need to mutate shared, non-Copy data (trees, graphs, caches)
  • You're comfortable treating a BorrowMutError panic as a real bug to fix, not background noise

Use Mutex<T> (with Arc) when:

  • The same problem shows up across threads
  • Don't reach for it in single-threaded code out of habit — it's real locking overhead you don't need

And honestly — before reaching for any of these, ask if the data structure itself could be redesigned so nobody needs shared mutation in the first place. Interior mutability should be a deliberate choice, not the first thing you try when the borrow checker pushes back.

Why This Is Rust Being Rust (Again)

Same story as the closures post. Rust's whole thing is: the safety guarantee doesn't disappear just because the compiler can't prove it upfront. It just moves — from compile time to a small, explicit runtime check, in exactly the types built for that job.

You're never turning safety off. You're telling Rust exactly where and how to keep checking.

📝 Cell = swap the sticky note, no questions asked

📋 RefCell = borrow it, but the sign-in sheet is watching

🔒 Mutex = same sheet, now with an actual lock on it

The notebook still has rules. It just checks them at a different moment.


If this made things click, drop a comment — I'd love to know which analogy landed. And if you haven't read the one on Fn, FnMut, and FnOnce yet, that's a good companion piece — closures and interior mutability both come down to the same question: who's allowed to touch what, and when. 👇

Source: dev.to

arrow_back Back to Tutorials