RustDebugging

Debugging Rust Code

Rust's compiler catches a large class of bugs before your program ever runs, but logic errors, unexpected panics, and performance surprises still happen. Rust has a rich set of debugging tools — from quick inline inspection with dbg! all the way to full symbolic debugging with LLDB. This page walks through the full toolkit from least to most effort.

The dbg! Macro

dbg!(expr) is Rust's most useful quick-debugging tool. It:

  1. Prints the source file and line number, the expression text, and the value to stderr.
  2. Returns the value unchanged — so you can wrap any sub-expression without restructuring your code.

RUST
fn factorial(n: u64) -> u64 {
    if dbg!(n) <= 1 {
        return 1;
    }
    dbg!(n * factorial(n - 1))
}

fn main() {
    let result = factorial(4);
    println!("4! = {}", result);
}
[src/main.rs:2] n = 4
[src/main.rs:2] n = 3
[src/main.rs:2] n = 2
[src/main.rs:2] n = 1
[src/main.rs:5] n * factorial(n - 1) = 2
[src/main.rs:5] n * factorial(n - 1) = 6
[src/main.rs:5] n * factorial(n - 1) = 24
4! = 24
Tip
Because dbg! returns its argument, you can wrap any expression inline: let y = dbg!(x * 2) + 1; — no need to split into a separate let statement and a println!.
println! with Debug and Display Formatting

When dbg! is not enough — or when you want to log to stdout rather than stderr — use println! with format specifiers:

  • {:?} — Debug format: machine-readable, derived automatically
  • {:#?} — Pretty-print Debug: multiline, indented, human-readable
  • {} — Display format: human-friendly output (must implement Display)

RUST
#[derive(Debug)]
struct Point { x: f64, y: f64 }

#[derive(Debug)]
struct Triangle { a: Point, b: Point, c: Point }

fn main() {
    let t = Triangle {
        a: Point { x: 0.0, y: 0.0 },
        b: Point { x: 1.0, y: 0.0 },
        c: Point { x: 0.5, y: 1.0 },
    };

    println!("{:?}", t);   // compact single line
    println!("{:#?}", t);  // pretty-printed multiline
}
Triangle { a: Point { x: 0.0, y: 0.0 }, b: Point { x: 1.0, y: 0.0 }, c: Point { x: 0.5, y: 1.0 } }
Triangle {
    a: Point {
        x: 0.0,
        y: 0.0,
    },
    b: Point {
        x: 1.0,
        y: 0.0,
    },
    c: Point {
        x: 0.5,
        y: 1.0,
    },
}
eprintln! for Stderr Output

Use eprintln! to write to stderr instead of stdout. This keeps debug output separate from program output, which matters when your program's stdout is piped to another tool or parsed by a caller.

RUST
fn process(items: &[i32]) -> i32 {
    eprintln!("[DEBUG] processing {} items", items.len());
    let sum: i32 = items.iter().sum();
    eprintln!("[DEBUG] sum = {}", sum);
    sum
}

fn main() {
    let data = vec![1, 2, 3, 4, 5];
    let result = process(&data);
    println!("{}", result);  // only this goes to stdout
}
[DEBUG] processing 5 items   <- stderr
[DEBUG] sum = 15             <- stderr
15                           <- stdout
Deriving Debug and Custom Implementations

Add #[derive(Debug)] to any struct or enum to get automatic debug printing. For custom formatting — hiding sensitive fields, showing only a subset of data — implement fmt::Debug manually:

RUST
use std::fmt;

struct Password(String);

// Don't expose the actual password in debug output
impl fmt::Debug for Password {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_tuple("Password")
            .field(&"<redacted>")
            .finish()
    }
}

#[derive(Debug)]
struct User {
    name:     String,
    password: Password,
}

fn main() {
    let u = User {
        name:     String::from("alice"),
        password: Password(String::from("s3cr3t")),
    };
    println!("{:#?}", u);
}
User {
    name: "alice",
    password: Password(
        "<redacted>",
    ),
}
Stack Traces on Panic

When your program panics, set the RUST_BACKTRACE environment variable to see the call stack:

Bash
# Short backtrace — filtered to your code
RUST_BACKTRACE=1 cargo run

# Full backtrace — includes std and runtime frames
RUST_BACKTRACE=full cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 5', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::slice::index_failed
   3: my_crate::main
             at ./src/main.rs:4:5
note: Some details are omitted; run RUST_BACKTRACE=full for a verbose backtrace.
Note
Backtraces are most useful in debug builds (cargo run /cargo build). Release builds strip symbols by default — adddebug = true under [profile.release] inCargo.toml to keep them in release mode too.
rustc --explain for Compiler Errors

Every Rust compiler error has a unique code in the form E0xxx. Run rustc --explain to get a detailed explanation with examples:

Bash
rustc --explain E0382
E0382: borrow of moved value

This error occurs when you try to use a value after it has been moved.

  let x = String::from("hello");
  let y = x;
  println!("{}", x); // error: x has been moved

Solution: either clone the value before moving, or restructure the code so
the original binding is not used after the move.
Conditional Debug Code

Use #[cfg(debug_assertions)] to include code only in debug builds (the default for cargo build and cargo run). Release builds (--release) have debug assertions disabled, so the block is compiled out entirely — zero overhead in production.

RUST
fn connect(host: &str, port: u16) {
    #[cfg(debug_assertions)]
    {
        eprintln!("[DEBUG] connecting to {}:{}", host, port);
    }

    // ... actual connection logic
}

fn main() {
    connect("localhost", 8080);
    // eprintln line appears in debug builds only
}
Assertions

RUST
fn divide(a: i32, b: i32) -> i32 {
    assert!(b != 0, "divisor must not be zero, got {}", b);
    a / b
}

fn add(x: i32, y: i32) -> i32 { x + y }

fn main() {
    // assert! — panics if condition is false (always active)
    assert!(1 + 1 == 2);

    // assert_eq! / assert_ne! — prints both values on failure
    assert_eq!(add(2, 3), 5);
    assert_ne!(add(2, 3), 6);

    // debug_assert! — only active in debug builds; compiled away in release
    debug_assert!(divide(10, 2) == 5);

    println!("all assertions passed");
}
all assertions passed

Macro

Active in

Use when

assert!(cond)

Always

Invariants that must hold in production

assert_eq!(a, b)

Always

Equality checks with useful failure messages

assert_ne!(a, b)

Always

Inequality checks

debug_assert!(cond)

Debug only

Expensive invariant checks safe to skip in release

The log Crate and env_logger

For applications that need structured, levelled logging (not just println!), use the log crate facade together with an implementation like env_logger:

TOML
[dependencies]
log        = "0.4"
env_logger = "0.11"

RUST
use log::{debug, info, warn, error};

fn process(n: i32) {
    debug!("processing value: {}", n);
    if n < 0 {
        warn!("negative value received: {}", n);
    }
    info!("done processing {}", n);
}

fn main() {
    env_logger::init();  // reads RUST_LOG env var
    process(42);
    process(-1);
    error!("this is an error message");
}

Bash
RUST_LOG=debug cargo run
[2024-01-15T10:23:01Z DEBUG my_crate] processing value: 42
[2024-01-15T10:23:01Z INFO  my_crate] done processing 42
[2024-01-15T10:23:01Z DEBUG my_crate] processing value: -1
[2024-01-15T10:23:01Z WARN  my_crate] negative value received: -1
[2024-01-15T10:23:01Z INFO  my_crate] done processing -1
[2024-01-15T10:23:01Z ERROR my_crate] this is an error message
The tracing Crate

For async code, the tracing crate is preferred over log. It adds spans (structured, nested context) on top of events, which is essential for understanding concurrent and async execution where plain log lines lose their ordering context.

TOML
[dependencies]
tracing          = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

RUST
use tracing::{info, warn, instrument};

#[instrument]  // automatically creates a span named after the function
async fn fetch_user(id: u64) -> String {
    info!(user_id = id, "fetching user");
    // ... async work ...
    format!("user-{}", id)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter("debug")
        .init();

    let user = fetch_user(42).await;
    info!(user = user.as_str(), "fetch complete");
}
Using a Debugger

For complex state bugs where dbg! is not enough, use a full symbolic debugger. Rust ships wrappers around GDB and LLDB that include Rust-specific pretty-printers:

Bash
# Build with debug symbols (default for cargo build)
cargo build

# Launch rust-gdb
rust-gdb target/debug/my-program

# Launch rust-lldb (preferred on macOS)
rust-lldb target/debug/my-program

Text
# Common GDB/LLDB commands inside the debugger
(gdb) break main          # set a breakpoint at main
(gdb) run                 # start the program
(gdb) next                # step over one line
(gdb) step                # step into a function call
(gdb) print variable      # inspect a variable
(gdb) backtrace           # show the call stack
(gdb) continue            # resume until next breakpoint
VS Code Debugging with CodeLLDB

For a graphical debugging experience, install the CodeLLDB extension in VS Code (extension ID: vadimcn.vscode-lldb) and add a launch configuration:

JSON
// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "lldb",
      "request": "launch",
      "name": "Debug my-program",
      "cargo": {
        "args": ["build", "--bin=my-program"],
        "filter": { "name": "my-program", "kind": "bin" }
      },
      "args": [],
      "cwd": "${workspaceFolder}"
    }
  ]
}
Tip
Set breakpoints by clicking the gutter in VS Code, then press F5 to start the debugger. CodeLLDB shows Rust-specific types (Vec contents, Option/Result variants, String values) natively in the Variables panel.
Recommended Debugging Strategy
  1. Start with dbg!() — it is fast to add, zero-dependency, and returns the value so you can wrap sub-expressions.

  2. Use {:#?} with println! for a full pretty-print of complex data structures.

  3. Enable RUST_BACKTRACE=1 any time you have a panic to get the call stack immediately.

  4. Run rustc --explain E0xxx for any compiler error you do not fully understand.

  5. Add log or tracing for long-running applications where you need persistent, filterable output.

  6. Escalate to rust-lldb / CodeLLDB when you need to inspect state at specific points in execution or step through complex algorithms.

  7. Use cargo miri run to catch undefined behaviour in unsafe code.

Success
Rust's debugging tools are well-matched to its error model: most bugs are caught at compile time, dbg! handles the quick runtime cases, andrust-lldb with CodeLLDB covers the hard ones. Building the habit of starting with dbg! and escalating only when needed keeps debugging sessions short and focused.