Memory management is the reason most people get into Rust in the first place, and it’s also the reason most people bounce off it early. I remember the exact moment memory allocation in Rust started making sense to me — I stopped thinking about Box, Rc, and Arc as “special container types I need to memorize” and started thinking about them as answers to a single question: who owns this data, and how many places need access to it?
In this article, I want to walk through how Rust actually allocates memory, the difference between the stack and the heap, and when (and why) to reach for Box, Rc, or Arc — the three smart pointers you’ll use constantly once your programs grow past toy examples.
Stack vs. Heap: The Foundation
Before touching any smart pointers, you need a solid mental model of the two places data can live.
The stack is fast, fixed-size, and organized like a stack of plates — data is pushed and popped in a strict last-in-first-out order. Every value with a size known at compile time (like i32, bool, or a fixed-size struct) can live on the stack.
The heap is more flexible but slower to allocate from. Data with a size that can change at runtime, or that needs to outlive the function that created it, lives here instead.
fn main() {
let stack_value: i32 = 42; // lives entirely on the stack
let heap_value: Box<i32> = Box::new(42); // the i32 lives on the heap
println!("Stack: {}", stack_value);
println!("Heap: {}", heap_value);
}
Output:
Stack: 42
Heap: 42
Both print the same value, but heap_value is a stack-allocated pointer (Box<i32>) pointing to an i32 that actually lives on the heap. This distinction is invisible when printing but matters enormously for how the data behaves.
Why Rust Needs Explicit Heap Allocation Types
In garbage-collected languages, you don’t think about this at all — everything just gets cleaned up eventually by the GC. Rust has no garbage collector. Instead, it tracks ownership at compile time and inserts the code to free memory automatically when the owner goes out of scope. This is deterministic and has zero runtime overhead, but it means Rust needs explicit types to represent “this data lives on the heap and I own it” — which is exactly what Box<T> is.
Box<T>: The Simplest Heap Allocation
Box<T> is a smart pointer that allocates T on the heap and owns it exclusively. When the Box goes out of scope, its destructor runs and the heap memory is freed automatically.
fn main() {
let boxed_number = Box::new(5);
println!("Boxed value: {}", boxed_number);
let sum = *boxed_number + 10;
println!("Sum: {}", sum);
} // boxed_number's heap memory is freed here automatically
Output:
Boxed value: 5
Sum: 15
The * dereferences the Box to get at the underlying value — Rust also does this automatically in many contexts through a mechanism called “deref coercion,” which is why println!("{}", boxed_number) works without an explicit *.
Why You’d Actually Use Box
1. Recursive data types. Rust needs to know the size of a type at compile time, but a recursive type like a linked list has, in principle, infinite size unless you break the recursion with a pointer:
#[derive(Debug)]
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))))));
println!("{:?}", list);
}
Output:
Cons(1, Cons(2, Cons(3, Nil)))
Without Box, the compiler would try to compute an infinite size for List (since each Cons contains another List), and refuse to compile. Box<List> is just a pointer with a known, fixed size, which breaks the infinite recursion.
2. Moving large data without copying. Passing a huge struct by value copies the whole thing on the stack; boxing it means you’re only ever moving a pointer.
3. Trait objects. Box<dyn Trait> lets you store values of different concrete types behind a common interface:
trait Animal {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Animal for Dog {
fn speak(&self) -> String { String::from("Woof!") }
}
impl Animal for Cat {
fn speak(&self) -> String { String::from("Meow!") }
}
fn main() {
let animals: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}
Output:
Woof!
Meow!
Rc<T>: Shared Ownership on a Single Thread
Box<T> assumes exactly one owner. But sometimes multiple parts of your program legitimately need to share the same data — think of a graph structure, or a piece of shared configuration read by several components. That’s what Rc<T> (Reference Counted) is for.
use std::rc::Rc;
fn main() {
let shared_data = Rc::new(String::from("shared configuration"));
println!("Initial count: {}", Rc::strong_count(&shared_data));
let clone_a = Rc::clone(&shared_data);
let clone_b = Rc::clone(&shared_data);
println!("After cloning twice: {}", Rc::strong_count(&shared_data));
println!("{} | {} | {}", shared_data, clone_a, clone_b);
}
Output:
Initial count: 1
After cloning twice: 3
shared configuration | shared configuration | shared configuration
Rc::clone doesn’t duplicate the underlying data — it just increments an internal counter and hands back another pointer to the same heap allocation. When each Rc clone goes out of scope, the count decrements, and the actual data is only freed once the count hits zero.
The Catch: Rc Is Not Thread-Safe
Rc<T>‘s reference counting isn’t atomic, which makes it fast but unsafe to share across threads. The compiler enforces this — if you try to send an Rc<T> across a thread boundary, it simply won’t compile, because Rc<T> doesn’t implement the Send trait.
Arc<T>: Shared Ownership Across Threads
Arc<T> (Atomically Reference Counted) is the thread-safe equivalent of Rc<T>. It uses atomic operations to update its counter, which is slightly slower than Rc but safe to share across threads.
use std::sync::Arc;
use std::thread;
fn main() {
let shared_numbers = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let numbers_clone = Arc::clone(&shared_numbers);
let handle = thread::spawn(move || {
let sum: i32 = numbers_clone.iter().sum();
println!("Thread {} calculated sum: {}", i, sum);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final reference count: {}", Arc::strong_count(&shared_numbers));
}
Output (order of thread lines may vary):
Thread 0 calculated sum: 15
Thread 1 calculated sum: 15
Thread 2 calculated sum: 15
Final reference count: 1
Each thread gets its own Arc clone pointing to the same underlying Vec, and the data is only actually freed once every clone (across every thread) has been dropped. This is a huge safety win over manually managing shared pointers in C++, where a similar mistake can lead to data races or use-after-free bugs that only show up under specific timing conditions.
Combining Rc/Arc With RefCell/Mutex for Mutability
Rc<T> and Arc<T> only grant shared, immutable access on their own — Rust’s borrowing rules don’t allow multiple mutable references, even through smart pointers. If you need shared and mutable data, you pair them with RefCell<T> (single-threaded, checks borrow rules at runtime) or Mutex<T> (multi-threaded, uses locking).
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared_counter = Rc::new(RefCell::new(0));
let counter_a = Rc::clone(&shared_counter);
let counter_b = Rc::clone(&shared_counter);
*counter_a.borrow_mut() += 5;
*counter_b.borrow_mut() += 10;
println!("Final value: {}", shared_counter.borrow());
}
Output:
Final value: 15
For the multithreaded equivalent, Arc<Mutex<T>> is the standard pattern:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut value = counter_clone.lock().unwrap();
*value += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
Output:
Final count: 5
Arc<Mutex<T>> is such a common pattern in real Rust codebases that it’s worth memorizing directly: Arc handles shared ownership across threads, and Mutex handles safe mutable access by only allowing one thread to hold the lock at a time.
How the Compiler Frees Memory: Drop and RAII
Rust’s memory safety comes from a pattern called RAII (Resource Acquisition Is Initialization). Every heap-allocating type implements the Drop trait, and the compiler automatically calls .drop() when a value goes out of scope:
struct Tracker {
name: String,
}
impl Drop for Tracker {
fn drop(&mut self) {
println!("Dropping tracker: {}", self.name);
}
}
fn main() {
let _first = Tracker { name: String::from("first") };
{
let _second = Tracker { name: String::from("second") };
println!("Inside inner scope");
}
println!("Back in outer scope");
}
Output:
Inside inner scope
Dropping tracker: second
Back in outer scope
Dropping tracker: first
Notice second is dropped as soon as its inner scope ends, and first is dropped at the very end of main, in reverse order of declaration. There’s no garbage collector pausing your program at unpredictable times — cleanup happens deterministically, exactly when ownership ends.
Avoiding Reference Cycles
Rc<T> and Arc<T> can leak memory if you accidentally create a reference cycle — two values pointing to each other with strong references, so the count never reaches zero. The standard fix is Weak<T>, a non-owning reference that doesn’t keep the value alive:
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)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("Leaf's parent value: {:?}", leaf.parent.borrow().upgrade().map(|p| p.value));
}
Output:
Leaf's parent value: Some(5)
Using Weak for the “back reference” (child pointing back up to parent) avoids a cycle, since Weak references don’t increment the strong count and don’t keep the value alive on their own.
Performance Considerations
- Stack allocation is essentially free — it’s just moving a pointer. Prefer stack-allocated data whenever the size is known and small.
Boxallocation costs one heap allocation and one deallocation — cheap, but not free, so avoid boxing tiny values unnecessarily in hot loops.Rc::cloneandArc::cloneare O(1) — just an increment, not a deep copy, butArc‘s atomic increment is slightly more expensive thanRc‘s plain increment due to CPU-level synchronization.Mutexlocking has overhead — contention between many threads locking the sameMutexcan become a bottleneck; consider finer-grained locking or channel-based designs for heavily concurrent workloads.
Common Mistakes
- Reaching for
Rc/Arcby default. If only one part of your code owns the data, plain ownership orBoxis simpler and faster. - Using
Rcacross threads. This won’t compile, and that’s a feature — swap it forArc. - Creating reference cycles with
Rc. Watch for parent/child relationships that both hold strong references; useWeakfor the back-reference. - Locking a
Mutexand holding it longer than necessary. This creates contention; grab the value you need, then drop the lock guard as soon as possible.
Cargo Commands
cargo new memory_demo
cd memory_demo
cargo run
cargo build --release
cargo run --release
Building with --release matters especially for memory-heavy or concurrent code, since Rust’s optimizer significantly reduces allocation and locking overhead compared to unoptimized debug builds.
FAQs
Q: When should I use Box instead of just owning the value directly? Use Box for recursive types, for moving large data cheaply, for trait objects (Box<dyn Trait>), or when you need heap allocation but only a single owner.
Q: What’s the real difference between Rc and Arc? They work identically from an API perspective, but Arc uses atomic operations for its reference count, making it safe to share across threads, while Rc is faster but restricted to a single thread.
Q: Do I always need RefCell or Mutex with Rc/Arc? Only if you need to mutate the shared data. If every owner only reads the data, plain Rc<T> or Arc<T> is enough.
Q: Can memory leaks happen in safe Rust? Yes — reference cycles using Rc/Arc are a real, safe-Rust way to leak memory (the memory just never gets freed, but there’s no undefined behavior). Use Weak references to break cycles.
Troubleshooting Tips
- “the trait
Sendis not implemented forRc<...>” error — you’re trying to move anRcinto a thread; switch toArc. - Program hangs indefinitely — check for a
Mutexbeing locked twice on the same thread, which causes a deadlock; keep lock scopes short and avoid nested locking of the same mutex. - Memory usage keeps growing despite dropping variables — look for
Rc/Arccycles; considerRc::strong_count/Weakreferences to diagnose and break them.
Summary
Rust’s approach to memory allocation trades a small amount of upfront learning for a huge amount of runtime safety and predictability. The stack handles fixed-size, short-lived data essentially for free. Box<T> gives you simple, single-owner heap allocation. Rc<T> and Arc<T> let multiple owners safely share the same heap data, single-threaded and multi-threaded respectively, and pairing them with RefCell/Mutex unlocks safe shared mutability when you genuinely need it. None of this requires a garbage collector — it’s all enforced and cleaned up automatically at compile time and through deterministic Drop calls, which is exactly why Rust programs can be both memory-safe and extremely fast.
References
- The Rust Programming Language Book, Smart Pointers — https://doc.rust-lang.org/book/ch15-00-smart-pointers.html
- Rust Standard Library documentation for
Box— https://doc.rust-lang.org/std/boxed/struct.Box.html - Rust Standard Library documentation for
Rc— https://doc.rust-lang.org/std/rc/struct.Rc.html - Rust Standard Library documentation for
Arc— https://doc.rust-lang.org/std/sync/struct.Arc.html - Cargo Book — https://doc.rust-lang.org/cargo/