Home Design Patterns Interior Mutability

Interior Mutability

Ownership and Lifetimes

Mutate through a shared reference by moving the borrow check from compile time to run time.

Purpose

Rust's central rule is that you may have many shared references to a value or one exclusive reference to it, never both. Interior mutability is the sanctioned way around that rule for cases where it is too strict: a value that everyone can see, and that something needs to change.

The rule is not abandoned. It is still enforced, just later, by a flag checked at run time instead of by the compiler. Break it and the program panics rather than failing to build. That is a real downgrade and the pattern should be understood as a trade rather than a feature.

Design

The standard library offers a graded set of these, and picking the cheapest one that fits is most of the skill:

  • Cell<T> holds a Copy value and lets you get and set it. There are no references handed out, so there is nothing to check and nothing that can panic.
  • RefCell<T> hands out references and tracks how many are outstanding. Asking for a mutable borrow while any other borrow is live panics.
  • Mutex<T> and RwLock<T> do the same job across threads, blocking rather than panicking.
  • Atomics do it for single primitive values with no lock at all.
UML class diagram: ArmModel holds a RefCell whose borrow flag is checked at run time, allowing a method taking a shared reference to write to a cache.
The borrow flag inside RefCell is doing at run time what the compiler would otherwise have done at compile time. Everything else about the diagram is ordinary composition.

Example

Autobot's office reporting client holds a model of each arm on the floor. Asking a model for its calibration factor is an expensive operation involving a lookup and some arithmetic, and the answer never changes for the life of the object, so it obviously wants to be cached.

The problem is that reading a calibration is conceptually a read. Every caller holds a shared reference, dozens of them exist at once across the reporting views, and the signature that makes sense is fn calibration(&self). Caching means writing. Rust will not let a method taking a shared reference write to a field, and rewriting every caller to take an exclusive reference so that one private cache can be filled would be the tail wagging the dog.

RefCell is the right answer here, and it is worth being precise about why: the mutation is genuinely invisible from outside. Two callers cannot observe a difference in behavior depending on which of them ran first, because the cached value is the same value the computation would have produced anyway.

use std::cell::RefCell;

struct ArmModel {
    id: u8,
    cache: RefCell<Option<f32>>,
}

impl ArmModel {
    fn calibration(&self) -> f32 {
        let cached = *self.cache.borrow();
        if let Some(value) = cached {
            return value;
        }

        let computed = self.measure();
        *self.cache.borrow_mut() = Some(computed);
        computed
    }

    fn measure(&self) -> f32 {
        // expensive, and always produces the same answer
        1.0
    }
}
Note that the shared borrow is read into a local and released before borrow_mut is called. Holding both at once compiles perfectly and panics at run time, which is the characteristic bug of this pattern.

Seen in the Wild

Rc<RefCell<T>> is the standard shape for a shared mutable graph in safe Rust, and it appears in almost every tutorial that builds a linked structure. In the standard library, OnceCell and LazyLock are interior mutability specialized for initialize-once data, and every Mutex in every threaded Rust program is the same mechanism with blocking instead of panicking. The idea is not Rust-specific: C++'s mutable member qualifier exists precisely so that a const method can update a cache, which is this pattern with the check removed entirely.

Comparisons

  • Arena is the alternative worth trying first. Most Rc<RefCell<T>> graphs are arenas that have not realized it yet, and converting to indices removes both the refcount and the runtime borrow check.
  • Proxy describes what RefCell is: a protection proxy that guards access, except that what it protects is not the data but the borrowing rule.
  • Observer cannot be written in safe Rust without something from this family. The subject holds the observers, the observers frequently need to reach back, and that is a cycle requiring Weak alongside the interior mutability.
  • Lazy Load is the most common use of it, and the caching example above is exactly that.
  • RAII and Drop Guards is how the borrow is tracked. The Ref and RefMut values are guards, and the flag is cleared when they drop.

Why It Exists

My take

Interior mutability exists because Rust's borrow checker is a conservative approximation, and every conservative approximation rejects programs that are actually fine. The checker proves the absence of a class of bug, and the price of a proof is that anything it cannot prove is refused, whether or not it was correct. This pattern is the pressure valve that keeps the language usable, and needing it at all is an honest admission that static analysis has limits.

The part I think is under-appreciated is that it is a downgrade, and the documentation is generally too cheerful about that. Rust's pitch is that a whole category of error becomes a compile failure. Reaching for RefCell takes one specific error and moves it back to run time, in a form that is harder to debug than the C++ equivalent would have been, because the offending borrow is often a temporary created inside a function three levels away. That can absolutely be the right trade. It should be a conscious one.

My rule of thumb is to ask whether the mutation is observable. Filling a cache is not: no caller can tell. Mutating shared application state through a handle everybody holds absolutely is, and reaching for RefCell there is usually a sign that the ownership model was never worked out and the borrow checker is being argued with rather than listened to. The first case is the pattern. The second is a refactor being deferred.

Criticisms

It converts a compile error into a panic, in a language whose entire proposition is the reverse. A double borrow is not caught by review, is not caught by the type system, and may not be caught in testing if the two borrows only overlap on an uncommon path. The failure then arrives in production as a process abort.

Those panics are unusually hard to diagnose. The message tells you a borrow was already active, not where it was taken, and the culprit is regularly a temporary whose lifetime extends further than the author expected. The classic version is calling a method that borrows while already holding a borrow across a statement, which reads as obviously fine and is not.

Rc<RefCell<T>> also brings the reference-counting problems along with it. Cycles leak, because a cycle keeps its own refcount above zero forever, and the fix is to demote one direction to Weak and handle the resulting Option at every access. Beyond that, the combination is the single most common crutch among people new to the language: it makes the compiler stop complaining, which feels like progress, while leaving the actual ownership question unanswered. If you are reaching for it more than occasionally, the design underneath is usually the thing that needs attention.