Cell<T>: Cheap Interior Mutability for Copy Types
17 min read
Quick Overview
Section titled “Quick Overview”Cell<T> is Rust’s lightest-weight tool for interior mutability: it lets you mutate a value through a shared & reference, with zero runtime cost and no borrow tracking. The trade-off is that you can never get a reference into a Cell — you only ever move whole values in and out with get and set, which is why it shines for small Copy types like counters, flags, and IDs.
For a TypeScript/JavaScript developer, the surprising part is not the mutation (everything mutates in JS) but why you’d need a special type for it at all. In Rust, a &self method normally cannot change anything; Cell<T> is the escape hatch that keeps the method signature &self while still letting a field change.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In JavaScript, an object method can freely mutate its own fields. There is no concept of a “read-only reference” enforced at the language level, so a render() method that bumps a private counter is completely ordinary:
// widget.ts — a render counter on an otherwise "read-only" objectclass Widget { label: string; private renderCount: number;
constructor(label: string) { this.label = label; this.renderCount = 0; }
// Looks like a read-only accessor, but it quietly mutates state. render(): string { this.renderCount += 1; return `${this.label} (rendered ${this.renderCount} times)`; }}
const w = new Widget("Button");console.log(w.render());console.log(w.render());console.log(w.render());Running it with Node v22 (node --experimental-strip-types widget.ts) prints:
Button (rendered 1 times)Button (rendered 2 times)Button (rendered 3 times)JavaScript does not care that render “reads like” an accessor — there is no compile-time notion of immutability to violate. Even a const w only freezes the binding, not the object’s fields (w.renderCount could still change). Rust draws that line much more strictly, and Cell<T> is how you cross it on purpose.
Rust Equivalent
Section titled “Rust Equivalent”The naive Rust translation — a render(&self) that does self.render_count += 1 — does not compile, because &self is a shared (read-only) reference. Wrapping the field in Cell<u32> makes it work while keeping the &self signature:
use std::cell::Cell;
struct Widget { label: String, render_count: Cell<u32>, // interior mutability for a Copy field}
impl Widget { fn render(&self) -> String { // get() copies the value out, set() stores a new value back in. self.render_count.set(self.render_count.get() + 1); format!("{} (rendered {} times)", self.label, self.render_count.get()) }}
fn main() { let w = Widget { label: "Button".to_string(), render_count: Cell::new(0), }; println!("{}", w.render()); println!("{}", w.render()); println!("{}", w.render());}This compiles and prints exactly the same output as the TypeScript version:
Button (rendered 1 times)Button (rendered 2 times)Button (rendered 3 times)Note:
wis not declaredmut. That is the whole point —Cell<T>lets the inner value change even though the binding and the&selfreference are immutable. The mutability is “interior” to theCell.
Detailed Explanation
Section titled “Detailed Explanation”The problem Cell solves
Section titled “The problem Cell solves”Rust’s borrow rules can be summed up as: at any moment a value may have either many shared & references or exactly one mutable &mut reference, never both. Methods that take &self therefore promise “I will not mutate.” That promise is enforced by the compiler:
struct Widget { render_count: u32,}impl Widget { fn render(&self) { self.render_count += 1; // does not compile (error[E0594]) }}fn main() { let w = Widget { render_count: 0 }; w.render();}The real compiler error is:
error[E0594]: cannot assign to `self.render_count`, which is behind a `&` reference --> src/main.rs:6:9 |6 | self.render_count += 1; // does not compile (error[E0594]) | ^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written |help: consider changing this to be a mutable reference |5 | fn render(&mut self) { | +++Changing to &mut self is one fix, but it forces every caller to hold a unique mutable handle — impossible when the widget is shared (behind an Rc, inside a Vec you’re iterating over, or used by multiple callbacks). Cell<T> lets you keep &self and still mutate. This is called interior mutability: from the outside the value looks immutable, but it carries a sanctioned mutable interior.
get and set: move values, never borrow
Section titled “get and set: move values, never borrow”Cell<T> deliberately exposes a tiny surface:
use std::cell::Cell;
fn main() { let counter = Cell::new(0); counter.set(counter.get() + 1); // read out, compute, store back counter.set(counter.get() + 1); println!("counter = {}", counter.get());
// A shared &reference is enough to mutate — no `mut` anywhere. let c = &counter; c.set(42); println!("via shared ref = {}", counter.get());}Output:
counter = 2via shared ref = 42get(&self) -> Treturns a copy of the inner value. It is only available whenT: Copy, which is whyCellis a fit for numbers,bool,char, smallenums, and otherCopydata.set(&self, value: T)overwrites the inner value, dropping the old one.
Crucially, neither method ever hands out a &T or &mut T pointing inside the Cell. Because no reference into the cell can exist, there is no way to observe a half-written value or to alias it — so no borrow checking is needed, and Cell is sound with literally zero bookkeeping. That is the deep reason get requires Copy: handing you a copy means you walk away with your own value, not a borrow of the cell’s.
Other useful methods
Section titled “Other useful methods”Cell<T> has a few more move-in/move-out helpers. These all take &self:
use std::cell::Cell;
fn main() { let a = Cell::new(10);
let old = a.replace(20); // set new, return the old value println!("replace: old={}, new={}", old, a.get());
let taken = a.take(); // available when T: Default; leaves T::default() println!("take: taken={}, now={}", taken, a.get());
let x = Cell::new(1); let y = Cell::new(2); x.swap(&y); // swap the contents of two cells println!("swap: x={}, y={}", x.get(), y.get());}Output:
replace: old=10, new=20take: taken=20, now=0swap: x=2, y=1There is also update, stabilized in Rust 1.88, which reads, applies a closure, and stores the result — handy for the very common “increment” pattern:
use std::cell::Cell;
fn main() { let c = Cell::new(5); c.update(|x| x + 1); // equivalent to c.set(c.get() + 1) println!("{}", c.get());}Output:
6When you do have &mut, the overhead vanishes
Section titled “When you do have &mut, the overhead vanishes”If you happen to hold a &mut Cell<T>, the cell stops being necessary, and Rust gives you direct, free access to the inside:
use std::cell::Cell;
fn main() { let mut c = Cell::new(5);
// get_mut requires &mut self and returns a real &mut T into the cell. let inner: &mut i32 = c.get_mut(); *inner += 10; println!("after get_mut: {}", c.get());
// into_inner consumes the Cell and returns the owned value. let owned = c.into_inner(); println!("into_inner: {}", owned);
// Cell<T> is exactly the same size as T — no tag, no flag, no overhead. println!("size_of::<i32>() = {}", std::mem::size_of::<i32>()); println!("size_of::<Cell<i32>>() = {}", std::mem::size_of::<Cell<i32>>());}Output:
after get_mut: 15into_inner: 15size_of::<i32>() = 4size_of::<Cell<i32>>() = 4get_mut is safe precisely because &mut self proves no other reference to the cell exists, so a real &mut T cannot alias anything. And as the last two lines show, Cell<i32> occupies the same 4 bytes as a bare i32 — wrapping a field in Cell costs nothing in memory.
Key Differences
Section titled “Key Differences”| Concept | TypeScript/JavaScript | Rust Cell<T> |
|---|---|---|
| Default mutability | Everything mutable; no read-only references | &self is read-only; Cell is an explicit opt-in to mutate |
| How you read | obj.field returns the live value | cell.get() returns a copy (requires T: Copy) |
| How you write | obj.field = x | cell.set(x) |
| References into it | You always get a live reference to the object | Never — you cannot borrow inside a Cell |
| Runtime cost | N/A | Zero — no flags, locks, or counters |
| Thread safety | Single-threaded event loop by default | !Sync — single-thread only (use Atomic*/Mutex across threads) |
| Failure mode | Runtime undefined/exceptions | None — Cell cannot panic |
Cell versus its siblings
Section titled “Cell versus its siblings”The smart-pointer family splits the job of “mutate through a shared reference” along two axes — what you store and single- vs multi-threaded:
| Need | Reach for |
|---|---|
Mutate a small Copy value, single thread, no overhead | Cell<T> (this file) |
Mutate a non-Copy value (e.g. String, Vec) and hand out borrows, single thread | RefCell<T> |
| Mutate shared state across threads | Mutex<T> / RwLock<T> or std::sync::atomic::* |
| Share ownership of the cell among many holders | Rc<Cell<T>> (single thread) |
The mental rule: if T: Copy and you never need a borrow into it, prefer Cell — it is the cheapest tool and it can never panic, unlike RefCell, which trades a runtime borrow check (and possible panic) for the ability to hand out references.
Tip: A common idiom is a counter shared across closures or graph nodes:
Rc<Cell<u32>>. TheRcshares ownership; theCellprovides the cheap mutation. You almost never wantRc<RefCell<u32>>for a plain integer.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Trying to use Cell with a non-Copy type and calling get
Section titled “Pitfall 1: Trying to use Cell with a non-Copy type and calling get”get clones the value out by copying, so it is gated on T: Copy. A Cell<String> compiles, but .get() on it does not:
use std::cell::Cell;
fn main() { let c = Cell::new(String::from("hi")); let s = c.get(); // does not compile (error[E0599]): String is not Copy println!("{}", s);}Real compiler error:
error[E0599]: the method `get` exists for struct `Cell<String>`, but its trait bounds were not satisfied --> src/main.rs:5:15 |5 | let s = c.get(); // does not compile (error[E0599]): String is not Copy | ^^^ | ::: .../library/alloc/src/string.rs:360:1 | | pub struct String { | ----------------- doesn't satisfy `String: Copy` | = note: the following trait bounds were not satisfied: `String: Copy`Fix: For non-Copy data you want to mutate behind &self, use RefCell<T> (which hands out borrows), or use Cell’s move-only methods like take/replace that do not require Copy. For example c.take() works on Cell<String> because it moves the String out and leaves an empty String (Default) behind.
Pitfall 2: Expecting to borrow the value inside a Cell
Section titled “Pitfall 2: Expecting to borrow the value inside a Cell”There is intentionally no &self method that returns a reference into a Cell. If you find yourself wanting &cell.contents to, say, iterate over a Vec stored inside, Cell is the wrong tool. You would have to get() a whole copy out (impossible for a Vec since it is not Copy) or take() it, mutate the owned copy, and set() it back. That is awkward by design.
Fix: Reach for RefCell<T>, whose borrow() / borrow_mut() give you references into the contents (at the cost of a runtime borrow check that can panic).
Pitfall 3: Sharing a Cell across threads
Section titled “Pitfall 3: Sharing a Cell across threads”Cell<T> is !Sync: a &Cell<T> may not be sent to another thread, because two threads writing to the same cell with no synchronization is a data race. The compiler stops you:
use std::cell::Cell;use std::thread;
fn main() { let counter = Cell::new(0); thread::scope(|s| { s.spawn(|| { counter.set(counter.get() + 1); // does not compile (error[E0277]) }); }); println!("{}", counter.get());}Real compiler error (abbreviated):
error[E0277]: `Cell<i32>` cannot be shared between threads safely --> src/main.rs:7:17 |7 | s.spawn(|| { | ^^ `Cell<i32>` cannot be shared between threads safely | = help: the trait `Sync` is not implemented for `Cell<i32>` = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead = note: required for `&Cell<i32>` to implement `Send`Fix: As the compiler itself suggests, use an atomic such as std::sync::atomic::AtomicI32 for a shared counter, or a Mutex/RwLock for larger state. See RefCell vs Mutex and the async section for shared-state patterns across tasks.
Pitfall 4: Reaching for Cell (or RefCell) too early
Section titled “Pitfall 4: Reaching for Cell (or RefCell) too early”Coming from JavaScript, interior mutability feels natural and you may sprinkle Cell everywhere. In idiomatic Rust it is the exception, not the rule. If a plain &mut self works — and it usually does — prefer it: the compiler then guarantees no aliasing for free.
Fix: Default to ordinary ownership and &mut. Reach for Cell only when you genuinely need to mutate through a shared reference (shared graph nodes, observer counters, flags inside an Rc, caches keyed by &self).
Best Practices
Section titled “Best Practices”- Use
Cellfor smallCopystate mutated behind&self: counters, version numbers, generation IDs, dirty/visited flags, cachedOption<T>of aCopyvalue. - Prefer
CelloverRefCellwhenT: Copy: it is cheaper and cannot panic. Only step up toRefCellwhen you need to borrow into a non-Copyvalue. - Reach for
updatefor read-modify-write:c.update(|n| n + 1)is clearer thanc.set(c.get() + 1)and avoids repeating the binding. - Pair with
Rcfor shared, mutable, single-threaded state:Rc<Cell<T>>is the canonical “shared counter” type; see Rc/Arc. - Switch to atomics across threads:
Cellis single-threaded by design. The thread-safe analogues areAtomicUsize,AtomicBool, etc., or aMutexfor larger data. - Keep the
Cellprivate: expose intent-revealing methods (record,next_id) rather than leaking theCellfield, so callers cannot accidentallysetarbitrary values.
Real-World Example
Section titled “Real-World Example”A request-metrics recorder is a perfect fit: handlers usually receive &self (the metrics object is shared), the tracked values are all Copy, and you never need to borrow into them — just bump counters and read totals.
use std::cell::Cell;
/// Tracks request statistics while exposing only `&self` methods, so it can/// live behind a shared reference (e.g. inside an `Rc` or a handler struct).#[derive(Debug)]struct RequestMetrics { total: Cell<u64>, errors: Cell<u64>, last_status: Cell<u16>,}
impl RequestMetrics { fn new() -> Self { RequestMetrics { total: Cell::new(0), errors: Cell::new(0), last_status: Cell::new(0), } }
/// Note: `&self`, not `&mut self`. Callers do not need a mutable handle. fn record(&self, status: u16) { self.total.update(|t| t + 1); if status >= 500 { self.errors.update(|e| e + 1); } self.last_status.set(status); }
fn error_rate(&self) -> f64 { let total = self.total.get(); if total == 0 { 0.0 } else { self.errors.get() as f64 / total as f64 } }}
fn main() { let metrics = RequestMetrics::new();
for status in [200, 200, 500, 404, 503, 200] { metrics.record(status); // shared &self call, no `mut metrics` needed }
println!("total requests: {}", metrics.total.get()); println!("server errors: {}", metrics.errors.get()); println!("last status: {}", metrics.last_status.get()); println!("error rate: {:.1}%", metrics.error_rate() * 100.0);}Output:
total requests: 6server errors: 2last status: 200error rate: 33.3%Notice that metrics is never mut, yet every call to record mutates three fields. If RequestMetrics lived inside an Rc shared among several handlers (see Rc/Arc), this exact code would still work — that is the payoff of choosing Cell over &mut self. If you needed thread-safe metrics, you would swap each Cell for an AtomicU64/AtomicU16 and the API would barely change.
Further Reading
Section titled “Further Reading”Official Documentation
Section titled “Official Documentation”std::cell::CellAPI docsstd::cellmodule overview — the canonical explanation of interior mutability- The Rust Book —
RefCell<T>and the Interior Mutability Pattern std::sync::atomic— the thread-safe counterparts toCell
Related Topics
Section titled “Related Topics”- RefCell and Mutex — interior mutability for non-
Copydata and across threads - Rc and Arc — shared ownership; pairs with
CellasRc<Cell<T>> - Box — heap allocation, the simplest smart pointer
- Weak — breaking reference cycles in shared graphs
- Cow — clone-on-write, another “avoid needless work” pattern
- Smart Pointer Comparison — a decision guide for which pointer to use when
- Variables and Mutability — why
&selfis read-only in the first place - Ownership — the borrow rules
Cellcarefully sidesteps - Getting Started and Introduction — if you are new to the guide
- Async — shared-state patterns across tasks (where
Celldoes not apply)
Exercises
Section titled “Exercises”Exercise 1
Section titled “Exercise 1”Difficulty: Beginner
Objective: Use Cell to mutate a field through a &self method.
Instructions: The following code does not compile because redraw tries to mutate redraws through &self. Change the field type so it compiles, without changing the method signatures or adding mut to the binding in main.
struct View { redraws: u32, // change me}impl View { fn redraw(&self) { self.redraws += 1; // must keep &self }}fn main() { let v = View { redraws: 0 }; // must stay non-mut v.redraw(); v.redraw(); println!("redraws = {}", v.redraws);}Solution
use std::cell::Cell;
struct View { redraws: Cell<u32>,}impl View { fn redraw(&self) { self.redraws.set(self.redraws.get() + 1); }}fn main() { let v = View { redraws: Cell::new(0) }; v.redraw(); v.redraw(); println!("redraws = {}", v.redraws.get());}Output:
redraws = 2Exercise 2
Section titled “Exercise 2”Difficulty: Intermediate
Objective: Build a monotonic ID generator that hands out unique IDs through a shared reference.
Instructions: Implement IdGenerator so that next_id(&self) returns 1, then 2, then 3, … on successive calls. The generator must work through &self (no &mut). Try to use a single Cell method for the read-and-bump.
struct IdGenerator { // TODO}impl IdGenerator { fn new() -> Self { // TODO } fn next_id(&self) -> u64 { // TODO: return current value, then increment }}fn main() { let id_gen = IdGenerator::new(); println!("{} {} {}", id_gen.next_id(), id_gen.next_id(), id_gen.next_id()); // expected: 1 2 3}Solution
use std::cell::Cell;
struct IdGenerator { next: Cell<u64>,}impl IdGenerator { fn new() -> Self { IdGenerator { next: Cell::new(1) } } fn next_id(&self) -> u64 { // replace stores `current + 1` and returns the previous value. self.next.replace(self.next.get() + 1) }}fn main() { let id_gen = IdGenerator::new(); println!("{} {} {}", id_gen.next_id(), id_gen.next_id(), id_gen.next_id());}Output:
1 2 3Note:
nextis the variable name here, notgen— in the latest stable edition (2024),genis a reserved keyword (forgenblocks) and cannot be used as a plain identifier.
Exercise 3
Section titled “Exercise 3”Difficulty: Advanced
Objective: Share a single counter across multiple closures using Rc<Cell<T>>.
Instructions: Write make_clicker() that returns a shared counter handle plus a closure. Each call to the closure increments the counter; reading the returned handle reflects every click. This mirrors a UI event handler holding a shared counter. (Hint: clone the Rc so the closure owns its own handle.)
use std::cell::Cell;use std::rc::Rc;
fn make_clicker() -> (Rc<Cell<u32>>, impl Fn()) { // TODO}
fn main() { let (count, click) = make_clicker(); click(); click(); click(); println!("clicks = {}", count.get()); // expected: 3}Solution
use std::cell::Cell;use std::rc::Rc;
fn make_clicker() -> (Rc<Cell<u32>>, impl Fn()) { let count = Rc::new(Cell::new(0)); let count_for_closure = Rc::clone(&count); let on_click = move || { count_for_closure.set(count_for_closure.get() + 1); }; (count, on_click)}
fn main() { let (count, click) = make_clicker(); click(); click(); click(); println!("clicks = {}", count.get());}Output:
clicks = 3The Rc gives both the returned handle and the closure shared ownership of the
same Cell; the Cell provides the cheap, panic-free mutation. For a non-Copy
payload you would instead use Rc<RefCell<T>> — see Rc/Arc and
RefCell/Mutex.