What interior mutability means

Interior mutability is the ability to mutate data even when it is behind an immutable reference, such as &T. Rust allows this only through types that encapsulate the necessary checks or synchronization.

The key idea is simple:

  • normal Rust references are checked at compile time
  • interior mutability types move some checks to runtime
  • the mutation remains safe because the type controls access

This pattern is especially useful when:

  • multiple owners need to observe or update shared state
  • a value must be initialized lazily
  • a struct needs caching, memoization, or counters
  • you are implementing APIs that need mutation behind &self

Choosing between Cell, RefCell, and OnceCell

These types solve related but distinct problems.

TypeBest forAccess patternRuntime costThread-safe
Cell<T>Copyable values, small stateget/set by valuevery lowno
RefCell<T>Borrow-checked mutable databorrow/borrow_mutborrow trackingno
OnceCell<T>One-time initializationset once, read manylowno, unless using sync variant

A useful rule of thumb:

  • use Cell for simple scalar state like counters, flags, or enums
  • use RefCell when you need mutable access to heap data like Vec<T> or HashMap<K, V>
  • use OnceCell when a value should be initialized exactly once, often lazily

Cell<T>: lightweight mutation for copyable data

Cell<T> provides interior mutability by copying values in and out. It does not hand out references to the inner value. That makes it ideal for small Copy types.

Example: a request counter

use std::cell::Cell;

struct RequestTracker {
    requests: Cell<u64>,
}

impl RequestTracker {
    fn new() -> Self {
        Self {
            requests: Cell::new(0),
        }
    }

    fn record_request(&self) {
        let current = self.requests.get();
        self.requests.set(current + 1);
    }

    fn count(&self) -> u64 {
        self.requests.get()
    }
}

fn main() {
    let tracker = RequestTracker::new();
    tracker.record_request();
    tracker.record_request();

    println!("requests = {}", tracker.count());
}

Why Cell works well here

Cell is a strong fit when:

  • the data is small and Copy
  • you only need whole-value replacement
  • you do not need references into the data

Common examples include:

  • counters
  • boolean flags
  • state machines with small enums
  • cached IDs or version numbers

Limitations of Cell

Cell cannot give you &T or &mut T to the inner data. That means it is not suitable for modifying a Vec, appending to a String, or iterating over a collection in place. For those cases, use RefCell.

RefCell<T>: runtime borrow checking for richer data

RefCell<T> enables mutable access through shared references by enforcing Rust’s borrowing rules at runtime. It tracks whether a value is currently borrowed immutably or mutably.

If you violate the rules, the program panics.

Example: shared cache with mutation

use std::cell::RefCell;
use std::collections::HashMap;

struct Cache {
    values: RefCell<HashMap<String, String>>,
}

impl Cache {
    fn new() -> Self {
        Self {
            values: RefCell::new(HashMap::new()),
        }
    }

    fn insert(&self, key: String, value: String) {
        self.values.borrow_mut().insert(key, value);
    }

    fn get(&self, key: &str) -> Option<String> {
        self.values.borrow().get(key).cloned()
    }
}

This design is common in application code where a struct exposes methods taking &self, but still needs to update internal state.

Borrow rules at runtime

RefCell enforces the same core rules as the compiler:

  • any number of immutable borrows, or
  • exactly one mutable borrow

The difference is when the rule is checked. With RefCell, the check happens when you call borrow() or borrow_mut(), not at compile time.

Example of a borrow violation

use std::cell::RefCell;

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

    let first = data.borrow();
    let second = data.borrow();

    println!("{:?} {:?}", first, second);

    let mut mutable = data.borrow_mut(); // panics if `first` or `second` are still alive
    mutable.push(4);
}

This kind of bug is still memory-safe, but it becomes a runtime panic rather than a compile error. That makes RefCell powerful, but also something to use deliberately.

Best practices for RefCell

Prefer RefCell when:

  • the data structure is naturally mutable
  • the borrow pattern is easy to reason about
  • the code is single-threaded
  • compile-time borrowing is too restrictive for the API design

Avoid RefCell when:

  • the borrowing logic is complex or deeply nested
  • a compile-time design is possible with ownership or splitting data
  • the code is shared across threads; use synchronization primitives instead

OnceCell<T>: initialize exactly once

OnceCell<T> is designed for values that should be set only once. After initialization, the value can be read repeatedly without extra overhead.

This is useful for:

  • configuration loaded on demand
  • expensive computed values
  • global registries in single-threaded contexts
  • memoized resources that must not be reinitialized

Example: lazy configuration

use std::cell::OnceCell;

struct Config {
    api_url: OnceCell<String>,
}

impl Config {
    fn new() -> Self {
        Self {
            api_url: OnceCell::new(),
        }
    }

    fn api_url(&self) -> &str {
        self.api_url
            .get_or_init(|| "https://api.example.com".to_string())
    }
}

fn main() {
    let config = Config::new();

    println!("{}", config.api_url());
    println!("{}", config.api_url());
}

The first call initializes the string; later calls reuse the same value.

Why OnceCell is valuable

OnceCell avoids repeated computation and keeps initialization logic local to the type. It is often cleaner than storing an Option<T> and manually checking whether initialization has happened.

Compare these two approaches:

PatternProsCons
Option<T>simple, explicitmanual state handling, repeated match logic
OnceCell<T>one-time init, cleaner APIonly supports single assignment

If your data should never change after creation, OnceCell is usually a better fit than RefCell.

Designing APIs around interior mutability

A common reason to use interior mutability is to keep your public API ergonomic. Instead of requiring &mut self for every operation, you can expose methods that take &self while still updating internal state.

This is especially useful for:

  • memoized computations
  • logging or metrics counters
  • object graphs with shared ownership
  • builder-like objects that initialize lazily

Example: memoized expensive computation

use std::cell::OnceCell;

struct Report {
    raw_data: String,
    summary: OnceCell<String>,
}

impl Report {
    fn new(raw_data: String) -> Self {
        Self {
            raw_data,
            summary: OnceCell::new(),
        }
    }

    fn summary(&self) -> &str {
        self.summary.get_or_init(|| {
            let word_count = self.raw_data.split_whitespace().count();
            format!("{} words", word_count)
        })
    }
}

This pattern keeps the expensive logic hidden and ensures the summary is computed only once.

API design guidance

When using interior mutability in public APIs:

  • keep mutation localized inside methods
  • avoid exposing the interior cell directly unless necessary
  • document whether methods may panic on borrow violations
  • prefer deterministic initialization over implicit side effects

Common pitfalls

1. Overusing RefCell

RefCell can make code feel easier at first, but it can also hide design problems. If your code frequently borrows mutably and immutably in overlapping scopes, consider restructuring the data instead.

A better design may involve:

  • splitting a struct into smaller parts
  • moving ownership into helper objects
  • using iterators or closures to limit borrow lifetimes
  • replacing nested borrows with explicit phases

2. Holding borrows too long

With RefCell, the lifetime of a borrow matters at runtime. Keep borrows short and scoped.

let value = {
    let borrowed = data.borrow();
    borrowed.get("key").cloned()
}; // borrow ends here

This style reduces the chance of accidental borrow conflicts later in the function.

3. Using interior mutability across threads

Cell, RefCell, and OnceCell from std::cell are single-threaded. For concurrent code, use thread-safe equivalents such as:

  • Mutex<T>
  • RwLock<T>
  • std::sync::OnceLock<T>

Choosing the wrong type can lead to compile errors or a design that does not scale to concurrent access.

4. Treating runtime panics as normal control flow

A RefCell borrow failure is a panic, not a recoverable branch. Do not rely on it for ordinary logic. If a borrow conflict is expected, redesign the ownership model instead of catching panics.

Practical decision guide

Use this quick guide when selecting a type:

  • choose Cell<T> if the value is small, Copy, and replaced wholesale
  • choose RefCell<T> if the value is mutable, non-Copy, and accessed from a single thread
  • choose OnceCell<T> if the value is written once and read many times

In practice, many well-designed Rust systems use all three in different layers:

  • Cell for counters or flags in lightweight components
  • RefCell for local mutable graphs or caches
  • OnceCell for lazy configuration and memoized results

Testing interior mutability behavior

Because RefCell can panic at runtime, tests are a good place to validate borrow patterns and initialization behavior.

Example: verifying one-time initialization

use std::cell::OnceCell;

#[test]
fn initializes_only_once() {
    let cell = OnceCell::new();
    let first = cell.get_or_init(|| 42);
    let second = cell.get_or_init(|| 99);

    assert_eq!(*first, 42);
    assert_eq!(*second, 42);
}

This kind of test helps confirm that your API behaves predictably under repeated access.

Summary

Interior mutability is one of Rust’s most practical advanced patterns. It lets you preserve safe APIs while supporting mutation in places where compile-time borrowing would be too restrictive.

The most important takeaway is to choose the smallest tool that fits the problem:

  • Cell for cheap, copy-based state
  • RefCell for runtime-checked mutable access
  • OnceCell for one-time initialization

Used carefully, these types help you build ergonomic, efficient Rust APIs without abandoning safety.

Learn more with useful resources