
Rust Interior Mutability with `Cell`, `RefCell`, and `OnceCell`
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.
| Type | Best for | Access pattern | Runtime cost | Thread-safe |
|---|---|---|---|---|
Cell<T> | Copyable values, small state | get/set by value | very low | no |
RefCell<T> | Borrow-checked mutable data | borrow/borrow_mut | borrow tracking | no |
OnceCell<T> | One-time initialization | set once, read many | low | no, unless using sync variant |
A useful rule of thumb:
- use
Cellfor simple scalar state like counters, flags, or enums - use
RefCellwhen you need mutable access to heap data likeVec<T>orHashMap<K, V> - use
OnceCellwhen 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:
| Pattern | Pros | Cons |
|---|---|---|
Option<T> | simple, explicit | manual state handling, repeated match logic |
OnceCell<T> | one-time init, cleaner API | only 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 hereThis 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:
Cellfor counters or flags in lightweight componentsRefCellfor local mutable graphs or cachesOnceCellfor 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:
Cellfor cheap, copy-based stateRefCellfor runtime-checked mutable accessOnceCellfor one-time initialization
Used carefully, these types help you build ergonomic, efficient Rust APIs without abandoning safety.
