Smart Pointers in Rust
Smart pointers are data structures that act like pointers but also carry additional metadata and capabilities. Unlike regular references (which only borrow data), smart pointers often own the data they point to. You have already used some smart pointers without knowing it: String and Vec<T> are both smart pointers that own heap-allocated data. In this lesson, we explore the key smart pointer types in Rust's standard library: Box, Rc, and RefCell.
Box<T>: Heap Allocation
Box<T> is the simplest smart pointer. It allocates data on the heap and stores a pointer to it on the stack. When the Box goes out of scope, both the pointer and the heap data are cleaned up. Use Box when you need a value with a known size at compile time but the type itself is dynamically sized, or when you want to transfer ownership without copying large amounts of data:
fn main() {
// Simple heap allocation
let boxed_number = Box::new(42);
println!("Boxed number: {}", boxed_number);
// Box is useful for large data -- moves the pointer, not the data
let large_array = Box::new([0u8; 1000]);
println!("Large array first element: {}", large_array[0]);
// Recursive types require Box because the size would be infinite otherwise
#[derive(Debug)]
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1,
Box::new(List::Cons(2,
Box::new(List::Cons(3,
Box::new(List::Nil))))));
println!("Linked list: {:?}", list);
}
That last case is the one worth slowing down on. A Cons variant holding a plain List would need to be big enough to hold a Cons holding a List holding a Cons, and so on forever — the compiler cannot pick a size, and it rejects the type with error[E0072]: recursive type has infinite size. Box<List> breaks the cycle because a Box is just a pointer, and a pointer has a fixed size no matter how large the thing it points to is. The program below makes that claim measurable with std::mem::size_of. Predict the three sizes before you run it.
Predict
Chain is a recursive enum whose Link variant holds an i32 and a Box<Chain>. An i32 is 4 bytes and a Box is 8. The program prints the size of Chain once before building a 3-link chain and once after. Predict the last two numbers: the size of Chain, and whether it changes after three links exist.
use std::mem::size_of;
enum Chain {
Link(i32, Box<Chain>),
End,
}
fn main() {
println!("size of i32: {}", size_of::<i32>());
println!("size of Box<Chain>: {}", size_of::<Box<Chain>>());
println!("size of Chain: {}", size_of::<Chain>());
let long = Chain::Link(1, Box::new(Chain::Link(2, Box::new(Chain::Link(3, Box::new(Chain::End))))));
let mut sum = 0;
let mut cur = &long;
while let Chain::Link(value, next) = cur {
sum += value;
cur = next;
}
println!("sum of 3 links: {}", sum);
println!("size of Chain: {}", size_of::<Chain>());
}Two things to carry forward. First, size_of::<Chain>() is a fact about the type, fixed at compile time, so it prints 16 before and after the chain is built — the links themselves live on the heap behind the Box pointers. Second, that fixed size is the whole reason Box rescues a recursive type: the compiler only ever needs room for one pointer, not for an infinite regress of nested values. Any indirection would do — the compiler's own error message suggests Box, Rc, or & — and Box is the one you reach for when the child has a single owner.
The Deref Trait
The Deref trait lets a smart pointer behave like a regular reference. When you implement Deref, the * operator and automatic deref coercion work with your type. This is what makes Box<T> transparent to use -- you can call methods on the inner value without explicit dereferencing in most cases:
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, *y); // Deref lets us use *
println!("Value in MyBox: {}", *y);
// Deref coercion: MyBox<String> -> &String -> &str
let name = MyBox::new(String::from("Rust"));
greet(&name); // Automatic deref coercion chain
}
The Drop Trait
The Drop trait lets you customize what happens when a value goes out of scope. This is Rust's equivalent of a destructor. Smart pointers use Drop to clean up heap memory, close files, release locks, and more:
struct DatabaseConnection {
name: String,
}
impl DatabaseConnection {
fn new(name: &str) -> Self {
println!("[{}] Connection opened", name);
DatabaseConnection {
name: String::from(name),
}
}
fn query(&self, sql: &str) {
println!("[{}] Executing: {}", self.name, sql);
}
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
println!("[{}] Connection closed and resources freed", self.name);
}
}
fn main() {
let conn1 = DatabaseConnection::new("primary");
let conn2 = DatabaseConnection::new("replica");
conn1.query("SELECT * FROM users");
conn2.query("SELECT * FROM orders");
// You can drop a value early with std::mem::drop
drop(conn2);
println!("conn2 has been dropped early");
conn1.query("SELECT count(*) FROM users");
println!("End of main -- conn1 will be dropped now");
}
Rc<T>: Reference Counted Pointers
Sometimes a value needs to have multiple owners -- for example, in a graph where multiple edges point to the same node. Rc<T> (Reference Counted) enables shared ownership by tracking the number of references. When the last Rc is dropped, the value is cleaned up. Note that Rc<T> is for single-threaded scenarios only:
use std::rc::Rc;
fn main() {
// Shared ownership with Rc
let shared_data = Rc::new(vec![1, 2, 3, 4, 5]);
let reference1 = Rc::clone(&shared_data);
let reference2 = Rc::clone(&shared_data);
println!("Original: {:?}", shared_data);
println!("Ref 1: {:?}", reference1);
println!("Ref 2: {:?}", reference2);
println!("Reference count: {}", Rc::strong_count(&shared_data));
// Drop one reference
drop(reference1);
println!("After dropping ref1, count: {}", Rc::strong_count(&shared_data));
// Practical example: shared configuration
let config = Rc::new(String::from("production"));
let service_a = Rc::clone(&config);
let service_b = Rc::clone(&config);
println!("Service A config: {}", service_a);
println!("Service B config: {}", service_b);
println!("Config ref count: {}", Rc::strong_count(&config));
}
The count is not a private implementation detail — Rc::strong_count reads it out, which makes shared ownership one of the few Rust concepts you can watch happen. Two things move it: every Rc::clone adds one, and every owner that goes away subtracts one. That second half is the interesting one, because an owner can go away without you writing drop — it also happens when a binding simply reaches the end of its scope. Trace the five printed counts before you run this.
Predict
Five counts are printed from the same Rc. One clone is made in an inner block that ends before line D, and one is dropped explicitly before line E. Predict all five numbers in order (A, B, C, D, E).
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("config"));
println!("A: {}", Rc::strong_count(&a));
let b = Rc::clone(&a);
println!("B: {}", Rc::strong_count(&a));
{
let c = Rc::clone(&a);
println!("C: {}", Rc::strong_count(&c));
}
println!("D: {}", Rc::strong_count(&a));
drop(b);
println!("E: {}", Rc::strong_count(&a));
}The counts run 1, 2, 3, 2, 1. There is one count, stored alongside the data, not one per handle — that is what lets the value know when its last owner disappears. It rose to 3 while c was alive and fell back to 2 at the closing brace of the inner block, with no drop written anywhere: Rc decrements in its own Drop implementation, which Rust runs automatically at scope exit. So drop(b) is not doing anything scope exit would not have done later; it just does it sooner. When the count reaches zero, the String behind it is freed.
RefCell<T>: Interior Mutability
Rust normally enforces borrowing rules at compile time. RefCell<T> moves these checks to runtime, allowing you to mutate data even when there are immutable references to the RefCell. This pattern is called interior mutability. If you violate the borrowing rules at runtime, the program will panic instead of producing undefined behavior:
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
// Immutable borrow with .borrow()
println!("Data: {:?}", data.borrow());
// Mutable borrow with .borrow_mut()
data.borrow_mut().push(4);
data.borrow_mut().push(5);
println!("After push: {:?}", data.borrow());
// Multiple immutable borrows are fine
let borrow1 = data.borrow();
let borrow2 = data.borrow();
println!("Two borrows: {:?} and {:?}", borrow1, borrow2);
// Must drop these before a mutable borrow
drop(borrow1);
drop(borrow2);
data.borrow_mut().retain(|&x| x > 2);
println!("After filter: {:?}", data.borrow());
}
Notice what RefCell did not change. The two drop calls before the mutable borrow are there because the rule being enforced is the same rule you already know — only the moment of enforcement moved. Pin that down before going further.
Recall
Without scrolling up: Borrowing in Depth gave you a rule about how many references to one value may be live at once, and the compiler rejected any program that broke it. RefCell lets you call borrow_mut() on a value you only hold an immutable reference to. Which row correctly states what RefCell changes about that rule, and what it costs?
So RefCell moves the borrow check; it does not weaken it. Many shared borrows or one exclusive borrow, never both — the rule from Borrowing in Depth survives intact, but a counter inside the RefCell enforces it while the program runs instead of the compiler enforcing it before the program exists. That buys you mutation through a shared reference, which you will need the moment Rc appears, since Rc only ever hands out &T. It costs you the compiler's proof: break the rule and you get a panic reading RefCell already borrowed at the offending line, not a build error. (You may also see the name BorrowMutError in this context — that is the Debug name of the error type try_borrow_mut() hands back in its Err, not the text of the panic.)
That failure is worth meeting deliberately, because the code that causes it looks entirely reasonable. The program below compiles without a single warning and then dies on its first pass through the loop. Work out which two borrows overlap before you change anything.
Debug
This archiver walks a list of entries and appends an archived copy of every entry older than the cutoff. It compiles cleanly, then panics at runtime with 'RefCell already borrowed'. Work out which borrow is still alive when the second one is requested, then restructure it so all three entries print.
use std::cell::RefCell;
// Archive every entry older than cutoff by appending a marked copy.
fn archive_old(entries: &RefCell<Vec<(String, u32)>>, cutoff: u32) {
for (name, age) in entries.borrow().iter() {
if *age > cutoff {
entries
.borrow_mut()
.push((format!("{}-archived", name), *age));
}
}
}
fn main() {
let entries = RefCell::new(vec![
(String::from("alpha"), 3),
(String::from("beta"), 12),
]);
archive_old(&entries, 10);
let final_len = entries.borrow().len();
assert_eq!(final_len, 3, "expected 3 entries after archiving, got {}", final_len);
println!("Entries after archiving: {}", final_len);
for (name, age) in entries.borrow().iter() {
println!(" {} ({})", name, age);
}
}Expected output: Entries after archiving: 3
alpha (3)
beta (12)
beta-archived (12)
The bug is that the for loop's iterator borrows from the Ref guard that entries.borrow() created, so that shared borrow stays live for every iteration — including the one that asks for borrow_mut(). RefCell sees a shared and an exclusive borrow overlapping, refuses, and panics with RefCell already borrowed. The fix is to split the phases rather than to shorten the write: build a plain Vec of the entries to append while you are only reading, which lets the read guard drop at the end of that statement, then take borrow_mut() once and extend with what you collected. Read fully, then write — the same discipline the compiler enforces statically when you try to push to a Vec you are iterating, except here nothing warns you until the program runs.
Combining Rc and RefCell
The most powerful pattern is Rc<RefCell<T>>, which gives you shared ownership with interior mutability. Multiple parts of your code can own the same data and mutate it:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
children: Vec<Rc<RefCell<Node>>>,
}
impl Node {
fn new(value: i32) -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
value,
children: vec![],
}))
}
fn add_child(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) {
parent.borrow_mut().children.push(child);
}
}
fn print_tree(node: &Rc<RefCell<Node>>, depth: usize) {
let node = node.borrow();
let indent = " ".repeat(depth);
println!("{}Node({})", indent, node.value);
for child in &node.children {
print_tree(child, depth + 1);
}
}
fn main() {
let root = Node::new(1);
let child_a = Node::new(2);
let child_b = Node::new(3);
let grandchild = Node::new(4);
Node::add_child(&root, Rc::clone(&child_a));
Node::add_child(&root, Rc::clone(&child_b));
Node::add_child(&child_a, Rc::clone(&grandchild));
// We can still mutate nodes through any reference
child_b.borrow_mut().value = 30;
grandchild.borrow_mut().value = 40;
println!("Tree structure:");
print_tree(&root, 0);
// The grandchild is owned by both child_a's children vec and our local variable
println!(
"\nGrandchild ref count: {}",
Rc::strong_count(&grandchild)
);
}
Choosing the Right Smart Pointer
Each smart pointer serves a different purpose. Here is a practical comparison to help you decide which one to use:
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
// Box<T>: single owner, heap allocation
// Use when: recursive types, large data, or trait objects
let boxed: Box<dyn std::fmt::Display> = Box::new("I'm a trait object");
println!("Box: {}", boxed);
// Rc<T>: multiple owners, immutable shared data (single-threaded)
// Use when: multiple parts of code need to read the same data
let shared = Rc::new(String::from("shared config"));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
println!("Rc: {} (refs: {})", a, Rc::strong_count(&shared));
drop(a);
drop(b);
// RefCell<T>: single owner, runtime-checked mutable borrows
// Use when: you need to mutate through an immutable reference
let cell = RefCell::new(vec![1, 2, 3]);
cell.borrow_mut().push(4);
println!("RefCell: {:?}", cell.borrow());
// Rc<RefCell<T>>: multiple owners with mutation (single-threaded)
// Use when: shared mutable state in a single thread
let shared_mut = Rc::new(RefCell::new(0));
let counter1 = Rc::clone(&shared_mut);
let counter2 = Rc::clone(&shared_mut);
*counter1.borrow_mut() += 10;
*counter2.borrow_mut() += 20;
println!("Rc<RefCell>: {}", shared_mut.borrow());
// For multi-threaded shared mutable state, use Arc<Mutex<T>> instead
println!("\nSummary:");
println!(" Box<T> -> single owner, heap data");
println!(" Rc<T> -> shared owner, immutable data");
println!(" RefCell<T> -> single owner, interior mutability");
println!(" Rc<RefCell> -> shared owner, interior mutability");
println!(" Arc<Mutex> -> shared owner, thread-safe mutation");
}
Try It Yourself
Reading about shared mutable state is not the same as wiring it up. This is a build task: a small program that reports its own pass/fail. You are given a metrics board — an Rc<RefCell<Vec<(String, u32)>>> that several parts of a program share and update in place — and three stubbed functions. Run it as-is and it fails immediately, naming the first stub that is still empty. Implement each until every check passes and it prints All checks passed.
The three stubs use exactly what this lesson taught: Rc::clone to hand out a second owner of one board rather than a copy of it, borrow_mut() to mutate through a shared reference, and a read borrow that is taken, used and released. The checks watch Rc::strong_count on both sides of a drop, so a share that quietly builds a fresh board instead of cloning the Rc is caught rather than silently passing. The starter has the type, the stubs and the checks; you write only the three bodies.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each behaves. Run it as-is to see which check fails first, decide what that stub is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
use std::cell::RefCell;
use std::rc::Rc;
/// A metrics board several parts of a program share and update in place.
type Board = Rc<RefCell<Vec<(String, u32)>>>;
// TODO 1: hand back a SECOND owner of the same board.
// The clone must share the board, not copy it — a change made through the
// returned handle must be visible through the original.
// share(&board) -> a Board whose strong_count is one higher
fn share(board: &Board) -> Board {
// your code here
Rc::new(RefCell::new(Vec::new()))
}
// TODO 2: add amount to the counter called name, creating it at amount
// if it is not on the board yet. Mutate through the shared board.
// record(&b, "hits", 2) twice -> ("hits", 4)
fn record(board: &Board, name: &str, amount: u32) {
// your code here
}
// TODO 3: return the total of every counter's value.
// total(&b) -> the sum of all counter values
fn total(board: &Board) -> u32 {
// your code here
0
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let board: Board = Rc::new(RefCell::new(Vec::new()));
assert_eq!(Rc::strong_count(&board), 1, "a fresh board has exactly one owner");
let worker = share(&board);
assert_eq!(
Rc::strong_count(&board),
2,
"share() must clone the Rc so the board has two owners, not build a new board"
);
record(&worker, "hits", 2);
record(&board, "hits", 2);
record(&worker, "errors", 1);
assert_eq!(
board.borrow().len(),
2,
"two distinct counters should exist: hits and errors"
);
assert_eq!(
board.borrow()[0],
(String::from("hits"), 4),
"recording 2 twice through DIFFERENT owners must accumulate to 4 on the SAME board"
);
assert_eq!(total(&board), 5, "total() should sum every counter: 4 + 1");
drop(worker);
assert_eq!(
Rc::strong_count(&board),
1,
"dropping the second owner brings the count back to 1"
);
println!("All checks passed.");
println!("counters: {}", board.borrow().len());
println!("total: {}", total(&board));
println!("owners: {}", Rc::strong_count(&board));
}Expected output: All checks passed.
counters: 2
total: 5
owners: 1
Once it passes, try two variations and predict each before running:
- Break the sharing on purpose. Change
shareback toRc::new(RefCell::new(Vec::new()))and predict which check fails first before running. It is thestrong_countassertion immediately aftershare, not any of the counter checks: the board still has one owner because you built a second, unrelated board rather than a second handle to the first. This is the assertion earning its place — without it, the two boards would each quietly hold their ownhitscounter and the bug would surface much later. - Hold a read borrow across a write. In
record, replace the first line withlet existing = board.borrow().len();and then keep theboard.borrow_mut()call below it on the sameexistingvalue. Predict whether this fails at compile time or at run time. Binding the read guard toexisting's statement lets it drop at the semicolon, so this one still runs; but bind the guard itself withlet counters = board.borrow();and leave it live across theborrow_mut(), and you get a runtime panic readingRefCell already borrowed— the same overlap as the archiver above, and never a compile error.
Key Takeaways
Box<T>provides simple heap allocation with single ownership -- it costs one allocation and matching free plus one indirection, and nothing extra on topRc<T>enables multiple ownership via reference counting, for single-threaded use onlyRefCell<T>provides interior mutability by moving borrow checks from compile time to runtime- The
Dereftrait enables smart pointers to be used like regular references via automatic coercion - The
Droptrait lets you customize cleanup behavior when a value goes out of scope Rc<RefCell<T>>combines shared ownership with mutation -- useful for graphs and shared state- For multi-threaded scenarios, use
Arc<Mutex<T>>instead ofRc<RefCell<T>>
Pro Tip: Reach for
Box<T>first -- it is the simplest and most performant smart pointer. Only move toRc<T>when you truly need shared ownership, and only addRefCell<T>when you need interior mutability. Each layer of indirection adds a small cost, so keep your pointer types as simple as your use case allows.
Next Steps
You now have a solid grasp of Rust's core concepts. Next, you'll learn how to write tests to verify your code works correctly using Rust's built-in testing framework.
Next lesson
Testing in Rust
Learn how to write unit tests, integration tests, and use Rust's built-in testing framework to ensure your code is correct
25 min