Debugging & Diagnostics
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust 1.99.0, tracing 0.1.44, tracing-subscriber 0.3.23, log 0.4 and env_logger 0.11, checked on 2026-10-06. Rust’s compiler catches many bugs before the program runs, but logic errors, unexpected input and performance problems still need investigating. This page covers the tools from quickest to most thorough: print debugging, panics and backtraces, interactive debuggers, structured logging and tracing, and Miri.
dbg! and eprintln!
dbg!(expr) prints the file, line, the expression’s source text and its Debug value to stderr, and returns the value,
so it can be wrapped around any sub-expression without restructuring code. It takes ownership of its argument (pass a
reference to keep it) and prints nothing different in release builds — remove it before committing (Clippy’s
dbg_macro restriction lint can enforce that).
#[derive(Debug)]
struct Loan {
member: String,
days: u16,
}
fn fee(days_late: u16) -> u32 {
u32::from(dbg!(days_late.min(10))) * 50 // inspect an intermediate value
}
fn main() {
let loan = Loan { member: "alice".into(), days: 25 };
let late = dbg!(loan.days.saturating_sub(21));
dbg!(&loan); // borrow, so `loan` stays usable
eprintln!("fee: {}", fee(late)); // stderr, keeps stdout clean for real output
}
// [src/main.rs:13:16] loan.days.saturating_sub(21) = 4
// [src/main.rs:14:5] &loan = Loan {
// member: "alice",
// days: 25,
// }
// [src/main.rs:8:15] days_late.min(10) = 4
// fee: 200
Source: std::dbg
Derive Debug on every type (it is cheap and the API Guidelines recommend it) so it can be inspected; {:#?}
pretty-prints nested structures. When the derived output is too noisy or leaks secrets, implement Debug by hand — for
example to print Password(***). See Standard
Traits & Operator Overloading. Azam (Advanced Rust, chapter 2) treats dbg!, stderr and Debug versus Display as
the first debugging tools to reach for.
Panics and backtraces
A panic prints its message and location. Set RUST_BACKTRACE=1 to also print a backtrace of the panicking thread, or
RUST_BACKTRACE=full for every frame including the runtime’s:
RUST_BACKTRACE=1 cargo run
# thread 'main' panicked at src/main.rs:4:14:
# called `Option::unwrap()` on a `None` value
# stack backtrace:
# 0: __rustc::rust_begin_unwind
# ...
# 3: shelf::find_book
# at ./src/main.rs:4:14
# 4: shelf::main
# at ./src/main.rs:9:5
Backtraces need debug info: they are most useful in dev builds, or in release builds with debug =
"line-tables-only" in the profile. std::backtrace::Backtrace captures one programmatically — Backtrace::capture()
respects RUST_BACKTRACE/RUST_LIB_BACKTRACE, force_capture() always captures — which is how anyhow attaches
backtraces to errors.
std::panic::set_hook replaces the code that runs on every panic (before unwinding), for example to log the panic as a
structured event or report it to an error tracker. The hook receives a PanicHookInfo with the message and location:
use std::backtrace::Backtrace;
use std::panic;
fn main() {
panic::set_hook(Box::new(|info| {
let location = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let message = info.payload_as_str().unwrap_or("non-string panic payload"); // 1.91
eprintln!("PANIC at {location}: {message}");
eprintln!("backtrace captured: {}", !matches!(Backtrace::capture().status(), std::backtrace::BacktraceStatus::Disabled));
}));
let result = panic::catch_unwind(|| {
let shelves: Vec<&str> = Vec::new();
shelves[0].len() // index out of bounds
});
println!("recovered: {}", result.is_err());
}
Source: std::panic::set_hook
Debuggers: LLDB, GDB and VS Code
Rust compiles to native code with DWARF (or PDB on Windows) debug info, so the usual native debuggers work. The
toolchain ships wrapper scripts that load Rust-aware pretty-printers for String, Vec, Option, HashMap and so on:
cargo build # dev profile: debug info, no optimisation
rust-lldb target/debug/shelf # or: rust-gdb target/debug/shelf
(lldb) breakpoint set --name shelf::find_book
(lldb) run search dune
(lldb) frame variable # locals, pretty-printed
(lldb) bt # backtrace
(lldb) next / step / continue
Source: The rustc Book: Debug info
In VS Code, install rust-analyzer and CodeLLDB (or use the C/C++ extension with GDB on Linux and the MSVC debugger on
Windows). rust-analyzer adds Debug code lenses above main and every test, and generates the launch configuration.
Debugging optimised builds is possible but variables are often "optimised out"; when needed, add a profiling profile
that inherits from release with debug = true (see Cargo &
Workspaces).
Logging: log and env_logger
The log crate is a facade: libraries call log::info!, debug!, warn!, and the application chooses a backend once.
env_logger is the simplest backend, configured with the RUST_LOG environment variable (RUST_LOG=shelf=debug,info):
use log::{debug, info, warn};
fn import(lines: &[&str]) -> usize {
info!("importing {} lines", lines.len());
let mut ok = 0;
for (i, line) in lines.iter().enumerate() {
if line.contains(';') {
debug!("line {i}: {line}");
ok += 1;
} else {
warn!("skipping malformed line {i}");
}
}
ok
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let n = import(&["Dune;1965", "garbage", "Emma;1815"]);
info!("imported {n} books");
}
Source: log crate documentation
Tracing: structured, contextual diagnostics
tracing extends the logging idea with spans — periods of time with structured fields, which nest — and events
inside them. In async code, where one thread interleaves many tasks, spans are what make a log line attributable to the
request that produced it. tracing is the standard in the tokio ecosystem and the basis of OpenTelemetry integration
(see Axum: Logging & Tracing).
use tracing::{debug, info, info_span, instrument, warn, Instrument};
#[derive(Debug)]
struct Isbn(String);
#[instrument(skip(catalog), fields(found))] // a span per call, recording `isbn` but not `catalog`
async fn lookup(catalog: &[(&str, &str)], isbn: &Isbn) -> Option<String> {
tokio::task::yield_now().await; // the span stays attached across .await
let title = catalog.iter().find(|(i, _)| *i == isbn.0).map(|(_, t)| t.to_string());
tracing::Span::current().record("found", title.is_some());
debug!(?title, "lookup finished"); // `?` records the field with Debug
title
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt() // the modern one-liner setup
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into()))
.with_target(false)
.init();
let catalog = [("978", "Dune")];
info!(books = catalog.len(), "catalogue loaded");
let request = info_span!("request", id = 42);
let title = lookup(&catalog, &Isbn("978".into())).instrument(request).await;
if title.is_none() {
warn!("not found");
}
}
// INFO catalogue loaded books=1
// DEBUG request{id=42}:lookup{isbn=Isbn("978")}: lookup finished title=Some("Dune")
Source: tracing documentation
Key points: use #[instrument] on functions, skip(..) large or secret arguments, attach spans to futures with
.instrument(span) rather than span.enter() (an enter guard held across .await attaches the span to the wrong
task), and configure output in one place with tracing_subscriber::fmt() (plain text, .json() for machines,
.pretty() for humans). Libraries that use log are captured too through the tracing-log bridge, which
tracing-subscriber's default features enable. Azam (chapter 2) uses log/env_logger and tracing with
#[instrument]; the tracing_subscriber::fmt().init() builder shown here is the current idiom.
Miri: detecting undefined behaviour
Miri is an interpreter for Rust’s MIR that executes a program while checking for undefined behaviour: out-of-bounds
access, use-after-free, invalid values, data races, misaligned pointers, violations of the aliasing model (Stacked or
Tree Borrows) and memory leaks. It is slow (100x or more) but invaluable for testing unsafe code:
rustup +nightly component add miri
cargo +nightly miri test # run the test suite under Miri
cargo +nightly miri run
Source: Miri
Miri is nightly-only and cannot run code that calls into C or performs most system calls. Use it for the crates and
modules that contain unsafe code; see Unsafe Rust.
Reading compiler diagnostics
The compiler’s messages are part of the debugging toolbox: read the first error first, follow the primary and secondary
labels, apply help: suggestions, and use rustc --explain E0XXX (or the online error index) for the long explanation
with examples. cargo check --message-format short gives one line per diagnostic; cargo clippy --fix and cargo fix
apply machine-applicable suggestions. See
Getting Started: Reading compiler errors.
Where the books are outdated
Azam (Advanced Rust, chapter 2) covers dbg!, stderr, Debug/Display, panics and RUST_BACKTRACE, panic::set_hook,
debugger setup, log/env_logger and tracing. Since then the panic hook receives PanicHookInfo (renamed from
PanicInfo in 1.81) and gained payload_as_str (1.91), and tracing_subscriber::fmt().init() with EnvFilter has
become the usual subscriber setup.