The moment the logic breaks
You run the code. The output is wrong. You sprinkle println! everywhere. The output becomes a wall of text. You add timestamps. You add labels. The code is now a debugging script, not the logic you intended. You need to stop the program, look at the variables, and resume. That is the job of a debugger.
Breakpoints and watch expressions are not Rust language features. They are tools that interact with your compiled binary. Rust compiles to machine code. The debugger attaches to that binary, intercepts execution, and uses debug symbols to map machine instructions back to your source code. When you set a breakpoint, you are telling the debugger to pause the process at a specific instruction. When you add a watch expression, you are telling the debugger to monitor a value and stop if it changes.
Debuggers are time machines, not magic
Think of your program as a movie playing at 60 frames per second. A breakpoint freezes the movie on a specific frame. You can look at the props, the actors, and the set. You can step forward one frame at a time. A watch expression is like a sensor on a prop. If someone swaps the prop while the camera isn't looking, the sensor triggers and freezes the movie.
Rust's ownership system prevents many classes of bugs at compile time. It does not prevent logic errors. If you increment an index by three instead of two, the compiler cannot stop you. If you calculate a checksum with the wrong algorithm, the compiler cannot stop you. Debuggers help you find these errors by letting you inspect the runtime state.
The build matters more than the tool
You cannot debug a binary without debug information. Debug information maps machine code back to source lines, variable names, and types. Without it, the debugger sees hex addresses and register dumps.
Cargo builds with debug information enabled by default. Running cargo build or cargo run produces a binary with debuginfo=2. This is the level needed for line-by-line debugging. Running cargo build --release strips debug information and enables optimizations. You cannot debug a release build effectively. The compiler moves variables into registers, reorders instructions, and inlines functions. The debugger will report "No location" for breakpoints, or show variables as "optimized out."
Convention aside: The Rust community uses rust-gdb and rust-lldb instead of raw GDB or LLDB. These are wrappers that load Rust pretty-printers. They make Vec<T>, String, and HashMap<K, V> readable. Raw GDB shows raw pointers and lengths. Use the wrappers.
# Build the binary with debug info.
# cargo run is fine, but cargo build gives you the binary path.
cargo build
# Start the debugger with the wrapper.
# rust-gdb loads pretty-printers automatically.
rust-gdb target/debug/your_binary
Build with debug info, or the debugger is blind.
Minimal example: Setting a breakpoint
This example contains a logic error. The loop is supposed to sum every second element. The index increments by two, but a conditional block adds an extra increment, causing the loop to skip three elements. A watch expression on the index reveals the jump.
/// Sums elements with a hidden index bug.
fn main() {
let mut data = vec![10, 20, 30, 40, 50];
let mut total = 0;
let mut idx = 0;
// Intentional bug: idx increments by 3 instead of 2.
// A watch on idx reveals the jump.
while idx < data.len() {
total += data[idx];
idx += 2;
// Logic error: this block always runs, adding another increment.
// The condition is always true, so idx += 1 executes every time.
if data[idx] > 0 {
idx += 1;
}
}
println!("Total: {}", total);
}
Run the debugger and set a breakpoint at the loop body. Add a watch on idx. Continue execution. The debugger stops every time idx changes. You will see idx jump from 0 to 3, then 6, then 9. The watch catches the mutation you didn't expect.
(gdb) break main.rs:10
Breakpoint 1 at 0x4011a0: file src/main.rs, line 10.
(gdb) run
Starting program: target/debug/your_binary
Breakpoint 1, main () at src/main.rs:10
10 while idx < data.len() {
(gdb) watch idx
Hardware watchpoint 2: idx
(gdb) continue
Continuing.
Hardware watchpoint 2: idx
Old value = 0
New value = 3
main () at src/main.rs:16
16 idx += 1;
Watches catch the sneaky mutations that step-through misses.
Watch expressions: Staring at variables
Watch expressions evaluate after every single step. This includes steps inside function calls. If you watch a variable and step into a function that modifies it, the debugger stops immediately. This is powerful for tracking down where a value gets corrupted.
Watch expressions can monitor any valid Rust expression. You can watch vec.len(), ptr.is_null(), or buffer.len() - pos. The debugger evaluates the expression in the current context. If the result changes, it stops.
In GDB, use watch expression. In LLDB, use watchpoint set expression expression. In VS Code, add the expression to the Watch panel. The behavior is the same.
Convention aside: Watch expressions have a performance cost. The debugger checks the value constantly. Watching a large structure or a complex expression can slow execution significantly. Watch simple variables or derived values like lengths and flags.
Realistic example: Chasing a buffer overflow
This example processes a buffer with variable-length chunks. The chunk length is stored in the first byte. The loop reads the length and advances the position. If the chunk length exceeds the remaining buffer, the code panics. A watch expression on the remaining space helps detect the overflow before it crashes.
/// Processes a buffer with variable-length chunks.
fn process_chunks(buffer: &[u8]) {
let mut pos = 0;
while pos < buffer.len() {
let chunk_len = buffer[pos] as usize;
// Watch `buffer.len() - pos` to detect overflow.
// If chunk_len exceeds remaining space, this panics.
if chunk_len > buffer.len() - pos {
panic!("Chunk exceeds buffer");
}
pos += chunk_len;
}
}
/// Demonstrates buffer processing with a watchable invariant.
fn main() {
// Buffer with a chunk length that overflows.
// First byte is 10, but only 5 bytes remain.
let buffer = vec![10, 1, 2, 3, 4, 5];
process_chunks(&buffer);
}
Set a breakpoint at the if check. Add a watch on buffer.len() - pos. Continue. The debugger stops when the remaining space drops below the chunk length. You can inspect chunk_len and see the mismatch. The watch expression monitors the invariant that must hold for safety.
Treat the watch expression as a runtime assertion. If it triggers, your assumption is wrong.
Pitfalls: Optimizations and inlining
Optimizations destroy debuggability. The compiler assumes the program is correct and rearranges code for speed. It moves variables into registers. It eliminates dead code. It inlines functions. If you build with --release, the debugger cannot find variables or breakpoints.
The compiler rejects debugging attempts with errors like "No location" or "Function not defined." This is not a bug in the debugger. The code is gone. The compiler optimized it away.
Debug in debug mode. If you must debug a release build, add -C debuginfo=2 and -C opt-level=0 to your RUSTFLAGS. This keeps debug information and disables optimizations. You lose performance, but you gain visibility.
Inlining is a specific problem. If a function is inlined, the breakpoint hits in multiple places or nowhere. Use #[inline(never)] on functions you need to debug. This forces the compiler to keep the function as a separate symbol.
Convention aside: Use debug_assertions for checks that should only run in debug mode. These checks are removed in release builds. They help catch bugs during development without impacting production performance.
Optimizations lie to debuggers. Keep opt-level=0 when chasing bugs.
Decision: When to use breakpoints, watches, dbg!, or println!
Use breakpoints when you need to pause execution at a specific line to inspect the full state, including the call stack and local variables. Use watch expressions when you suspect a variable is changing unexpectedly between steps and want the debugger to alert you automatically. Use the dbg! macro when you need a quick, one-off inspection without setting up a debugger session; it prints the value and the source location. Use println! when you are debugging in an environment where a debugger is unavailable, like a production server or a restricted CI runner. Use IDE debuggers when you prefer a graphical interface with variable trees, call stacks, and integrated breakpoints. Use CLI debuggers like rust-gdb when you are on a remote server, in a container, or need scriptable automation.
Pick the tool that matches your environment. The debugger is just a lens; your code is the reality.