Skip to content
← All articles

Choosing a smart pointer

Nine types, one decision table, and the honest headline: most code needs none of them. Plus the cost ordering in real numbers, the nesting rules, and where the community genuinely disagrees.

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

  1. Can plain ownership express this? → done, use it.
  2. Is the size unknown, or is it a trait object, or a big value you move often? → Box.
  3. Is there really more than one owner? (If the answer is “no, but the lifetimes were annoying” — go back to 1.)
  4. Read-only? → Rc. Cross-thread? → Arc.
  5. Mutated rarely and T: Clone? → Rc + make_mut.
  6. Mutated often, and T is a Copy scalar? → Cell.
  7. Written once and then read? → OnceCell (OnceLock in a static).
  8. Otherwise → RefCell, and write down that you have accepted a panic path.
  9. At any point, if the shape is a graph — stop and consider an index arena.
  10. 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. Cell over RefCell, OnceCell over RefCell<Option<T>>, Rc::make_mut over Rc<RefCell<T>>.
  • RefCell is the row where you buy a runtime failure mode. That is sometimes right and never free.
  • Rc cycles leak, silently, with no lint. Every back-edge is Weak.
  • 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.

Smart Pointers and Interior Mutability · step 26 of 26

That's the end of this track. Review it or pick another.

← Back to Smart Pointers and Interior Mutability