Skip to content
Codeloom
Rust

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.

·6 min read · By Codeloom
Intermediate 10 min read

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:

  1. Do you need heap allocation with a single owner? Use Box<T>. It is the cheapest option and imposes no overhead beyond the allocation.

  2. Do multiple owners need the same data in a single thread? Use Rc<T>. The reference counting is cheap because it avoids atomic operations.

  3. Do multiple owners need the same data across threads? Use Arc<T>. The atomic reference count has a small performance cost compared to Rc, but it is the only safe option for concurrent access.

FeatureBox<T>Rc<T>Arc<T>
OwnershipSingleSharedShared
Thread-safeYes (single owner)NoYes
OverheadHeap alloc onlyRef countAtomic ref count
Mutable accessDirect (if owned)Needs Cell/RefCellNeeds Mutex/RwLock
Common pairingTrait objects, recursive typesRefCellMutex, 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.