Comparison of Box, Rc, Arc, RefCell, and Mutex
A side-by-side comparison of Rusts five core smart pointers to help you choose the right tool for ownership, sharing, mutability, and thread safety
Rust’s ownership model enforces a single owner and compile‑time borrow checking by default. Smart pointers relax those constraints in controlled ways, but each one relaxes a different rule. Box, Rc, Arc, RefCell, and Mutex are the five you will meet most often, and choosing the wrong one leads to compile errors, runtime panics, or silently incorrect concurrent code.
Quick Comparison Table
| Pointer | Ownership | Thread‑safe? | Allows mutation through a shared reference? | Cost |
|---|---|---|---|---|
Box<T> | Single, exclusive | Yes (if T: Send) | Yes, through &mut | Heap allocation |
Rc<T> | Shared, non‑atomic reference count | No | Only with interior mutability (RefCell etc.) | Reference count increment/decrement (non‑atomic) |
Arc<T> | Shared, atomic reference count | Yes | Only with interior mutability (Mutex etc.) | Atomic reference count (more overhead than Rc) |
RefCell<T> | Single owner, borrow rules checked at runtime | No (!Sync) | Yes, through borrow_mut() | Runtime borrow tracking; panics on violation |
Mutex<T> | Single owner, protected by a lock | Yes (if T: Send) | Yes, through lock() | Lock acquisition, potential contention |
The table makes the trade‑offs visible. The rest of this page digs into what each pointer actually does, when you reach for it, and the mistakes that trip up newcomers.
Box<T> — Heap Allocation with Single Ownership
A Box<T> is the simplest smart pointer. It places a value on the heap and owns it exclusively, just like a plain value on the stack, except the data lives somewhere else.
Why it exists. Rust needs to know the size of every type at compile time. Recursive types (like a linked list) have no fixed size — a Node contains another Node, which contains another, and so on. Box breaks the infinite chain because a Box itself is a fixed‑size pointer; the recursion is stored on the heap, out of the way. Box is also used when you want to transfer ownership of a large value without copying the whole thing.
Mental model. Think of Box as a “unique pointer” that automatically calls drop when the box goes out of scope. There is no reference counting, no locking, and no runtime bookkeeping beyond the allocation itself.
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));
// `list` is a single owner of the entire chain.
}
This code defines a singly‑linked list. Each Cons variant owns its next element through a Box. The compiler can compute the size of List because it sees Box<List> — a pointer — not an inline List. When list goes out of scope, the entire chain is deallocated automatically.
Box does not share ownership:
A Box is always the sole owner of its heap data. If you need two places to hold a reference to the same allocation, Box will not work — you will be fighting the borrow checker until you switch to Rc or Arc.
Common mistake. Trying to use Box for shared ownership. Code like let b1 = Box::new(5); let b2 = b1; moves the box; b1 becomes invalid. Beginners sometimes attempt to clone a Box to get a second owner, but Box does not implement Clone (unless the inner type does and you explicitly want a deep copy of the heap data, not a second pointer).
Rc<T> — Shared Ownership in Single‑Threaded Code
Rc<T> (Reference Counted) allows multiple owners of the same heap allocation. It keeps an internal counter of how many Rc pointers are active. When the last one is dropped, the heap memory is freed.
Why it exists. Single ownership is too strict for data structures like graphs or GUIs where several parts of the program need to refer to the same object. Rc solves this without a garbage collector: the cost is a pair of integer increments/decrements every time you clone or drop an Rc.
How it works. Rc::clone does not copy the underlying data. It creates a new pointer to the same allocation and bumps the reference count. The data is shared immutably — Rc only hands out &T references.
use std::rc::Rc;
struct Node {
value: i32,
next: Option<Rc<Node>>,
}
fn main() {
let node1 = Rc::new(Node { value: 1, next: None });
let node2 = Rc::new(Node { value: 2, next: Some(Rc::clone(&node1)) });
let node3 = Rc::new(Node { value: 3, next: Some(Rc::clone(&node1)) });
println!("node1 strong count: {}", Rc::strong_count(&node1)); // 3
}
Here node1 is pointed to by itself (the Rc inside node1) plus the two next pointers from node2 and node3. The strong count reaches 3. When node2 and node3 are dropped, the count falls, and when it reaches 0 the data is deallocated.
Rc is not thread‑safe:
Rc uses non‑atomic operations for the reference count. Moving an Rc to another thread is a compile error. If you need cross‑thread shared ownership, you must use Arc.
Common mistake. Creating a reference cycle. Two Rcs that point to each other can never reach a strong count of zero, leaking the memory. For cyclic structures, pair Rc with Weak<T>.
Arc<T> — Shared Ownership Across Threads
Arc<T> (Atomic Reference Counted) is the thread‑safe sibling of Rc. It uses atomic instructions to modify the reference count, guaranteeing correctness when multiple threads clone and drop the Arc simultaneously.
The mental model is identical to Rc: multiple owners, data shared immutably. The only difference is that Arc is Send and Sync (when T is Send + Sync), so you can send it to another thread.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3]);
let mut handles = vec![];
for _ in 0..3 {
let data_clone = Arc::clone(&data);
handles.push(thread::spawn(move || {
println!("{:?}", data_clone);
}));
}
for handle in handles {
handle.join().unwrap();
}
}
Each spawned thread receives its own Arc pointer to the same allocation. The atomic reference count ensures no data race on the count itself. The printed output is the same Vec from every thread.
Arc does not give mutation:
Like Rc, Arc only provides shared (&) access to its contents. To mutate data behind an Arc, you wrap the inner value with a synchronisation primitive like Mutex or RwLock.
RefCell<T> — Interior Mutability (Single‑Threaded)
A RefCell<T> moves Rust’s borrow rules from compile time to runtime. It lets you mutate the contained value through a shared reference (&RefCell<T>), something normally forbidden.
Why it exists. Sometimes the compiler is too strict. A classic example: you have a shared cache that logically is never mutated in a way that breaks safety, but the struct’s methods take &self. RefCell says “trust me, I’ll check the rules when the program runs.”
How it works. RefCell tracks outstanding borrows at runtime with a counter. borrow() returns an immutable reference and increments a reader count; borrow_mut() returns a mutable reference and sets a flag. If you try to call borrow_mut() while a borrow() is active — or two borrow_mut() calls overlap — RefCell panics immediately.
use std::cell::RefCell;
fn main() {
let data = RefCell::new(5);
{
let mut num = data.borrow_mut();
*num += 1;
} // mutable borrow ends here
println!("{}", data.borrow()); // 6
}
The inner scope takes a mutable borrow, modifies the value, and drops it. After that, the immutable borrow succeeds. This pattern works because the borrows never overlap.
Overlapping borrows panic at runtime:
The compiler cannot catch overlapping mutable borrows inside a RefCell. If your logic creates one, the program will compile but crash when the second borrow_mut() is called. Test the paths that exercise borrow_mut.
Common mistake. Returning a Ref or RefMut guard across function boundaries that introduce overlapping borrows. The guard holds the borrow active; forgetting it is still alive leads to panics far from the actual error.
Mutex<T> — Thread‑Safe Interior Mutability
Mutex<T> (Mutual Exclusion) provides the same interior mutability guarantee as RefCell, but across threads. It wraps a value and only allows access after acquiring a lock. The lock ensures that at most one thread can inspect or modify the data at any moment.
Why it exists. When multiple threads need to mutate the same piece of data, you need synchronisation. Mutex provides that by serialising access. Typically you pair it with Arc to share the Mutex itself among threads.
How it works. lock() blocks the current thread until the mutex is available, then returns a MutexGuard. Through the guard you can read or write the inner value. The lock is automatically released when the guard is dropped.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // 10
}
Ten threads each increment the shared counter. The Mutex ensures the increments are atomic (in the logical sense), and the final value is always 10.
Arc + Mutex is the standard multi‑threaded mutable pattern:
If you need shared, mutable state across threads, Arc<Mutex<T>> is the canonical starting point. It compiles, works safely, and is well‑understood.
Common mistake. Holding a MutexGuard across an .await point in async code. This can cause deadlocks because the lock is not released until the guard is dropped, but the task might be suspended. In async contexts, use an async‑aware mutex like tokio::sync::Mutex and never hold its guard across an .await.
Choosing the Right Pointer
The decision process is sequential — each answer leads to a narrower set of pointers.
Step 1: Determine ownership
Do you need a single owner for the data, or can multiple parts of the program own it?
- Single owner:
Boxor plain stack allocation is usually enough. - Shared ownership: you need
RcorArc.
Step 2: Determine thread boundaries
Will the data be accessed from more than one thread?
- Single‑threaded:
Rc(non‑atomic) is lighter. - Multi‑threaded: you must use
Arc(atomic reference count). UsingRcacross threads is a compile error.
Step 3: Decide if you need mutation through a shared reference
Do you need to mutate the data while it is shared?
- If the data is owned by a single
Boxand you have&mutaccess, mutation is straightforward — no extra wrapper. - If you need mutation through a shared reference (e.g., behind an
RcorArc), you must add an interior‑mutability wrapper.
Step 4: Pick the interior‑mutability wrapper
Based on the threading model from Step 2:
- Single‑threaded:
RefCellprovides runtime‑checked borrowing. Combine it asRc<RefCell<T>>. - Multi‑threaded:
Mutex(orRwLock) provides synchronised access. Combine it asArc<Mutex<T>>.
Two common combinations illustrate the final result.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct SharedData {
value: RefCell<i32>,
}
fn main() {
let data = Rc::new(SharedData { value: RefCell::new(10) });
let data2 = Rc::clone(&data);
*data2.value.borrow_mut() += 5;
println!("{:?}", data); // SharedData { value: RefCell { value: 15 } }
}
Both patterns give you shared, mutable state. The single‑threaded version uses cheaper bookkeeping (non‑atomic counts, no OS‑level locks). The multi‑threaded version pays for synchronisation but works safely across threads.
Never mix single‑threaded and multi‑threaded primitives:
An Rc<Mutex<T>> makes the mutex pointless for threading because Rc prevents sending it to another thread. An Arc<RefCell<T>> does not compile because RefCell is not Sync. The pairs are Rc+RefCell and Arc+Mutex; stay within those lanes.
Summary
The five pointers are not competitors — they are answers to four different questions: “where does the data live?” (Box), “how many owners?” (Rc/Arc), “does mutation need to happen through a shared reference?” (RefCell/Mutex), and “is this concurrent?” (atomic vs. non‑atomic). A useful way to remember the split: Box for one owner, Rc for many owners in one thread, Arc for many owners across threads; then bolt on RefCell for single‑threaded runtime borrows, or Mutex for thread‑safe locking.
When you find yourself inside an Arc<Mutex<T>>, you are using the most general — and most expensive — pattern Rust offers. Before reaching for it, check whether a simpler Box or Rc<RefCell<T>> is enough.