Interior Mutability & Lazy Initialization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Rust’s borrowing rule — shared or mutable, never both — is checked at compile time. Some designs need to mutate data that is reachable through a shared reference: a cache inside an otherwise immutable object, a counter updated by callbacks, a node in a graph shared by Rc. Interior mutability types allow that, moving the check to run time or restricting what can be done so that it stays sound. The same machinery underlies lazy initialization of values computed on first use.

Why it needs special types

Through &T you normally cannot mutate. That guarantee is what lets the compiler assume a value behind &T does not change. Interior-mutability types are built on UnsafeCell<T>, the one primitive that tells the compiler "this memory may change even while shared". Every safe wrapper adds a rule that keeps it sound:

Type How it stays sound Cost Threads

Cell<T>

never hands out references to the inside; only copies, swaps and replaces values

none

single

RefCell<T>

tracks borrows at run time; panics (or returns Err) on a conflicting borrow

a borrow counter

single

OnceCell<T> / LazyCell<T>

can be written only once

a flag

single

Mutex<T> / RwLock<T>

blocks other threads while a guard is held

a lock

multi

AtomicU64, AtomicBool, …​

CPU atomic instructions

memory ordering

multi

OnceLock<T> / LazyLock<T>

one-time initialisation with synchronisation

a sync flag

multi

Cell<T>

Cell<T> holds a value you can get (for T: Copy), set, replace, take and — since 1.88 — update with a closure, all through &self. Because it never gives out a reference to its contents, there is nothing to invalidate. It is ideal for small Copy values such as counters and flags:

use std::cell::Cell;

struct Book {
    title: String,
    views: Cell<u32>,          // can change even through &Book
}

impl Book {
    fn view(&self) -> &str {   // &self, yet it updates a counter
        self.views.update(|v| v + 1); // Cell::update: 1.88
        &self.title
    }
}

fn main() {
    let b = Book { title: "Dune".into(), views: Cell::new(0) };
    b.view();
    b.view();
    println!("{} viewed {} times", b.title, b.views.get());
    let old = b.views.replace(100);
    println!("{old} -> {}", b.views.get());
}

Source: std::cell::Cell

RefCell<T>: borrow checking at run time

RefCell<T> hands out references to its contents: borrow() returns a Ref<T> (shared) and borrow_mut() a RefMut<T> (exclusive). The same aliasing rules apply, but they are checked when the program runs. A violation panics with "already borrowed: BorrowMutError"; the try_borrow* methods return a Result instead.

use std::cell::RefCell;

fn main() {
    let shelf = RefCell::new(vec!["Dune".to_string()]);

    shelf.borrow_mut().push("Emma".into());               // guard dropped at end of statement
    println!("{} books", shelf.borrow().len());

    {
        let reader = shelf.borrow();                       // a shared borrow is alive...
        println!("first: {}", reader[0]);
        let attempt = shelf.try_borrow_mut();              // ...so a mutable one fails
        println!("try_borrow_mut while reading: {}", attempt.is_err()); // true
    }                                                      // reader dropped here

    let mut writer = shelf.borrow_mut();                   // fine now
    writer.sort();
    // let again = shelf.borrow();                         // would panic: already mutably borrowed
}

Keep Ref/RefMut guards short-lived — ideally within one statement — and never hold one across a call that might borrow the same cell again (a callback, a recursive method). Since the 2024 edition, temporaries in an if let condition are dropped before the else branch, which removes one classic source of BorrowMutError panics (see Control Flow).

Rc<RefCell<T>>: shared, mutable data in one thread

Rc gives several owners; RefCell lets them mutate. Together they model shared mutable object graphs in single-threaded code, such as observers or a GUI model:

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

#[derive(Debug, Default)]
struct Stats {
    loans: u32,
}

struct Desk {
    stats: Rc<RefCell<Stats>>,
}

impl Desk {
    fn lend(&self) {
        self.stats.borrow_mut().loans += 1;
    }
}

fn main() {
    let stats = Rc::new(RefCell::new(Stats::default()));
    let front = Desk { stats: Rc::clone(&stats) };
    let back = Desk { stats: Rc::clone(&stats) };
    front.lend();
    back.lend();
    println!("{:?}", stats.borrow()); // Stats { loans: 2 }
}

Rc<RefCell<T>> is the single-threaded counterpart of Arc<Mutex<T>>. Reaching for it frequently can be a sign that ownership could be structured more simply (pass &mut down, or use indices into a Vec), but it is the right tool for genuinely shared mutable state.

Thread-safe analogues

Cell and RefCell are !Sync: the compiler refuses to share them between threads. The multi-threaded equivalents are Mutex<T> (one accessor at a time), RwLock<T> (many readers or one writer) and the atomic integer types; they are covered on Shared State, Send/Sync & Atomics.

Initialise once: OnceCell and OnceLock

OnceCell<T> (single-threaded) and OnceLock<T> (thread-safe, 1.70) start empty and can be set exactly once; get_or_init(|| ...) runs the initialiser on first access and returns a reference afterwards:

use std::collections::HashMap;
use std::sync::OnceLock;

fn genres() -> &'static HashMap<&'static str, &'static str> {
    static GENRES: OnceLock<HashMap<&str, &str>> = OnceLock::new();
    GENRES.get_or_init(|| {
        println!("building genre table");          // runs once, even with many threads
        HashMap::from([("SF", "Science fiction"), ("FIC", "Fiction")])
    })
}

fn main() {
    println!("{:?}", genres().get("SF"));
    println!("{:?}", genres().get("FIC"));         // no second "building" line
}

Lazy values: LazyCell and LazyLock (1.80)

LazyCell<T, F> and LazyLock<T, F> bundle the initialiser with the cell: the value is computed on first dereference. LazyLock is the standard replacement for the lazy_static! macro and the once_cell::sync::Lazy type, and works in static items:

use std::sync::LazyLock;

struct Config {
    max_loans: u32,
    library_name: String,
}

static CONFIG: LazyLock<Config> = LazyLock::new(|| {
    let max_loans = std::env::var("SHELF_MAX_LOANS").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
    Config { max_loans, library_name: "Shelf".to_string() }
});

fn main() {
    println!("{} allows {} loans", CONFIG.library_name, CONFIG.max_loans); // initialised here
}

If the initialiser panics, the LazyLock is poisoned and every later access panics too. For values that need configuration passed in at start-up (a database pool, a secret key), prefer constructing them in main and passing them down — in axum, through application state — rather than reaching for a global; globals make testing harder.

Choosing

Single-threaded Multi-threaded

Copy a small value in and out

Cell<T>

AtomicU32, AtomicBool, …​

Borrow and mutate in place

RefCell<T>

Mutex<T>, RwLock<T>

Set once, read many times

OnceCell<T>

OnceLock<T>

Compute on first use

LazyCell<T>

LazyLock<T>

Per-thread state

 — 

thread_local! with Cell/RefCell inside

Where the books are outdated

Yoon (Beginning Axum, chapter 4) keeps a JWT secret in a lazy_static! global. Since Rust 1.80, std::sync::LazyLock replaces lazy_static! without a macro or dependency; and for secrets specifically, loading them at start-up into application state is preferable to any global (see Axum: JWT & Bearer Tokens). Cell::update (1.88) shortens the common cell.set(cell.get() + 1) pattern.