- Published on
Rust Smart Pointers: Box, Rc, Arc, RefCell & Interior Mutability
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — What Is a Smart Pointer?
- Box — Putting a Value on the Heap
- Rc — Sharing Ownership
- Arc — Shared Ownership Across Threads
- RefCell and Interior Mutability — Moving the Rules to Run Time
- Mutex — Thread-Safe Interior Mutability
- Deref Coercion — Treating a Smart Pointer Like the Value
- Rc Reference Cycles and Weak — Preventing Memory Leaks
- What to Use When — A Summary
- Wrapping Up
- References
Introduction — What Is a Smart Pointer?
Rust's ownership system is powerful, but sometimes it feels too strict. A value has exactly one owner, borrows follow the rules, and sizes are fixed at compile time. Those rules alone make recursive data structures and shared, mutable state hard to express. Smart pointers are what bridge that gap.
A smart pointer isn't a raw pointer that just holds an address. It's a data structure that points at data while carrying extra metadata and behavior. Most of them own the value they point to and take on the responsibility of cleaning it up when they go out of scope. In fact, the String and Vec<T> you use every day are already smart pointers, since they own and manage heap memory.
This post walks through five core smart pointers from the standard library: Box, Rc, Arc, RefCell, and Mutex. We'll see what problem each one exists to solve, when to reach for it, and what traps you fall into if you misuse it — all by example.
Box — Putting a Value on the Heap
The simplest smart pointer is Box<T>. It does exactly what its name says: it puts a value on the heap instead of the stack, and keeps a pointer to that heap address on the stack. There is still only one owner, so the rules stay simple.
fn main() {
let boxed: Box<i32> = Box::new(5);
println!("{}", boxed); // 5 — usable like the value itself, thanks to Deref
// when boxed goes out of scope, the heap memory is freed automatically
}
Boxing a single integer buys you almost nothing on its own. Box shines in specific situations.
First, recursive types. Rust has to know a type's size at compile time, but a type that directly contains itself would have infinite size. This does not compile:
// compile error: recursive type has infinite size
enum List {
Cons(i32, List),
Nil,
}
List contains another whole List, so its size can't be pinned down. Insert a Box and the inner value lives on the heap while only a pointer (a fixed-size value) sits on the stack, so the size becomes known.
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
// now the size is known, and it compiles
}
Second, moving large values. Moving a big struct means copying all its bytes on the stack, but wrapping it in a Box keeps it on the heap and moves only the pointer.
Third, trait objects. To treat different concrete types uniformly through a trait, you can't know the size, so you put them on the heap as Box<dyn Trait> and work through a pointer. We'll return to that in the Deref section.
Rc — Sharing Ownership
Box rests on the assumption of a single owner. But sometimes a single piece of data must be owned by several places at once — several graph nodes pointing at one node, or several data structures sharing the same config value. For that you use Rc<T>, the reference counting pointer.
Rc keeps a counter alongside the value that tracks "how many owners does this value have right now." Calling Rc::clone doesn't copy the data; it just bumps the counter by one, and when an owner goes out of scope the counter drops by one. The moment the counter hits zero, the value is actually freed.
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared config"));
println!("count = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a); // not a data copy — just count +1
println!("count = {}", Rc::strong_count(&a)); // 2
{
let c = Rc::clone(&a);
println!("count = {}", Rc::strong_count(&a)); // 3
} // c drops here, count -1
println!("count = {}", Rc::strong_count(&a)); // 2
}
By convention we write Rc::clone(&a) rather than a.clone(), to distinguish it from a normal clone that deep-copies the whole value. This call leaves the value alone and only touches the counter, so it's very cheap.
One important constraint: Rc is single-threaded only. It doesn't update its counter atomically, so if several threads touch the count at once the value can be corrupted. That's why Rc is neither Send nor Sync, and the compiler stops you if you try to send one across a thread boundary. To share across threads, you need Arc, which comes next.
Arc — Shared Ownership Across Threads
Arc<T> stands for Atomic Reference Counted. Its API is essentially the same as Rc, but it updates the counter with atomic operations, which makes it safe to share across threads.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data = Arc::clone(&data); // one owner handed to each thread
let handle = thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("thread {i}: sum = {sum}");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
So why not always use Arc? Atomic operations are more expensive than ordinary integer ops. If you only share within one thread, there's no reason to pay that cost, so Rc is faster. The rule is Arc only when you need it, Rc otherwise. Since the compiler enforces thread safety, using Rc where you actually need Arc simply won't compile, so there's little room to get it wrong.
Either way, a value shared through Rc or Arc is immutable by default. If there are multiple owners and one of them mutates the value, the borrow rule (no mutation while a shared reference exists) breaks. Yet in practice you often want to share and mutate. That's where interior mutability enters.
RefCell and Interior Mutability — Moving the Rules to Run Time
Rust's borrow rules are normally checked at compile time: "many shared references or one mutable reference, never both at once." RefCell<T> defers that check to run time. This is interior mutability — a pattern that lets you mutate the inner value even when you only hold a shared reference on the outside.
RefCell hands out a shared reference via borrow() and a mutable one via borrow_mut(). It checks the rules at run time rather than at compile time, and a violation causes a panic instead of a compile error.
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
{
let mut m = cell.borrow_mut(); // mutable borrow
*m += 10;
} // m is returned here
println!("{}", cell.borrow()); // 15
// an example of a rule violation — panics at run time
let _a = cell.borrow_mut();
let _b = cell.borrow_mut(); // panic: already borrowed
}
Why defer to run time what the compiler could catch? Compile-time checks are safe but conservative, and they reject some code that's actually safe. RefCell is an escape hatch for "I know I'm obeying the rules, but I can't convince the compiler." The price is that the safety check moves to run time, and breaking a rule shows up as a panic.
RefCell really earns its keep combined with Rc. Rc<RefCell<T>> is the common pattern for "shared by several owners and mutable."
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone = Rc::clone(&shared);
clone.borrow_mut().push(4); // mutate the shared value
println!("{:?}", shared.borrow()); // [1, 2, 3, 4]
}
One caution: RefCell is also single-threaded only. If you need interior mutability across threads, you move on to Mutex, below.
Mutex — Thread-Safe Interior Mutability
To mutate a value shared across threads, you use Mutex<T> (mutual exclusion). A Mutex protects the value with a lock so that only one thread accesses it at a time. Calling lock() waits until it can acquire the lock, then returns a guard that grants access to the value. When the guard goes out of scope, the lock is released automatically.
The thread-safe version of Rc<RefCell<T>> is Arc<Mutex<T>>.
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);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // acquire the lock
*num += 1;
}); // guard drops, lock released
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("result = {}", *counter.lock().unwrap()); // 10
}
Arc hands ownership to several threads, and Mutex serializes the mutations. This combination is the standard idiom for shared, mutable state across threads in Rust.
When reads vastly outnumber writes, there's also RwLock<T>: many threads can hold a read lock at once, and only the write lock is exclusive. Finally, mutexes come with the trap of deadlock — if two threads each wait on the lock the other holds, they hang forever. Keeping a consistent lock-acquisition order is the basic defense.
Deref Coercion — Treating a Smart Pointer Like the Value
In the earlier example, passing Box<i32> straight to println! printed it like an integer. What makes that work is the Deref trait and deref coercion.
A type that implements Deref lets you reach the inner value with the * dereference operator. On top of that, the compiler inserts the conversion automatically when needed. For instance, passing &Box<String> to a function that takes &str makes the compiler chain the dereferences on its own: Box<String> → String → str.
fn greet(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let boxed = Box::new(String::from("Rust"));
greet(&boxed); // &Box<String> is coerced to &str automatically
}
Thanks to this automatic conversion, you can use a smart pointer as naturally as if it were the inner value. That's why the trait objects mentioned earlier — Box<dyn Trait>, or Vec<Box<dyn Trait>> — are also easy to work with, since a single dereference lets you call the trait's methods directly.
Rc Reference Cycles and Weak — Preventing Memory Leaks
Rust is famous for memory safety, but misusing Rc can still cause a memory leak. If two Rcs point at each other in a reference cycle, each value's reference count can never fall to zero because of the other. Neither is ever freed, and both linger in memory.
Think of a parent-child tree. The parent owns its children with Rc, and if you make each child also point at its parent with an Rc, you get a cycle. Strong references run both ways — parent → child and child → parent — so the counts never reach zero.
The fix is Weak<T>, a weak reference. A Weak, created with Rc::downgrade, points at a value but claims no ownership. It doesn't raise the strong count (strong_count), so it breaks the cycle. The principle: make ownership strong in one direction (parent → child) and weak in the other (child → parent).
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
value: i32,
parent: RefCell<Weak<Node>>, // parent held weakly (not owned)
children: RefCell<Vec<Rc<Node>>>, // children held strongly (owned)
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// the child points at its parent weakly — no cycle is formed
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
// a weak reference is read via upgrade (None if it's gone)
if let Some(parent) = leaf.parent.borrow().upgrade() {
println!("leaf's parent value = {}", parent.value); // 5
}
}
The value a Weak points at may already be freed, so to reach it you call upgrade(), which returns an Option<Rc<T>> you check for liveness: Some if alive, None if already gone. That one layer of checking is the key to handling bidirectional references safely, without a cycle.
What to Use When — A Summary
Here is a compressed guide to choosing among the smart pointers we've seen.
Box<T>: put a value on the heap with a single owner. Recursive types, moving large values, trait objects.Rc<T>: share one value among several owners within a single thread. Read-only sharing.Arc<T>: shared ownership across threads. The thread-safe version ofRc.RefCell<T>: mutate a value behind a shared reference within a single thread (interior mutability). OftenRc<RefCell<T>>.Mutex<T>/RwLock<T>: shared, mutable state across threads. OftenArc<Mutex<T>>.Weak<T>: break the reference cycles ofRc/Arcto prevent leaks. A non-owning back reference.
The core intuition is three axes: one owner or many (Box vs Rc/Arc), single-threaded or multi-threaded (Rc/RefCell vs Arc/Mutex), and immutable or need interior mutability (plain Rc vs Rc/RefCell). Answer those three questions and the right choice usually narrows to one.
Wrapping Up
Smart pointers aren't a back door around Rust's ownership rules. They're tools that extend those rules to more situations. Box safely lifts the constraints of size and location, Rc and Arc the constraint of single ownership, and RefCell and Mutex the constraint of immutable sharing. And for the sake of that safety, there's even Weak as a safeguard.
Nesting like Rc<RefCell<T>> looks strange at first, but once you know what each layer solves, its intent reads clearly. Reaching for only the minimum tools you need is the path to data structures in Rust that are both safe and flexible.
References
- The Rust Programming Language — Smart Pointers chapter: https://doc.rust-lang.org/book/ch15-00-smart-pointers.html
- Rust standard library:
std::boxed::Box: https://doc.rust-lang.org/std/boxed/struct.Box.html - Rust standard library:
std::rc::Rc: https://doc.rust-lang.org/std/rc/struct.Rc.html - Rust standard library:
std::sync::Arc: https://doc.rust-lang.org/std/sync/struct.Arc.html - Rust standard library:
std::cell::RefCell: https://doc.rust-lang.org/std/cell/struct.RefCell.html - The Rustonomicon — interior mutability and related concepts: https://doc.rust-lang.org/nomicon/