Skip to content
← All articles

Shared does not mean immutable

Interior mutability looks like an escape hatch that turns off the borrow checker. It is not. The check moved to runtime, it will panic, and the whole family makes sense once you rename the two reference types.

You are about to meet Cell, RefCell, OnceCell and Mutex, and there is a story about them that is easy to reach and completely wrong:

RefCell is the escape hatch you use when the borrow checker won’t let you do what you want.”

If you carry that sentence into the next five items, everything will feel arbitrary and you will scatter RefCell through your code until something panics in production. It is worth ten minutes to replace it with the true story, which starts with two badly-chosen words.

The rename that fixes everything

You have been reading &T as “immutable reference” and &mut T as “mutable reference”, because that is what every tutorial calls them, including — for years — the official Book.

Stop. The Rust Reference calls them shared and mutable, and internally the compiler team long ago settled on the more accurate pair:

&T is a SHARED reference. &mut T is an EXCLUSIVE reference.

Read that again, because almost every confusion in this track dissolves into it.

The rule the borrow checker enforces was never “you may not mutate through &T“. The rule is aliasing xor mutability: at any moment, a value may have many readers or one writer, never both. &mut is not primarily a permission to write; it is a guarantee of exclusivity, and permission to write is what that guarantee buys.

Turn it around and the whole design becomes obvious. If some other mechanism can guarantee that no two accesses conflict, then writing through a shared reference is perfectly sound — the invariant was never about the mut keyword. That other mechanism is what “interior mutability” names.

💡Why is &mut exclusivity, rather than mutability, the property the optimizer actually cares about? click to reveal

Because exclusivity is what licenses caching and reordering.

When a function takes &mut T, the compiler knows nothing else in the program can read or write that T for the duration. It can hoist a load out of a loop, keep a field in a register across a call, or reorder two writes — none of which are legal if some other pointer might be watching. LLVM is told this directly: &mut parameters are emitted with noalias.

Mutability alone would buy none of that. A C int * is mutable and aliasable, which is exactly why C compilers have to reload through pointers constantly and why C added the restrict keyword — an opt-in, unchecked version of what &mut gives Rust by default.

This also explains a rule that otherwise looks like pedantry: two &mut to the same value are forbidden even if you never write through either one. The prohibition is on the aliasing, not on the writing.

So what is interior mutability?

A type has interior mutability when it lets you mutate its contents through a shared reference&self, not &mut self — while still upholding aliasing-xor-mutability by some other means. There are exactly four means in std, and each type is defined by which one it picks:

type how it guarantees exclusivity cost can it fail?
Cell<T> never hands out a reference to the inside at all — you move values in and out free no
RefCell<T> counts live borrows at runtime one usize + a branch per borrow yes, panics
OnceCell<T> the value may only be written once one Option check no
Mutex<T> / RwLock<T> takes a lock, blocking other threads atomic op, sometimes a syscall yes, blocks / poisons

Look at the column that matters. Cell cannot fail because it never lets a reference escape, so there is nothing for a second borrow to conflict with. RefCell can fail, and that is not a defect — it is the price of the extra power, and the whole subject of two items later in this track.

And all four bottom out in the same place. UnsafeCell<T> is the only type in the language the compiler treats specially: it is the sole legal way to obtain &mut T from a &UnsafeCell<T>, because it is the sole type for which the compiler suppresses the noalias promise. Every one of the four rows above is a safe, checked wrapper over UnsafeCell. There is no fifth mechanism and no back door; if you write your own — and you will, at the end of this track — it will use UnsafeCell too.

Why you need it at all: Rc hands out &T

The concrete reason this whole family exists is one line from the previous items: Rc<T> gives you &T and can never give you &mut T. It has no idea how many other handles are live, so it cannot promise exclusivity, so it cannot hand out an exclusive reference. Try it and you get:

error[E0596]: cannot borrow data in an `Rc` as mutable
  = help: trait `DerefMut` is required to modify through a dereference,
          but it is not implemented for `Rc<Vec<i64>>`

So shared ownership plus mutation is impossible — unless the value inside the Rc is one of the types above. That is the origin of Rc<RefCell<T>>, the most reached-for and most misused pattern in Rust, which gets its own item shortly.

The three uses std actually blesses

The standard library’s own documentation is unusually direct here, and it is worth quoting the stance rather than paraphrasing it: interior mutability should be a last resort compared with ordinary inherited mutability. Lead with &mut; reach for a cell when &mut genuinely cannot express the design.

Three situations qualify.

One: mutating something inside an Rc or Arc. Shared ownership with genuine shared state — an observer list, a widget tree, an interpreter’s environment.

Two: a logically-immutable operation that caches internally. A &self method that computes something expensive and remembers it. The caller sees a read-only API and should: nothing observable changed. This is what OnceCell is for, and it is a much better fit than RefCell<Option<T>>.

Three: Clone implementations that must mutate. Clone::clone takes &self, so a type whose clone must update bookkeeping has no choice. The canonical example is Rc itself — its strong count is a Cell<usize>, and Rc::clone(&self) increments it through a shared reference. The type you have been using all track is built on the mechanism this article describes.

💡A colleague's struct has a RefCell<HashMap<String, u64>> field used as a memo cache inside &self methods. It works. What would you suggest, and what would you *not* suggest? click to reveal

Do not reflexively suggest removing it. A genuinely dynamic memo table — new keys arriving over time — is the second blessed use, and RefCell is a reasonable tool for it.

What is worth raising is the failure mode. Every borrow_mut() on that field is a potential panic, and the panic happens when some other code path holds a borrow at the same time. Two independent call sites, each correct alone, can deadlock the borrow flag in a combination neither author tested. So the questions are: are all the borrows short and non-overlapping? Does anything call back into self while a borrow is live? Is any borrow held across an await? (Clippy has a lint for that last one, await_holding_refcell_ref, and it exists because the bug is common.)

If the cache is a single computed value rather than a growing table, OnceCell replaces it outright and removes the panic risk entirely. If the value is a Copy scalar — a counter, a dirty flag, a memoised length — Cell does the same. Reaching for RefCell when Cell or OnceCell fits is the most common overshoot in this whole area.

Three lints that only make sense once you know this

declare_interior_mutable_const and borrow_interior_mutable_const. A const in Rust is not a variable — it is a value that gets copied into every place it is used. So const COUNTER: AtomicUsize = AtomicUsize::new(0); gives every use site its own private counter, and incrementing “it” a hundred times leaves every one of them at zero. Change const to static and there is one counter, shared, and it counts. The two lints catch the declaration and the use respectively, and the mistake is common enough that rustc grew its own lint for it too.

mutable_key_type. A HashMap key’s hash must not change while it is in the map, or the entry becomes unreachable — the map looks in the wrong bucket forever. A key with interior mutability can change under you, so the lint flags it. (In practice Cell<T> does not implement Hash at all, so the obvious version of this mistake is caught by the type system before clippy ever sees it. The lint is aimed at types that contain a cell while implementing Hash by hand.)

What to carry forward

  • &T is shared, &mut T is exclusive. The rule is aliasing xor mutability, and exclusivity is what the optimizer is actually buying.
  • Interior mutability is not the borrow checker switched off. It is the same rule, upheld by a different mechanism — and for RefCell that mechanism reports failure at runtime, by panicking.
  • Pick the weakest tool that fits: Cell if the value is Copy and you only ever swap it wholesale; OnceCell if it is written once; RefCell only when the borrow pattern is genuinely dynamic; Mutex only when threads are involved.
  • All of them are safe wrappers over UnsafeCell, the single type the compiler treats specially.
  • It is a last resort, not a default. If &mut self expresses your design, use &mut self.