Rust Smart Pointers: Box, Rc, and Arc Compared
Learn when and how to use Box, Rc, and Arc smart pointers in Rust with practical examples covering heap allocation, shared ownership, and thread safety.
What you'll learn
- ✓How Box puts data on the heap
- ✓How Rc enables shared ownership
- ✓How Arc makes shared ownership thread-safe
- ✓When to pick each pointer type
Prerequisites
- •Basic Rust knowledge
- •Ownership and borrowing fundamentals
Rust’s ownership model gives you memory safety without a garbage collector, but sometimes a single owner is not enough. Smart pointers extend the ownership story by wrapping a value and providing additional capabilities like heap allocation, reference counting, or thread-safe sharing. This tutorial walks through the three most important ones: Box<T>, Rc<T>, and Arc<T>.
Box: Heap Allocation with a Single Owner
Box<T> is the simplest smart pointer. It allocates data on the heap and gives you a single owner on the stack. When the Box goes out of scope, both the heap data and the stack pointer are freed.
You reach for Box in three situations: when you have a type whose size is unknown at compile time, when you want to transfer ownership of a large value without copying it, and when you need to store a trait object.
// Recursive types need Box because their size is otherwise infinite
enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
// Box for heap-allocating a large array
let big_array = Box::new([0u8; 1_000_000]);
println!("Array length: {}", big_array.len());
// Box for trait objects
let animal: Box<dyn std::fmt::Display> = Box::new("cat");
println!("Animal: {animal}");
}
Box implements Deref, so you can use the inner value as if it were not wrapped at all. It also implements Drop, which frees the heap memory when the box is dropped. There is zero runtime overhead beyond the heap allocation itself.
Rc: Shared Ownership in a Single Thread
Sometimes multiple parts of your program need to own the same data and you cannot determine at compile time which part will finish using it last. Rc<T> (reference counted) solves this by keeping a count of how many clones exist. When the last Rc is dropped, the data is freed.
use std::rc::Rc;
struct Config {
max_retries: u32,
base_url: String,
}
fn main() {
let config = Rc::new(Config {
max_retries: 3,
base_url: String::from("https://api.example.com"),
});
// Clone bumps the reference count, it does not copy the Config
let config_for_client = Rc::clone(&config);
let config_for_logger = Rc::clone(&config);
println!("Reference count: {}", Rc::strong_count(&config)); // 3
drop(config_for_client);
println!("After drop: {}", Rc::strong_count(&config)); // 2
println!("URL: {}", config_for_logger.base_url);
}
A common use case is building graph-like data structures where multiple nodes point to the same child.
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, Rc<List>),
Nil,
}
fn main() {
let shared_tail = Rc::new(List::Cons(5, Rc::new(List::Cons(10, Rc::new(List::Nil)))));
// Two lists share the same tail
let branch_a = List::Cons(3, Rc::clone(&shared_tail));
let branch_b = List::Cons(4, Rc::clone(&shared_tail));
println!("Branch A: {:?}", branch_a);
println!("Branch B: {:?}", branch_b);
}
Important: Rc is not thread-safe. It does not use atomic operations for its reference count, which makes it faster for single-threaded code but impossible to send between threads. The compiler will stop you if you try.
Arc: Shared Ownership Across Threads
Arc<T> (atomically reference counted) is the thread-safe version of Rc. It uses atomic operations to manage the reference count, so you can safely share it 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);
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();
}
}
Arc gives you shared read access. If you need shared mutable access across threads, pair it with a Mutex or RwLock.
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();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap()); // 10
}
When to Use Which
The decision tree is straightforward:
-
Do you need heap allocation with a single owner? Use
Box<T>. It is the cheapest option and imposes no overhead beyond the allocation. -
Do multiple owners need the same data in a single thread? Use
Rc<T>. The reference counting is cheap because it avoids atomic operations. -
Do multiple owners need the same data across threads? Use
Arc<T>. The atomic reference count has a small performance cost compared toRc, but it is the only safe option for concurrent access.
| Feature | Box<T> | Rc<T> | Arc<T> |
|---|---|---|---|
| Ownership | Single | Shared | Shared |
| Thread-safe | Yes (single owner) | No | Yes |
| Overhead | Heap alloc only | Ref count | Atomic ref count |
| Mutable access | Direct (if owned) | Needs Cell/RefCell | Needs Mutex/RwLock |
| Common pairing | Trait objects, recursive types | RefCell | Mutex, RwLock |
Common Pitfalls
Reference cycles with Rc/Arc. If two Rc values point to each other, the reference count never reaches zero and you get a memory leak. Break cycles with Weak<T>:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
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)]),
});
// Use Weak to avoid a cycle
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("Leaf parent exists: {}", leaf.parent.borrow().upgrade().is_some());
}
Unnecessary Arc. Do not reach for Arc when data is only used on one thread. The atomic operations are not free. Start with Rc and upgrade to Arc only when you need cross-thread sharing.
Forgetting that smart pointers implement Deref. You can call methods on the inner type directly through any of these smart pointers. You rarely need to explicitly dereference them.
Wrapping Up
Smart pointers are Rust’s answer to the cases where plain ownership is too restrictive. Box gives you heap allocation, Rc gives you shared ownership in a single thread, and Arc extends that sharing across threads. Start with the simplest option that fits your use case and reach for the more complex ones only when the compiler or your architecture demands it.
Related articles
- Rust Understanding Rust Smart Pointers: Box, Rc, and Arc
A hands-on guide to Rust smart pointers covering heap allocation with Box, shared ownership with Rc, and thread-safe sharing with Arc.
- Rust Rust Deref and AsRef Explained
Demystify Rust's Deref, DerefMut, and AsRef traits with clear examples showing how smart pointers, coercions, and flexible APIs really work.
- Rust Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
- Rust Rust Concurrency: Channels, Mutex, and Arc Patterns
Learn Rust concurrency with channels, Mutex, Arc, and thread-safe patterns. Covers mpsc, shared state, thread pools, and real-world examples.