When the error message isn't enough
You're debugging a production service. The logs scream Error: connection refused, but your codebase has fifty places that open connections. You don't know which one failed. You add println! statements everywhere, rebuild, deploy, and wait. It happens again. You need more than the error message. You need the stack trace. You need to see exactly which function called which function led to the crash.
Rust gives you this power, but it requires a deliberate choice. Panics print backtraces automatically when you set an environment variable. Errors returned via Result stay silent by default. You have to opt in. The std::error::Error trait is the bridge that lets your error types carry stack traces, and the std::backtrace::Backtrace type is the container that holds them.
What a backtrace actually is
A backtrace is a snapshot of the call stack at a specific moment. It lists every function on the stack, from main down to the point of failure, along with file paths and line numbers. It answers the question "how did we get here?"
For panics, Rust captures this automatically. For errors, Rust assumes you want speed and minimal overhead, so it skips the capture. You must ask for it. The std::error::Error trait has a method called backtrace(). When you implement this method, you tell the ecosystem that your error type can provide a stack trace. Tools like anyhow, logging frameworks, and crash reporters check for this method. If it exists, they use it. If not, they move on.
The std::backtrace::Backtrace struct holds the trace. You create it by calling Backtrace::capture(). This function walks the stack, records the addresses, and returns a snapshot. The snapshot is cheap to clone and cheap to store. The cost is in the capture.
Minimal example: capturing a trace manually
You can capture backtraces using only the standard library. This approach requires a bit of boilerplate, but it has zero dependencies. Define your error struct, store a Backtrace, and implement Error.
use std::backtrace::Backtrace;
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct AppError {
msg: String,
// Store the backtrace captured at the moment the error is created.
backtrace: Backtrace,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Display the human-readable message.
write!(f, "{}", self.msg)
}
}
impl Error for AppError {
// Expose the backtrace to tools that query the Error trait.
fn backtrace(&self) -> Option<&Backtrace> {
Some(&self.backtrace)
}
}
fn risky_operation() -> Result<(), AppError> {
// Capture the stack trace right here.
// This records the call chain leading to this line.
Err(AppError {
msg: "Database connection failed".into(),
backtrace: Backtrace::capture(),
})
}
fn main() {
if let Err(e) = risky_operation() {
// Print the error message.
eprintln!("Error: {}", e);
// Print the backtrace explicitly.
eprintln!("Trace:\n{}", e.backtrace);
}
}
Run this with RUST_BACKTRACE=1 cargo run. The output includes file names and line numbers. Without the variable, you get a minimal trace with just function names. The environment variable controls the verbosity and detail level of the capture.
Convention aside: use Backtrace::capture() instead of Backtrace::new(). The capture name makes it clear that you are taking a snapshot of the current stack. The community treats capture as the canonical name.
Set RUST_BACKTRACE=1 before you deploy. You'll thank yourself when the first bug hits.
How the capture works
When you call Backtrace::capture(), the runtime walks the stack frames. It records the return address of each frame. If debug symbols are available, it resolves those addresses to function names, file paths, and line numbers. The resolution happens lazily when you print the backtrace. The capture itself is fast. The printing is slower.
The RUST_BACKTRACE environment variable gates the behavior. If it is not set, capture() may return a minimal trace or skip symbol resolution entirely. If it is set to 1, you get full details. If you set it to full, you get even more metadata, including inlined functions. This variable is the standard switch for enabling backtraces across the ecosystem.
The Error::backtrace() method returns Option<&Backtrace>. This allows error types to opt out. If your error type doesn't store a backtrace, return None. Tools handle this gracefully. They won't crash if the backtrace is missing. They just won't print one.
If you wrap an error using Box<dyn Error>, the backtrace is preserved if the inner type implements Error::backtrace(). The trait object forwards the call. You don't lose the trace when you erase the type.
Don't hide the trace. If you wrap an error, pass the backtrace up.
Realistic example: automatic capture with anyhow
Manual structs work, but they are tedious. Real applications use crates to handle the boilerplate. The anyhow crate is the standard choice for application code. It captures backtraces automatically when you use .context() or the ? operator. It checks RUST_BACKTRACE internally and attaches the trace to the error.
use anyhow::{Context, Result};
fn fetch_config() -> Result<()> {
// Read a file that might not exist.
let content = std::fs::read_to_string("config.toml")
// Add context to the error.
// anyhow captures the backtrace here automatically.
.context("Failed to read configuration file")?;
// Parse the content.
let config = toml::from_str(&content)
.context("Failed to parse configuration")?;
Ok(())
}
fn main() -> Result<()> {
fetch_config()?;
Ok(())
}
When this code fails, anyhow prints the error chain and the backtrace. The backtrace points to the exact line where the error occurred. You don't need to store Backtrace manually. anyhow does it for you.
Convention aside: anyhow is for application code. Use it in main.rs or the top-level binary. Do not use anyhow in library crates. Libraries should define precise error types using thiserror or manual structs. Forcing anyhow on library users couples them to your error handling strategy. Keep the boundary clear.
Use anyhow at the top of your app. Use thiserror in your libraries. Keep the boundary clear.
Realistic example: library errors with thiserror
Libraries need to define error types that users can match on. The thiserror crate generates Error implementations for enums and structs. It also supports backtraces via the #[backtrace] attribute. This attribute tells thiserror to capture a backtrace automatically when the variant is constructed.
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Custom error: {0}")]
// Capture a backtrace for this variant.
Custom(String, #[backtrace] Backtrace),
}
fn process_data() -> Result<(), AppError> {
// Return a custom error with a backtrace.
Err(AppError::Custom("Data validation failed".into()))
}
fn main() {
if let Err(e) = process_data() {
eprintln!("Error: {}", e);
// Access the backtrace via the Error trait.
if let Some(bt) = e.backtrace() {
eprintln!("Trace:\n{}", bt);
}
}
}
The #[backtrace] attribute works on fields of type Backtrace. thiserror generates code that captures the trace when you construct the variant. This keeps the library code clean while providing full debugging info. Users of the library can query e.backtrace() to get the trace.
Convention aside: thiserror is the standard for library error types. It generates minimal, efficient code. It integrates with anyhow seamlessly. If you use thiserror in a library and anyhow in the app, the backtraces flow through the boundary without loss.
Libraries define errors. Applications catch them. Backtraces belong at the boundary where you decide to stop handling and start reporting.
Pitfalls and performance
Backtraces cost CPU cycles. Capturing a trace walks the stack. This takes microseconds to milliseconds depending on the depth and the platform. If you capture backtraces in a hot loop, you will tank performance. Only capture when an error actually occurs. Never capture in the success path.
Backtraces also increase binary size. The backtrace crate adds symbol resolution code. Your binary gets bigger. In release builds, you might strip symbols to reduce size. If you strip symbols, backtraces show ?? for function names. You lose the debugging info. Decide early whether you need backtraces in production. If you do, keep debug symbols or use a separate debug build.
The RUST_BACKTRACE variable is not just for printing. It controls capture behavior. If you don't set it, some implementations skip capture entirely to save time. If you rely on backtraces for logging, set the variable in your deployment environment.
Compiler errors can trip you up. If you try to use ? with a type that doesn't implement Error, you get E0277 (trait bound not satisfied). The compiler tells you the type doesn't implement std::error::Error. Fix this by implementing the trait or using a wrapper.
Profile before you panic. If backtraces slow down your error path, capture them lazily.
Decision: when to use what
Use std::backtrace::Backtrace when you need zero dependencies and are writing a custom error type.
Use anyhow when you are writing application code and want automatic backtrace capture with minimal boilerplate.
Use thiserror when you are writing a library and need to define precise error types without forcing backtrace overhead on your users.
Use the backtrace crate directly when you need low-level control over symbol resolution or want to capture traces in unsafe contexts where the standard library abstractions are too heavy.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about.