Box Rc and Arc in Rust Compared With Examples Explained
Box owns one heap value, Rc shares it on one thread and Arc shares it across threads. See what each costs, when you need RefCell or Mutex too, and how to choose.

Ownership says every value has exactly one owner. Smart pointers are the escape hatches for the cases where that is too strict: a value of unknown size, a value many parts of the program need and a value shared between threads. Box, Rc and Arc look similar in code and solve three different problems, and picking by habit leads either to compile errors or to a slower program. This post runs each one, shows the three errors that make you choose, measures what they cost and demonstrates the leak that reference counting allows. All outputs are from Rust 1.99, edition 2024.
Box: one owner, on the heap
A Box<T> puts a value on the heap and owns it. The common reasons are a type that refers to itself and a value whose type you only know as a trait. Without the box, a recursive type cannot exist:
enum List {
Cons(i32, List),
Nil,
}
fn main() {
let _l = List::Cons(1, List::Cons(2, List::Nil));
}error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),A List that contains a List that contains a List has no finite size, so the compiler cannot lay it out. The help text suggests the fix: a Box, whose size is that of a pointer no matter what it points at.
#[derive(Debug)]
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
impl List {
fn sum(&self) -> i32 {
match self {
Cons(v, rest) => v + rest.sum(),
Nil => 0,
}
}
}
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog { fn speak(&self) -> String { "woof".into() } }
impl Speak for Cat { fn speak(&self) -> String { "meow".into() } }
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
println!("{list:?} sum={}", list.sum());
println!("List is {} bytes, Box<List> is {} bytes", size_of::<List>(), size_of::<Box<List>>());
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for a in &animals {
print!("{} ", a.speak());
}
println!();
}Cons(1, Cons(2, Cons(3, Nil))) sum=6
List is 16 bytes, Box<List> is 8 bytes
woof meow The list is now an enum whose Cons variant holds a number and a Box<List>. List is 16 bytes and a Box<List> is 8 bytes, a single pointer, which is what makes the type sized. The recursive sum walks it. The second use, Vec<Box<dyn Speak>>, stores values of different concrete types (Dog and Cat) behind one trait, with each element a box because the elements differ in size. This is the dynamic dispatch covered in trait objects, and the error you get when you try to use such a type unboxed is in size not known at compile time.
A box is the cheapest smart pointer: there is no reference count, no sharing and the value is freed when the box goes out of scope. Reach for it when you have exactly one owner and need the heap.
Rc: many owners on one thread
Rc<T>, reference counted, lets several owners share a value. Cloning an Rc does not copy the data; it increments a counter, and the data is freed when the count reaches zero:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Config {
name: String,
}
fn main() {
let shared = Rc::new(Config { name: "prod".to_string() });
println!("count after new: {}", Rc::strong_count(&shared));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
println!("count with two clones: {}", Rc::strong_count(&shared));
{
let _c = Rc::clone(&shared);
println!("inside block: {}", Rc::strong_count(&shared));
}
println!("after block: {}", Rc::strong_count(&shared));
drop(a);
println!("after drop(a): {}", Rc::strong_count(&shared));
println!("{} {}", b.name, Rc::ptr_eq(&shared, &b));
// Shared AND mutable on one thread: Rc<RefCell<T>>
let counter = Rc::new(RefCell::new(0));
let c1 = Rc::clone(&counter);
let c2 = Rc::clone(&counter);
*c1.borrow_mut() += 5;
*c2.borrow_mut() += 10;
println!("counter = {}", counter.borrow());
}count after new: 1
count with two clones: 3
inside block: 4
after block: 3
after drop(a): 2
prod true
counter = 15The count goes from 1 to 3 with two clones, to 4 inside the block that holds a third, back to 3 after the block, and to 2 after drop(a). Rc::ptr_eq confirms the clones point at the same allocation. Write Rc::clone(&shared) rather than shared.clone(): the call is identical, and the explicit form tells a reader this is a cheap pointer copy and not a deep copy of the data.
An Rc only gives you shared access, which means &T, never &mut T. To change a value that several owners hold, put a RefCell inside. It moves the borrow check from compile time to run time. The last lines of the program add 5 and 10 through two handles and read 15. Break the rule and the check fires at run time:
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(vec![1]);
let first = cell.borrow();
cell.borrow_mut().push(2);
println!("{first:?}");
}thread 'main' panicked at src/main.rs:6:10:
RefCell already borrowedHolding cell.borrow() while calling borrow_mut() is exactly the aliasing that the compiler forbids for plain references. A RefCell allows it to be written and panics when it actually happens. Use try_borrow_mut when a panic is not acceptable.
Arc: many owners across threads
Rc is fast because its counter is not atomic, and for that reason the compiler will not let it cross a thread:
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || println!("{data}"));
handle.join().unwrap();
}error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(move || println!("{data}"));
| ------------- -------^^^^^^^^^^^^^^^^^^^
| | |
| | `Rc<i32>` cannot be sent between threads safely
| | within this `{closure@src/main.rs:6:32: 6:39}`
| required by a bound introduced by this callRc<i32> cannot be sent between threads safely: it does not implement Send. This is not a lint to turn off. Two threads incrementing a non-atomic counter at once corrupt it, and the value gets freed too early or never. Arc (atomic reference counted) uses atomic operations and is Send and Sync when its contents are. For shared mutation add a Mutex:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let total = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for id in 1..=4u64 {
let total = Arc::clone(&total);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*total.lock().unwrap() += id;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("total = {} (expected {})", *total.lock().unwrap(), 1000 * (1 + 2 + 3 + 4));
println!("strong_count = {}", Arc::strong_count(&total));
}total = 10000 (expected 10000)
strong_count = 1Four threads add their id a thousand times to one total, through Arc<Mutex<u64>>, and the answer is exactly 10,000. Each thread gets its own clone of the Arc, moved into the closure with move; after the threads are joined the count is back to 1. Compare with the Mutex and channel options in message passing with mpsc, which avoids the shared state entirely.
What they cost
Size first. A smart pointer is a pointer:
use std::mem::size_of;
use std::rc::Rc;
use std::sync::Arc;
fn main() {
println!("Box<u64> {} Rc<u64> {} Arc<u64> {}", size_of::<Box<u64>>(), size_of::<Rc<u64>>(), size_of::<Arc<u64>>());
println!("Box<dyn Fn()> {} Rc<str> {} Box<[u8]> {}", size_of::<Box<dyn Fn()>>(), size_of::<Rc<str>>(), size_of::<Box<[u8]>>());
println!("Option<Rc<u64>> {}", size_of::<Option<Rc<u64>>>());
}Box<u64> 8 Rc<u64> 8 Arc<u64> 8
Box<dyn Fn()> 16 Rc<str> 16 Box<[u8]> 16
Option<Rc<u64>> 8Box<u64>, Rc<u64> and Arc<u64> are 8 bytes. Pointers to unsized things such as dyn Fn(), str and [u8] are 16: the address plus a length or a vtable. And Option<Rc<u64>> is also 8 bytes, because a null pointer represents None. The reference counts live in the heap allocation next to the data, not in the handle.
Then time. This benchmark clones and drops 50 million handles in a loop, with black_box stopping the optimiser from deleting the work:
use std::hint::black_box;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Instant;
fn main() {
const N: u32 = 50_000_000;
let rc = Rc::new(1u64);
let arc = Arc::new(1u64);
let t = Instant::now();
for _ in 0..N {
black_box(Rc::clone(black_box(&rc)));
}
let rc_ns = t.elapsed().as_nanos() as f64 / N as f64;
let t = Instant::now();
for _ in 0..N {
black_box(Arc::clone(black_box(&arc)));
}
let arc_ns = t.elapsed().as_nanos() as f64 / N as f64;
let t = Instant::now();
for _ in 0..N {
black_box(Box::new(black_box(1u64)));
}
let box_ns = t.elapsed().as_nanos() as f64 / N as f64;
println!("Rc::clone + drop {rc_ns:>6.2} ns");
println!("Arc::clone + drop {arc_ns:>6.2} ns");
println!("Box::new + drop {box_ns:>6.2} ns");
}Rc::clone + drop 4.26 ns
Arc::clone + drop 9.97 ns
Box::new + drop 25.55 nsThe two runs of the program agreed within 1%. Rc::clone plus drop costs 4.3 ns, Arc 10.0 ns, and allocating and freeing a Box costs 25.6 ns. The atomic increment and decrement make Arc about 2.3 times slower than Rc per clone, and still cheap. The more important number is the box: it includes a heap allocation, and the clone figures do not, because cloning shares an existing one. The practical rule is not to avoid Arc for speed, it is to avoid cloning in a hot loop at all when a reference would do. All figures are from one machine with 50 million iterations; the ratio is the portable part.
The leak that reference counting allows
Reference counts free a value when the last owner drops, which fails when owners form a loop. Two nodes that hold strong Rcs to each other each keep the other alive for ever:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
name: &'static str,
next: RefCell<Option<Rc<Node>>>,
parent: RefCell<Weak<Node>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("dropping {}", self.name);
}
}
fn node(name: &'static str) -> Rc<Node> {
Rc::new(Node { name, next: RefCell::new(None), parent: RefCell::new(Weak::new()) })
}
fn main() {
println!("-- with a Weak back-reference (no leak)");
{
let parent = node("parent");
let child = node("child");
*parent.next.borrow_mut() = Some(Rc::clone(&child));
*child.parent.borrow_mut() = Rc::downgrade(&parent);
println!("parent strong={} weak={}", Rc::strong_count(&parent), Rc::weak_count(&parent));
println!("child's parent is {}", child.parent.borrow().upgrade().map_or("gone", |p| p.name));
}
println!("-- with two strong references (a cycle: leak)");
{
let a = node("a");
let b = node("b");
*a.next.borrow_mut() = Some(Rc::clone(&b));
*b.next.borrow_mut() = Some(Rc::clone(&a));
println!("a strong={} b strong={}", Rc::strong_count(&a), Rc::strong_count(&b));
}
println!("end of main: a and b were never dropped");
}-- with a Weak back-reference (no leak)
parent strong=1 weak=1
child's parent is parent
dropping parent
dropping child
-- with two strong references (a cycle: leak)
a strong=2 b strong=2
end of main: a and b were never droppedIn the first block the child holds only a Weak pointer back to its parent. Rc::downgrade makes a weak reference, which does not count as an owner: the parent shows strong 1 and weak 1, and both nodes are dropped at the end of the block (the Drop implementation prints). In the second block a points at b and b points at a with strong references, each ends at a count of 2, and when the block ends the counts drop to 1, not 0. Nothing is printed by Drop. The memory is leaked, and Rust considers that safe, because leaking cannot cause undefined behaviour; memory leaks in safe code covers how it shows up in services. The rule: parents own children with Rc, children refer to parents with Weak.
Picking one
| Need | Use |
|---|---|
| One owner, value on the heap | Box<T> |
| A recursive type | Box<T> inside the type |
| Values of different types behind one trait | Box<dyn Trait> |
| Several owners, one thread | Rc<T> |
| Several owners, several threads | Arc<T> |
| Shared and mutable, one thread | Rc<RefCell<T>> |
| Shared and mutable, several threads | Arc<Mutex<T>> or Arc<RwLock<T>> |
| A back reference that must not keep a node alive | Weak<T> |
Habits
- Start with plain ownership and references; add a smart pointer when the compiler's error asks for one.
- Prefer
Rc::clone(&x)tox.clone()for clarity. - Share immutable data with
Arc<T>and no lock at all; lock only what changes. - Use
Arc<str>orArc<[T]>for shared strings and slices, which drop the extra capacity field. - Keep the guard of a
MutexorRefCellin the smallest possible scope.
When none of these is right
If data is shared only because the code is structured that way, passing a reference down the call stack or restructuring ownership is usually simpler and faster than reference counting. For graph structures, an arena of nodes addressed by index (Vec<Node> and usize ids) avoids both the cycles and the counts. And for data read by many threads and replaced rarely, the arc-swap crate gives lock-free reads that Arc<RwLock<T>> cannot.


