When the memory stays
You're building a scene graph for a game engine. Every node holds a reference to its children, and every child holds a reference back to its parent. You delete the root node, expecting the whole tree to vanish. The profiler shows the memory staying allocated. You check the code. No malloc calls, no manual free. Rust promised no leaks, but the bytes are sitting there, refusing to move.
This is a memory leak. In Rust, this shouldn't happen, right? It can. Rust prevents memory safety bugs like buffer overflows, use-after-free, and double-free. It does not prevent memory leaks. A leak is safe. It is just inefficient. The program will not crash. It will not invoke undefined behavior. It will simply consume memory until the process ends or the system runs out of resources.
Rust's ownership system usually prevents leaks by design. When a variable goes out of scope, the compiler generates code to drop it. If that variable owns other data, those get dropped too. The chain reaction cleans everything up. Leaks happen when you break this chain. You can break it intentionally by telling the compiler to ignore cleanup. Or you can break it accidentally by creating a reference cycle where two pieces of data hold strong references to each other, keeping each other alive forever.
Safety does not mean no leaks
Memory safety and memory leak freedom are different guarantees. Memory safety means you cannot access invalid memory. Leak freedom means you cannot lose track of valid memory. Rust guarantees the first. It does not guarantee the second.
Think of a leak as a book left on a table that no one ever reads. The library still owns the book. The book is safe. No one is tearing pages out. But the book is not on the shelf where it belongs. It occupies space. If enough books get left on tables, the library runs out of room.
Rust allows leaks because preventing all leaks would require the compiler to solve the halting problem or track complex graph structures at compile time. That is computationally intractable. Instead, Rust gives you tools to manage shared ownership and lets you decide when to break cycles. The compiler enforces that you cannot accidentally use freed memory. It does not enforce that you free all memory.
Trust the borrow checker for safety, but trust your profiler for leaks.
The intentional leak: forget and ManuallyDrop
The simplest way to leak memory in Rust is to ask for it. The standard library provides std::mem::forget. This function takes ownership of a value and does nothing. The value is moved into the function, the function body is empty, and the value is lost. The compiler sees the value moved, so it does not generate drop code for the original variable. The heap allocation remains reachable by no one, but the operating system does not know it is garbage.
use std::mem::forget;
fn main() {
// Allocate a String on the heap.
let data = String::from("This will leak");
// forget takes ownership and suppresses the drop glue.
// The String's memory is never freed.
forget(data);
// The program continues. The memory is leaked.
// When main exits, the OS reclaims everything,
// but during runtime the leak existed.
}
Calling forget directly is rare in production code. The community prefers std::mem::ManuallyDrop when you need to control drop timing. ManuallyDrop is a type wrapper that signals intent. It prevents the value from being dropped automatically, but it keeps the value accessible. You can drop it manually later, or you can leak it intentionally. The type system reminds readers that the value is not being dropped.
use std::mem::ManuallyDrop;
fn main() {
// ManuallyDrop wraps the value and suppresses automatic drop.
let data = ManuallyDrop::new(String::from("Controlled leak"));
// You can still read the value via Deref.
println!("Data: {}", data);
// If you never call ManuallyDrop::into_inner,
// the value leaks when data goes out of scope.
// This is explicit and visible in the type signature.
}
Convention aside: Use ManuallyDrop instead of forget when you want to signal that a value will not be dropped automatically. forget is a blunt instrument. ManuallyDrop is a type that documents your intent. If you see ManuallyDrop in a signature, you know the caller is responsible for cleanup.
The accidental leak: reference cycles
Intentional leaks are obvious. Accidental leaks are harder to spot. They happen with reference-counted smart pointers like Rc<T> and Arc<T>. These types allow multiple owners of the same data. They keep a counter of how many owners exist. When the counter hits zero, the data is dropped.
A leak occurs when you create a cycle. If A points to B and B points to A, both counters are at least one. Neither counter hits zero. Neither value drops. The memory stays allocated until the process ends.
use std::rc::Rc;
struct Node {
value: i32,
// Strong reference to the next node.
next: Option<Rc<Node>>,
}
fn main() {
let node1 = Rc::new(Node { value: 1, next: None });
let node2 = Rc::new(Node { value: 2, next: Some(node1.clone()) });
// Create a cycle: node1 points to node2, node2 points to node1.
// This requires interior mutability to modify node1 after creation.
// For brevity, assume we can mutate next.
// In practice, this cycle prevents both nodes from dropping.
// The ref counts stay at 1. Memory leaks.
}
The compiler does not warn about cycles. The borrow checker tracks ownership lifetimes, not reference counts. It cannot prove that a cycle exists at compile time. You need runtime tools to find these leaks. Tools like valgrind or cargo-leak can detect memory that was allocated but never freed.
The borrow checker won't warn you about leaks. Profile your code if you use Rc.
Breaking the cycle with Weak
The solution to reference cycles is Weak pointers. Weak<T> is a non-owning reference to a value managed by Rc<T> or Arc<T>. It points to the same allocation but does not increment the strong reference count. It is a "maybe" pointer. You can upgrade it to an Rc if you need to use the value, but the upgrade might fail if the value has been dropped.
In a graph, you use Rc for parent-to-child references and Weak for child-to-parent references. The parent owns the child. The child knows about the parent but does not keep it alive. When the parent is dropped, the child's Weak reference becomes invalid. The cycle is broken.
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
// Strong reference to child. Parent owns child.
next: Option<Rc<RefCell<Node>>>,
// Weak reference to parent. Child does not own parent.
parent: Weak<RefCell<Node>>,
}
fn main() {
// Create parent node.
let parent = Rc::new(RefCell::new(Node {
value: 1,
next: None,
parent: Weak::new(),
}));
// Create child node with a Weak reference to parent.
let child = Rc::new(RefCell::new(Node {
value: 2,
next: None,
// Downgrade the Rc to a Weak.
// This does not increment the strong count.
parent: Rc::downgrade(&parent),
}));
// Parent holds strong ref to child.
parent.borrow_mut().next = Some(child.clone());
// When parent and child go out of scope:
// 1. child drops. Its ref count decreases.
// 2. parent drops. Its ref count decreases.
// 3. child's ref count hits zero. Child is freed.
// 4. parent's ref count hits zero. Parent is freed.
// No cycle. No leak.
}
Weak pointers require careful handling. You cannot use a Weak pointer directly. You must call upgrade() to get an Option<Rc<T>>. If the target is still alive, you get Some(Rc). If the target was dropped, you get None. This prevents dangling pointers. The compiler enforces that you check the Option before using the value.
// Accessing the parent from the child.
let parent_opt = child.borrow().parent.upgrade();
match parent_opt {
Some(p) => println!("Parent value: {}", p.borrow().value),
None => println!("Parent was dropped"),
}
Convention aside: Use Rc::clone(&rc) instead of rc.clone() when cloning an Rc. Both compile, but Rc::clone makes it clear that you are cloning the pointer, not the data. rc.clone() looks like a deep clone to readers who are not familiar with Rc. The explicit form reduces cognitive load.
Break the cycle with Weak. If every pointer is strong, nothing ever dies.
Pitfalls and silent failures
Memory leaks in Rust are silent. The compiler does not emit errors. The program does not panic. You only notice when memory usage grows over time. This makes leaks insidious. They can hide in long-running processes like servers or daemons. A small leak per request can exhaust memory after thousands of requests.
Common pitfalls include:
- Forgetting to use
Weakfor back-references in graphs or trees. - Using
Rcin a cycle and assuming the compiler will catch it. - Holding
Rcreferences in global state or caches without eviction policies. - Using
forgetorManuallyDropand forgetting to drop the value manually.
The compiler error E0382 (use of moved value) might appear if you try to move a value out of a borrowed context while debugging a leak. This error is unrelated to the leak itself. It indicates a move violation. Leaks do not produce compiler errors. You must rely on testing and profiling.
The compiler guarantees safety, not efficiency. Leaks are safe but wasteful.
Choosing the right pointer
Shared ownership adds overhead and complexity. Use it only when necessary. Prefer plain ownership when possible. When you need shared ownership, choose the right tool for the job.
Use Rc<T> for single-threaded shared ownership when the data structure is a tree or a DAG with no cycles. Use Arc<T> for multi-threaded shared ownership when the data is read from multiple threads. Use Weak<T> for back-references in graphs or caches where the reference should not keep the target alive. Use ManuallyDrop<T> when you need to suppress automatic drop and signal intent to readers. Use std::mem::forget only when implementing custom Drop logic or interfacing with FFI functions that take ownership. Reach for plain ownership when possible; shared ownership is a tool, not a default.
Prefer ownership. Shared ownership is a tool, not a default.