Rust's compiler catches a lot, but when logic bugs slip through, you need good debugging techniques. This guide covers the practical tools — from quick dbg!() calls to stepping through code with LLDB.
dbg!() — The Better println!The dbg!() macro prints the expression, its value, and the file/line — then returns the value so it works inline:
let gain_db = dbg!(10.0 * ratio.log10()); // prints to stderr with location
Output:
[src/cascade.rs:42:18] 10.0 * ratio.log10() = 13.01
Unlike println!, dbg!():
Debug trait, not Displayprintln! for Structured OutputWhen you need formatted output or want to see multiple values together:
println!("stage {i}: gain={gain_db:.2} dB, nf={nf_db:.2} dB, p1db={p1db:.1} dBm");
{:#?} (pretty-print Debug) for complex structs — it adds newlines and indentation.For debug prints you want to keep but disable easily:
#[cfg(debug_assertions)]
eprintln!("cascade state: {:#?}", chain);
This compiles away entirely in release builds.
When a panic occurs, set environment variables to see the call stack:
RUST_BACKTRACE=1 cargo test # abbreviated backtrace
RUST_BACKTRACE=full cargo test # full backtrace with all frames
For anyhow or eyre error chains:
RUST_LIB_BACKTRACE=1 cargo run # backtrace on error creation, not just panic
debug = true in your dev profile (the default) — without debug symbols, backtraces show only function addresses.cargo expand — See What Macros GenerateWhen derive macros or macro_rules! produce surprising behavior, expand them:
cargo install cargo-expand
cargo expand --lib # expand entire lib
cargo expand cascade # expand specific module
cargo expand --test my_test # expand test file
Example — see what #[derive(Debug, Clone)] generates:
cargo expand --lib cascade::Block
This is invaluable for debugging:
serde, thiserror, sqlx::FromRow)macro_rules! that don't compile#[tokio::main] or #[test] actually produceRust compiles to native code, so LLDB (on macOS) and GDB (on Linux) work directly.
Build without optimizations (the default for dev profile), then launch:
cargo build --test integration_tests
lldb target/debug/deps/integration_tests-abc123
Or attach to a running process:
lldb -p $(pgrep my_app)
b cascade::compute_p1db # breakpoint on function
b cascade.rs:87 # breakpoint on line
r # run
n # next (step over)
s # step into
c # continue
p gain_db # print variable
fr v # show all local variables
bt # backtrace
With the CodeLLDB extension, add to .vscode/launch.json:
{
"type": "lldb",
"request": "launch",
"name": "Debug Tests",
"cargo": {
"args": ["test", "--no-run", "--lib"],
"filter": {
"name": "my_crate",
"kind": "lib"
}
},
"args": ["test_name"],
"cwd": "${workspaceFolder}"
}
cargo test test_cascade_gain -- --nocapture # see println!/dbg! output
cargo test test_cascade -- --nocapture --exact # exact name match
cargo nextest run --nocapture -E 'test(cascade)' # filter + output
cargo nextest run --retries 2 # retry flaky tests
When dbg!() isn't enough and you need structured, filterable output:
use tracing::{debug, info, instrument};
#[instrument(skip(blocks), fields(count = blocks.len()))]
fn compute_cascade(blocks: &[Block]) -> CascadeResult {
for (i, block) in blocks.iter().enumerate() {
debug!(stage = i, gain_db = %block.gain_db, "processing block");
}
// ...
}
RUST_LOG=my_crate=debug cargo run # see debug-level spans
RUST_LOG=my_crate::cascade=trace # trace single module
See the Tracing guide for full setup with tracing-subscriber.
When the compiler gives a confusing error, these flags help:
cargo build 2>&1 | head -50 # just the first error (often the root cause)
cargo check --message-format=json # machine-readable errors (for tooling)
For lifetime errors specifically, the #[cfg(debug_assertions)] trick can help isolate which borrow is the problem — comment out code blocks until the error disappears, then narrow down.
| Situation | Tool |
|---|---|
| "What's this value?" | dbg!() |
| "What did this macro generate?" | cargo expand |
| "Where did it panic?" | RUST_BACKTRACE=1 |
| "I need to step through logic" | LLDB / CodeLLDB |
| "Tests pass but behavior is wrong" | --nocapture + dbg!() |
| "Production issue" | tracing with RUST_LOG |
| "Lifetime/borrow error I can't read" | Isolate + minimize the example |
Don't leave dbg!() in committed code. Use #[cfg(debug_assertions)] for prints you want to keep, or better yet, use tracing which has proper level filtering.
Don't optimize then debug. Release builds reorder and inline aggressively — debug in dev profile, reproduce in release only when necessary.
Don't ignore warnings. #[warn(unused_variables)] and dead-code warnings often point directly at the bug — you forgot to use a computed value, or a branch is unreachable.