You now know nine types that were not in your vocabulary at the start of this track: Box, Rc, Weak, Cell, RefCell, OnceCell, LazyCell, Cow, and — from the concurrency side — Arc, Mutex, OnceLock. That is a lot of facts and no criteria, and the usual result is that people default to whichever one they saw most recently, which for most Rust learners is Rc<RefCell<T>>.
One table fixes that. But first, the sentence that matters more than the table.
The honest headline
Most code needs none of these.
Plain ownership, &, &mut, Vec, String and HashMap cover the large majority of everything you will ever write. A codebase where smart pointers are rare is usually a codebase where the data flow was thought through; a codebase where they are everywhere is usually one where the borrow checker was fought rather than listened to.
So the first question in the flowchart is not “which pointer” but “do I need one at all?” If a function can take &T and return an owned T, do that. If a struct can own its fields outright, do that. Reach for this track’s machinery when the shape of the ownership genuinely requires it, and not before.
The decision table
Read top to bottom and stop at the first row that matches.
| if… | use | why |
|---|---|---|
| one owner, size known at compile time | the stack | free |
| one owner, unknown size / recursion / trait object / a large value you move a lot |
Box<T> |
one allocation, one indirection |
| many owners, read-only after construction |
Rc<T> |
pointer copy + a counter bump per clone |
many owners, T: Clone, mutated rarely |
Rc<T> + Rc::make_mut |
copy-on-write; cannot panic |
many owners, freely mutated, T is a Copy scalar |
Cell<T> |
free, and cannot panic |
| written exactly once, then read |
OnceCell<T> |
hands out a real &T, no borrow flags, cannot panic |
| anything else needing mutation through a shared handle |
RefCell<T> |
and you accept the runtime panic risk |
| a back-edge that must not own |
Weak<T> |
breaks cycles |
| maybe-borrowed, maybe-owned return value |
Cow<'_, T> |
avoids allocating on the common path |
| any of the above, across threads |
Arc, Mutex/RwLock, OnceLock/LazyLock, atomics |
swap the whole row |
a graph, and you were about to reach for Rc<RefCell<_>> |
an index arena | often beats all of them |
Notice how many rows say cannot panic. That is the axis most people never think about, and it is the one that shows up in production. RefCell is not the default; it is the row you land on when the three cheaper answers above it do not fit.
💡A Config is built at startup and read by six subsystems for the life of the process. It never changes. Someone has written it as Rc<RefCell<Config>>. What would you use instead, and why does the answer change if the subsystems run on threads?
click to reveal
Rc<Config> — third row. There is no mutation, so there is nothing for the RefCell to do except add a borrow flag, a branch on every access, and a panic path that can only ever fire because of a bug.
With threads, the whole row swaps: Arc<Config>. And note that Arc<RefCell<Config>> is not an option at all — it does not compile, because RefCell is never Sync.
Worth going one step further, though. If the config lives for the whole process and is built once, OnceLock<Config> in a static removes the handles entirely: no clones to pass around, no refcount traffic, and every subsystem just calls config(). And if it is genuinely immutable and cheap, plain &Config threaded through the call graph beats all of them — back to the honest headline.
Costs, in real terms
Vibes are not useful here; approximate numbers are.
| cost | |
|---|---|
| stack value | free — it is just a stack slot |
Box<T> |
one allocation, one pointer hop; move is one word |
Rc<T> |
one allocation for value + two counters; clone is a pointer copy and a non-atomic increment |
Arc<T> |
same, but the increments are atomic — roughly an order of magnitude more expensive under contention |
Cell<T> |
free. Same size as T, no branch, no flag |
RefCell<T> |
one usize alongside T, plus a compare-and-branch per borrow, plus a decrement on guard drop |
OnceCell<T> |
one Option check per access after init |
Mutex<T> |
an atomic operation uncontended; a syscall when it has to block |
Two consequences fall out of that table.
Rc versus Arc is not a style choice. Rc::clone is counter += 1. Arc::clone is a lock-prefixed atomic increment that must be visible to every core. In a tight loop cloning handles, the difference is measurable. Use Rc when you are single-threaded and let the compiler stop you when you are not — it will, because Rc is !Send.
RefCell is cheap in instructions and expensive in risk. One branch is nothing. The panic that branch can produce is not nothing. Price it as a failure mode you are buying, not as a few nanoseconds.
Nesting rules
| combination | verdict |
|---|---|
Rc<RefCell<T>> |
yes — shared ownership of one mutable cell. The workhorse. |
Rc<Cell<T>> |
yes, and better than the above whenever T is a Copy scalar |
RefCell<Rc<T>> |
rarely — “a mutable slot holding a shared handle”. A re-pointable link, occasionally right. |
Rc<Box<T>> |
never — two indirections for one value. redundant_allocation catches it. |
Rc<String>, Rc<Vec<T>> |
prefer Rc<str> and Rc<[T]> — one allocation instead of two |
Arc<RefCell<T>> |
does not compile. RefCell is not Sync. Use Arc<Mutex<T>>. |
Rc<Mutex<T>> |
pointless — an atomic lock guarding provably single-threaded data. rc_mutex. |
Vec<Box<T>>, Box<Vec<T>> |
no — vec_box and box_collection. The Vec is already on the heap. |
Rc<RefCell<Vec<Rc<RefCell<Node>>>>> |
a type people really write. type_complexity fires. A type alias is the minimum fix; a redesign is the real one. |
The live disagreement
Two things in this track are genuinely contested, and you should know that rather than believe you have been given settled doctrine.
First: Rc<RefCell<T>> for graphs. One camp says it is the natural Rust translation of an object graph, that the panics are manageable with discipline, and that the arena alternative reinvents pointers badly. The other says it trades compile-time safety for runtime failure, is not Send, leaks on cycles, and that every large Rust project that started with it has migrated away. Both are describing real experience. The criterion that survives contact with both is the one from earlier in the track: is the borrow pattern genuinely dynamic, or is the RefCell substituting for a decision about ownership you have not made?
Second: clone_on_ref_ptr. Whether Rc::clone(&x) should be mandatory over x.clone() is a style argument the lint itself declines to settle — it is allow-by-default, and plenty of production code writes .clone(). Know the reasoning (the associated-function form makes it visible that you are copying a pointer, not the data) and follow whatever your codebase does.
💡You are writing a parser. The AST has nodes that own their children; a later pass needs to annotate nodes in place and walk from any node up to its parent. Sketch the two designs and say which you would ship. click to reveal
Design A, pointers. Rc<RefCell<Node>> for children, RefCell<Weak<Node>> for parents. It works. It costs an allocation and two counters per node, a borrow flag per node, and — the real cost — a borrow_mut() in the annotation pass that will panic the day some visitor holds a borrow across a recursive call. The AST is also not Send, so no parallel passes, and printing a node with {:?} may not terminate.
Design B, an arena. Vec<Node> where Node { parent: Option<NodeId>, kids: Vec<NodeId>, … } and NodeId is a newtype over usize. Annotation is arena[id].tag = x with no runtime check at all. Walking up is while let Some(p) = arena[id].parent. The whole tree is one allocation, it is Send, Clone and serialisable, and dumping it is trivial.
Ship B. This is exactly the shape where the arena wins on every axis that matters, and it is why real compilers — rustc included — represent their ASTs and HIR this way. The costs are real and worth stating: an id is not checked, so a stale id is a logic bug, and you may want generational indices if nodes are ever removed. In a parser they usually are not.
The one thing that would flip the answer is a requirement that a node outlive the arena — handed to a language server client, cached independently, captured in a callback with its own lifetime. That is the case Rc exists for, and if it is your case, use it deliberately rather than by default.
The flowchart, in words
- Can plain ownership express this? → done, use it.
-
Is the size unknown, or is it a trait object, or a big value you move often? →
Box. - Is there really more than one owner? (If the answer is “no, but the lifetimes were annoying” — go back to 1.)
-
Read-only? →
Rc. Cross-thread? →Arc. -
Mutated rarely and
T: Clone? →Rc+make_mut. -
Mutated often, and
Tis aCopyscalar? →Cell. -
Written once and then read? →
OnceCell(OnceLockin astatic). -
Otherwise →
RefCell, and write down that you have accepted a panic path. - At any point, if the shape is a graph — stop and consider an index arena.
-
Any back-edge in a shared structure must be
Weak, or you have a leak.
What to carry forward
- Most code needs none of this. Ask that question first, every time.
-
Pick the weakest tool that fits.
CelloverRefCell,OnceCelloverRefCell<Option<T>>,Rc::make_mutoverRc<RefCell<T>>. -
RefCellis the row where you buy a runtime failure mode. That is sometimes right and never free. -
Rccycles leak, silently, with no lint. Every back-edge isWeak. - For graph-shaped data, an index arena is usually the better design, and it is what production Rust actually does.
-
Cross-thread swaps the entire row:
Arc,Mutex,OnceLock, atomics.