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 |
|---|---|---|---|
|
never hands out references to the inside; only copies, swaps and replaces values |
none |
single |
|
tracks borrows at run time; panics (or returns |
a borrow counter |
single |
|
can be written only once |
a flag |
single |
|
blocks other threads while a guard is held |
a lock |
multi |
|
CPU atomic instructions |
memory ordering |
multi |
|
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
}
Source: std::sync::OnceLock
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
}
Source: std::sync::LazyLock
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 |
|
|
Borrow and mutate in place |
|
|
Set once, read many times |
|
|
Compute on first use |
|
|
Per-thread state |
— |
|
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.
References
-
The Rust Programming Language: RefCell<T> and the Interior Mutability Pattern
-
std::cell (Cell, RefCell, OnceCell, LazyCell, UnsafeCell)