Rust

Debugging Rust

Practical techniques for finding and fixing bugs in Rust — from print debugging to LLDB and macro expansion

Debugging Rust

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!():

  • Prints to stderr (won't corrupt piped output)
  • Shows the expression text, not just the value
  • Works inline — returns the value, so you can wrap any expression
  • Requires Debug trait, not Display

println! for Structured Output

When 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");
Use {:#?} (pretty-print Debug) for complex structs — it adds newlines and indentation.

Conditional Debug Output

For debug prints you want to keep but disable easily:

#[cfg(debug_assertions)]
eprintln!("cascade state: {:#?}", chain);

This compiles away entirely in release builds.

RUST_BACKTRACE

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
Always build with debug = true in your dev profile (the default) — without debug symbols, backtraces show only function addresses.

cargo expand — See What Macros Generate

When 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:

  • Custom derive macros (serde, thiserror, sqlx::FromRow)
  • macro_rules! that don't compile
  • Understanding what #[tokio::main] or #[test] actually produce

LLDB — Interactive Debugging

Rust compiles to native code, so LLDB (on macOS) and GDB (on Linux) work directly.

Setup

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)

Essential Commands

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

VS Code Integration

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}"
}

Targeted Test Debugging

Run a Single Test with Output

cargo test test_cascade_gain -- --nocapture    # see println!/dbg! output
cargo test test_cascade -- --nocapture --exact  # exact name match

Nextest for Better Failure Output

cargo nextest run --nocapture -E 'test(cascade)'  # filter + output
cargo nextest run --retries 2                       # retry flaky tests

Tracing for Runtime Inspection

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.

Compiler Error Archaeology

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.

Quick Reference

SituationTool
"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

Anti-Patterns

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.