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:
- Prints the source file and line number, the expression text, and the value to stderr.
- Returns the value unchanged — so you can wrap any sub-expression without restructuring your code.
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
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 implementDisplay)
#[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.
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:
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:
# 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.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:
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.
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
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:
[dependencies] log = "0.4" env_logger = "0.11"
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");
}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.
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }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:
# 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
# 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:
// .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}"
}
]
}Recommended Debugging Strategy
Start with
dbg!()— it is fast to add, zero-dependency, and returns the value so you can wrap sub-expressions.Use
{:#?}withprintln!for a full pretty-print of complex data structures.Enable
RUST_BACKTRACE=1any time you have a panic to get the call stack immediately.Run
rustc --explain E0xxxfor any compiler error you do not fully understand.Add
logortracingfor long-running applications where you need persistent, filterable output.Escalate to
rust-lldb/ CodeLLDB when you need to inspect state at specific points in execution or step through complex algorithms.Use
cargo miri runto catch undefined behaviour in unsafe code.
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.