When the borrow checker isn't enough
You wrote a Rust program. It compiles. The borrow checker is silent. You run it, and it works perfectly for ten minutes. Then you check the process monitor, and memory usage is climbing steadily. Or the program crashes with a segmentation fault deep inside a loop, and the stack trace points to a library you didn't write.
Rust promises memory safety, but that promise has boundaries. The compiler catches errors in safe code, but it cannot verify unsafe blocks. It cannot detect cycles in reference-counted graphs. It cannot stop a dependency from mismanaging memory. When you step outside the borrow checker's reach, or when logic errors create reference cycles, bugs slip through. You need runtime tools to find the leaks and corruption that the compiler misses.
Sanitizers: runtime eyes on your memory
Sanitizers are compiler-supported runtime checks that instrument your code to watch memory access. They insert extra instructions that track allocations, deallocations, and pointer usage. When your code violates memory rules, the sanitizer halts execution and prints a detailed report.
Think of the borrow checker as a strict librarian who checks books in and out. Sanitizers are like a security camera system that watches the shelves 24/7, recording every movement, and an alarm that goes off if a book is left on the wrong shelf or if someone tries to read a book that's been shredded.
Rust supports sanitizers via the nightly toolchain. The most common ones are LeakSanitizer for detecting memory leaks and AddressSanitizer for catching buffer overflows and use-after-free errors. Sanitizers slow down your program significantly, often by 2x to 10x. You use them for debugging and testing, never for production.
Catching leaks with LeakSanitizer
Memory leaks happen when allocated memory is never freed. In Rust, this usually occurs in unsafe code where you forget to deallocate, or when reference cycles prevent Drop from running.
LeakSanitizer tracks every allocation and reports any memory still alive when the program exits. It gives you the allocation site and the call stack, making it easy to find the source.
fn main() {
// Simulate a leak that happens in a loop.
// In real code, this is a bug, not an intentional leak.
for _ in 0..100 {
let data = vec![0u8; 1024];
// WHY: Box::leak turns the Box into a raw pointer.
// Rust no longer tracks this memory. It will never be freed.
let _ptr = Box::leak(data.into_boxed_slice());
}
// WHY: When main ends, the leaked memory is lost.
// LeakSanitizer will detect this.
}
Run this with LeakSanitizer enabled:
RUSTFLAGS="-Z sanitizer=leak" cargo +nightly run --release
The output looks like this:
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 102400 byte(s) in 100 object(s) allocated from:
#0 0x... in malloc
#1 0x... in alloc::raw_vec::RawVec<T,A>::allocate_in
...
The report tells you exactly how many bytes leaked and where they were allocated. The call stack points to the line in your code.
Convention aside: Always use --release with sanitizers. Debug builds can have different memory layouts and may hide leaks due to lack of optimization. The --release flag ensures the binary behaves like production code while sanitizers catch the bugs.
LeakSanitizer is your first line of defense against memory growth. If your process eats RAM over time, run LSan. It will find the leak.
Catching corruption with AddressSanitizer
Leaks are bad, but memory corruption is worse. Buffer overflows, use-after-free, and double-free errors can crash your program or corrupt data silently. AddressSanitizer (ASan) catches these bugs by maintaining a "shadow memory" that tracks the state of every byte.
ASan is essential when you use unsafe code or call C libraries. It catches bugs that the borrow checker cannot see.
fn main() {
let mut v = vec![1, 2, 3];
// WHY: Get a raw pointer to the data.
let ptr = v.as_ptr();
// WHY: Pushing might reallocate the vector.
// If reallocation happens, the old pointer becomes dangling.
v.push(4);
unsafe {
// WHY: This is a use-after-free if reallocation occurred.
// The pointer might point to freed memory.
println!("{}", *ptr);
}
}
Run this with AddressSanitizer:
RUSTFLAGS="-Z sanitizer=address" cargo +nightly run --release
The output reports the error immediately:
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 0x... in main
...
Previously allocated by thread T0 here:
#0 0x... in malloc
#1 0x... in alloc::raw_vec::RawVec<T,A>::allocate_in
...
ASan tells you exactly what went wrong: a read from freed memory. It shows the offending line and the allocation site. This makes debugging use-after-free trivial.
ASan also catches buffer overflows. If you write past the end of an array or slice in unsafe code, ASan halts execution and points to the write. It catches out-of-bounds access that safe Rust prevents but unsafe allows.
AddressSanitizer is the standard tool for debugging crashes and corruption. If your program segfaults or produces garbage data, run ASan. It will find the bug.
Pitfalls and conventions
Sanitizers are powerful, but they have quirks. Understanding these helps you use them effectively.
False positives with intentional leaks. If your code intentionally leaks memory using Box::leak or std::mem::forget, LeakSanitizer will report it. This is correct behavior. If you want to suppress the report, you can use __lsan_ignore_object in unsafe code, but this is rare. Usually, intentional leaks are a sign of a design issue. Fix the design instead of silencing the tool.
Performance impact. Sanitizers add overhead. Your code will run slower. This is expected. Do not use sanitizers for performance testing. Use them for correctness testing. The slowdown is the price of detailed diagnostics.
Nightly requirement. Sanitizers require the nightly toolchain. This is a hard requirement. You cannot use them with stable. The community convention is to use cargo +nightly for sanitizer commands, not to switch your global toolchain. This keeps your development environment stable while allowing nightly features for debugging.
CI integration. The best time to catch memory bugs is in continuous integration. Add a job to your CI pipeline that runs tests with sanitizers. This catches regressions before they reach users. Many Rust projects run cargo test with ASan and LSan in CI. It's a small cost for a huge safety gain.
Convention aside: The Rust community treats sanitizer reports as errors. If a sanitizer fails, the build fails. This discipline ensures memory bugs are fixed immediately. Don't ignore sanitizer output. Treat it like a compiler error.
Sanitizers don't care about your excuses. They care about bytes. If the shadow memory says you touched a dead address, you touched a dead address. Fix the code.
Which tool for which bug
Pick the sanitizer that matches the symptom. Using the wrong tool wastes time.
Use LeakSanitizer when you suspect memory is allocated but never freed, especially in long-running processes or after stress tests. Use LeakSanitizer when your process memory grows over time and you need to find the source. Use AddressSanitizer when you see segmentation faults, use-after-free crashes, or buffer overflows that the borrow checker didn't catch. Use AddressSanitizer when unsafe code interacts with raw pointers and you need to verify memory safety. Use MemorySanitizer when you suspect uninitialized memory reads, mostly in FFI code where C libraries might leave data uninitialized. Use Valgrind when you need detailed call stacks for leaks on Linux and don't mind a massive performance hit, or when sanitizers aren't available on your platform.
Don't ship with sanitizers. They're too slow. Ship with confidence. Use sanitizers to earn that confidence.
Where to go next
Memory issues often stem from misunderstanding ownership and borrowing. If you're struggling with returning references from functions, check How to return a borrowed value from a function. If you're fighting the borrow checker while iterating over collections, see How to borrow from HashMap while iterating. Understanding the trait hierarchy helps too: What is the Borrow trait vs AsRef.