The most surprising thing about Rust memory leaks, especially Rc cycles, is that they’re a symptom of correctly using a fundamental tool for shared ownership, not a bug in the language itself.

Let’s see Rc (Reference Counting) in action, and then we’ll dive into how cycles can form.

Imagine you have a Node in a graph. Each Node can have multiple children, and importantly, each child might want to keep a reference back to its parent. This is a classic scenario where shared ownership makes sense.

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    id: usize,
    children: RefCell<Vec<Rc<Node>>>,
    parent: RefCell<Option<Weak<Node>>>, // Weak reference to parent
}

impl Node {
    fn new(id: usize) -> Rc<Node> {
        Rc::new(Node {
            id,
            children: RefCell::new(vec![]),
            parent: RefCell::new(None),
        })
    }

    fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
        parent.children.borrow_mut().push(Rc::clone(child));
        *child.parent.borrow_mut() = Some(Rc::downgrade(parent)); // Store weak reference
    }
}

fn main() {
    let root = Node::new(1);
    let child1 = Node::new(2);
    let child2 = Node::new(3);

    Node::add_child(&root, &child1);
    Node::add_child(&root, &child2);

    println!("Root: {:?}", root);
    println!("Child 1: {:?}", child1);
    println!("Child 2: {:?}", child2);

    // At this point, root has 2 strong references (from main and from child1/child2's parent field)
    // child1 and child2 each have 1 strong reference (from main).
    // The parent field on child1 and child2 holds a WEAK reference, which doesn't increment the strong count.
}

In this example, Node::add_child creates a relationship. parent owns children via Rc. To avoid a cycle, the parent field on Node uses Weak<Node>. Rc::clone creates a strong reference, incrementing the reference count. Rc::downgrade creates a weak reference, which doesn’t increment the strong count but allows you to access the data if it’s still alive. When the strong count of an Rc drops to zero, the object is deallocated.

The problem arises when two or more Rcs hold onto each other directly, forming a cycle.

Consider this modification:

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    id: usize,
    children: RefCell<Vec<Rc<Node>>>,
    parent: RefCell<Option<Rc<Node>>>, // <-- PROBLEM: Strong reference to parent!
}

impl Node {
    fn new(id: usize) -> Rc<Node> {
        Rc::new(Node {
            id,
            children: RefCell::new(vec![]),
            parent: RefCell::new(None),
        })
    }

    fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
        parent.children.borrow_mut().push(Rc::clone(child));
        *child.parent.borrow_mut() = Some(Rc::clone(parent)); // <-- PROBLEM: Direct Rc clone!
    }
}

fn main() {
    let root = Node::new(1);
    let child1 = Node::new(2);

    Node::add_child(&root, &child1); // root -> child1, child1 -> root

    println!("Root ref count before dropping: {}", Rc::strong_count(&root));
    println!("Child 1 ref count before dropping: {}", Rc::strong_count(&child1));

    // Now, let's see what happens when 'root' and 'child1' go out of scope.
    // If they were the only references, they'd be deallocated.
    // But they hold references to each other!
} // <-- At this point, even though 'root' and 'child1' are no longer directly accessible from 'main',
  //     they still have strong references pointing to each other. Their reference counts will never reach zero.

In this flawed add_child, we’ve changed parent: RefCell<Option<Weak<Node>>> to parent: RefCell<Option<Rc<Node>>> and used Rc::clone(parent) instead of Rc::downgrade(parent).

Now, root has a strong reference to child1 (via root.children). And child1 has a strong reference back to root (via child1.parent).

When main finishes, the Rcs named root and child1 go out of scope. However, their reference counts are both 2 (one from main, one from the other Node). Neither count will ever drop to zero because they are holding each other alive. This is an Rc cycle, and it’s a memory leak. The memory is allocated but never deallocated because Rust’s Rc mechanism only deallocates when the strong count reaches zero.

The mental model to break is that Rc implies automatic deallocation. It implies deallocation when there are no more active owners. A cycle means there are always active owners, even if they are unreachable from the rest of your program.

The key to preventing this is understanding the ownership graph you’re creating. If a structure needs to point back to its owner or a parent, and that owner/parent also points to the structure, you must use Weak for at least one of those references. Weak references don’t keep the object alive. You can try to upgrade a Weak reference to an Rc to access the object, but if the object has been deallocated (its strong count dropped to zero), upgrade() will return None.

The one thing most people don’t know is that RefCell itself doesn’t cause leaks, but it enables the mutation of Rc or Weak pointers after the Rc has been created. This flexibility is what allows you to accidentally create cycles by adding a strong Rc pointer back to a parent or sibling that already holds an Rc to you, without realizing the implications until runtime.

The next concept you’ll run into is how to manage these Weak pointers to safely access data that might have been deallocated, using Option::map and Ref::upgrade.

Want structured learning?

Take the full Rust course →