When the borrow checker isn't enough
Your Rust program compiles without warnings. The borrow checker is happy. You run it in production and it segfaults after three hours, or your memory usage climbs steadily until the OOM killer steps in. The compiler promised safety, but somewhere in an unsafe block, a C library call, or a custom allocator, memory is leaking or being read past its bounds. You need a tool that watches your program run in real time and flags exactly where memory goes wrong.
Valgrind fills that gap. It does not replace the borrow checker. It audits the parts of your code where the compiler hands control back to you. You run it when you suspect a leak, a use-after-free, or an uninitialized read that slipped past compile-time guarantees. Treat it as a memory microscope. Point it at your binary, watch the output, and fix what it flags.
How Valgrind actually watches your memory
Valgrind is a dynamic binary instrumentation framework. It does not modify your source code. It loads your compiled binary into a virtual CPU and executes it instruction by instruction. Every time your program touches memory, Valgrind intercepts the operation and compares it against a shadow ledger it maintains in real time.
Think of it like a warehouse inspector tracking inventory. When your program calls malloc or the Rust allocator requests heap space, Valgrind records the exact address, the size, and the stack trace. It marks that region as allocated. When your program reads or writes to that address, Valgrind checks the bounds. If you read past the end of a buffer, it flags an invalid read. If you write to memory after calling free, it flags a use-after-free. If you read from a buffer before writing a value, it flags uninitialized memory access.
The overhead is substantial. Valgrind slows execution by a factor of twenty to fifty. It is not a performance profiler. It is a correctness auditor. You run it until the output is clean, then you strip it out and ship your optimized binary. Trust the slowdown. It is the price of watching every byte.
Compiling and running your first check
Valgrind needs debug symbols to translate raw memory addresses back into file names and line numbers. Without them, you get hexadecimal addresses and stack traces that look like noise. Compile your binary with the --debug flag or use cargo build in debug mode. Release builds strip symbols and inline functions aggressively, making Valgrind output nearly impossible to read.
/// Demonstrates a simple memory leak that Valgrind will catch.
fn main() {
// Allocate a Vec on the heap. The Vec tracks its own capacity.
let data = vec![1, 2, 3, 4, 5];
// Intentionally leak the allocation by dropping the Vec's contents
// but keeping the raw pointer alive. This is a contrived example.
let raw = data.leak() as *mut i32;
// We never free `raw`. Valgrind will flag this as a leak.
// In real code, this happens when unsafe code forgets to call drop.
std::mem::forget(data);
}
Run the binary through Valgrind with the --leak-check=full flag.
# Compile with debug symbols so Valgrind can map addresses to source lines.
rustc --debug src/main.rs -o my_program
# Run Valgrind with full leak checking enabled.
valgrind --leak-check=full ./my_program
Valgrind wraps your executable in its instrumentation layer. It executes your code, intercepts memory calls, and prints a summary when the process exits. The summary tells you exactly how many bytes were leaked, where the allocation originated, and which function failed to free it. Read the stack traces from bottom to top. The bottom frame shows where the allocation happened. The top frame shows where the leak was detected. Follow the trail back to your source code.
Reading the output like a diagnostician
Valgrind classifies leaks into four categories. Knowing the difference saves you from chasing ghosts.
Definitely lost means Valgrind found allocated memory that your program no longer has any pointer to. This is a true leak. Fix it by ensuring every allocation has a matching deallocation.
Indirectly lost means the memory itself is still reachable, but it is only accessible through a pointer that was definitely lost. Fix the parent allocation and the child memory will follow.
Still reachable means your program exited while still holding pointers to allocated memory. This is common in Rust when global caches or thread-local storage hold references until process teardown. It is rarely a bug. You can safely ignore it or suppress it.
Suppressed means Valgrint hid the warning based on a suppression file. Review your suppressions periodically. Old suppressions can mask new bugs.
Valgrind also reports invalid reads and writes. These show the exact address, the size of the access, and the stack trace. If you see an invalid read of size 8, you are likely reading a pointer or a 64-bit integer past a buffer boundary. If you see a conditional jump or move depends on an uninitialized value, you are making a decision based on garbage data. Both are undefined behavior waiting to corrupt your state.
Keep your suppression files small. The community convention is to generate a baseline suppression for known allocator noise, then add new suppressions only when you have proven the warning is a false positive. Document the reason in the suppression file itself. Treat suppressions like unsafe blocks: minimal, justified, and reviewed.
Realistic scenario: tracking down a raw pointer leak
Real-world Rust code often manages raw pointers for performance or FFI compatibility. Consider a manual node allocator that bypasses Box.
use std::alloc::{alloc, Layout};
use std::ptr;
/// A manual memory manager for a simple linked list node.
struct RawNode {
value: i32,
next: *mut RawNode,
}
/// Allocates a node on the heap without using Box.
fn allocate_node(value: i32) -> *mut RawNode {
// Calculate the size and alignment required for RawNode.
let layout = Layout::new::<RawNode>();
// SAFETY:
// 1. layout has non-zero size and valid alignment.
// 2. alloc returns a valid, aligned pointer for the requested layout.
let ptr = unsafe { alloc(layout) };
if ptr.is_null() {
panic!("allocation failed");
}
// Write the value and set next to null.
unsafe {
ptr::write(ptr, RawNode { value, next: ptr::null_mut() });
}
ptr
}
/// Frees a node manually.
fn free_node(ptr: *mut RawNode) {
if ptr.is_null() {
return;
}
let layout = Layout::new::<RawNode>();
// SAFETY:
// 1. ptr was allocated with the exact same layout.
// 2. ptr has not been freed yet and is still valid.
unsafe {
std::alloc::dealloc(ptr as *mut u8, layout);
}
}
fn main() {
let node = allocate_node(42);
// Oops: we forgot to call free_node(node).
// The borrow checker cannot see this because we used raw pointers.
}
Run this through Valgrind. The output will point directly to allocate_node and show that the allocation was never matched with a free. You will see a stack trace that includes the line where alloc was called. Fix the leak by calling free_node(node) before main exits. Valgrind does not fix the code. It gives you the exact coordinates of the mistake.
If you accidentally dereference a raw pointer outside an unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). Valgrind catches the runtime consequences when you do dereference them inside unsafe. It watches the memory, not the syntax.
Pitfalls, false positives, and CI conventions
Valgrind is powerful, but it has quirks when paired with Rust. The Rust standard library uses a custom allocator by default. Older versions of Valgrind struggled with Rust's allocator implementation, reporting false positives for internal bookkeeping structures. Modern Valgrind handles it better, but you may still see noise around thread-local storage or global caches.
If you see leaks that disappear when you run the program normally, check your allocator. You can switch to the system allocator temporarily to rule out allocator-specific tracking issues. Add this to your crate root:
#[global_allocator]
static GLOBAL: std::alloc::System = std::alloc::System;
Valgrind also struggles with heavily optimized code. Always run it against debug builds. Release builds strip debug symbols and inline functions aggressively, making stack traces unreadable. The community convention is to run Valgrind in a dedicated CI job that only triggers on PRs touching unsafe code or FFI bindings. Do not run it on every commit. The slowdown will kill your pipeline.
Another common trap is ignoring suppressed warnings. Valgrind ships with a suppression file for known false positives. You can create your own by running valgrind --gen-suppressions=all and piping the output to a file. Keep suppressions minimal. If you suppress a warning, document why it is safe. Treat suppressions like unsafe blocks: small, justified, and reviewed.
Do not use Valgrind to measure performance. It adds instrumentation overhead that completely distorts timing. Use perf or cargo flamegraph for profiling. Use Valgrind for correctness. Keep the tools separate.
Choosing the right memory tool
Use Valgrind when you need a thorough, offline memory audit of a binary that interacts with C libraries or contains extensive unsafe code. Use Miri when you want to catch undefined behavior in safe Rust code and unsafe blocks without compiling a full binary. Use AddressSanitizer (ASan) when you need faster leak and bounds checking during development, accepting that it may miss some edge cases Valgrind catches. Use MemorySanitizer (MSan) when your primary concern is uninitialized memory reads and you are willing to recompile the entire toolchain with instrumentation. Trust the borrow checker for everyday ownership and lifetime management; external tools only fill the gaps the compiler cannot verify.