How does thiserror::Error derive #[backtrace] attribute for capturing stack traces on error creation?
The #[backtrace] attribute in thiserror marks a field to be used as the error's backtrace, automatically implementing std::error::Error::backtrace() to return a reference to that field when captured at the point of error creation. This enables stack trace capture without manual boilerplate, but the backtrace must be explicitly created and storedāthiserror doesn't automatically capture it, it just wires up the trait implementation.
The Role of Backtraces in Error Handling
use std::backtrace::Backtrace;
fn backtrace_basics() {
// Backtrace captures the call stack at a point in time
let backtrace = Backtrace::capture();
// The backtrace shows the function call chain
println!("{}", backtrace);
// Output looks like:
// 0: std::backtrace::Backtrace::capture
// 1: my_module::backtrace_basics
// 2: my_module::main
// ...
// Capturing happens at the point where capture() is called
// Not when the error is propagated
}Backtraces show how execution reached an error point, essential for debugging where failures originate.
Rust's Backtrace Support
use std::backtrace::Backtrace;
fn backtrace_support() {
// Rust 1.65+ has stable Backtrace support
// Environment variable controls capture: RUST_BACKTRACE=1
// Three capture modes:
let bt_full = Backtrace::capture(); // Full backtrace (if enabled)
// Backtrace::disabled() always returns empty backtrace
// (even with RUST_BACKTRACE=1)
// When RUST_BACKTRACE is not set:
// Backtrace::capture() returns Backtrace::disabled()
// No runtime overhead in release builds
// When RUST_BACKTRACE=1:
// Backtrace::capture() returns actual stack trace
// This is why backtraces must be explicitly enabled
}Backtraces require RUST_BACKTRACE=1 environment variable to actually captureāthe default is disabled for performance.
Basic thiserror Backtrace Pattern
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Processing failed")]
struct ProcessingError {
#[backtrace]
backtrace: Backtrace,
}
fn create_error() {
// The backtrace is captured HERE, at error creation
let error = ProcessingError {
backtrace: Backtrace::capture(),
};
// When printed, shows both error and backtrace
println!("{}", error);
}The #[backtrace] attribute tells thiserror that this field should implement std::error::Error::backtrace().
How #[backtrace] Works
use thiserror::Error;
use std::backtrace::Backtrace;
// thiserror generates:
// impl std::error::Error for ProcessingError {
// fn backtrace(&self) -> Option<&Backtrace> {
// Some(&self.backtrace)
// }
// }
#[derive(Error, Debug)]
#[error("Processing failed")]
struct ProcessingError {
#[backtrace]
backtrace: Backtrace,
}
fn attribute_behavior() {
let err = ProcessingError {
backtrace: Backtrace::capture(),
};
// The #[backtrace] attribute enables:
let bt: Option<&Backtrace> = std::error::Error::backtrace(&err);
// This is useful for error chaining and introspection
// Error handling code can extract the backtrace
}The #[backtrace] attribute implements Error::backtrace() to return a reference to the marked field.
Automatic Backtrace Capture with source
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Database operation failed")]
struct DatabaseError {
source: std::io::Error,
#[backtrace]
backtrace: Backtrace,
}
// With source field (error chaining):
// thiserror also generates source() implementation
// The backtrace is captured at DatabaseError creation
fn with_source() {
let io_error = std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found"
);
// Backtrace captured HERE
let db_error = DatabaseError {
source: io_error,
backtrace: Backtrace::capture(),
};
// The backtrace shows where DatabaseError was created
// Not where the underlying io::Error was created
}When an error has a source, the backtrace shows where the current error was created, not where the source originated.
Backtrace in Enums
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
Io(#[source] std::io::Error, #[backtrace] Backtrace),
#[error("Parse error: {0}")]
Parse(#[source] std::num::ParseIntError, #[backtrace] Backtrace),
#[error("Custom error: {message}")]
Custom {
message: String,
#[backtrace]
backtrace: Backtrace,
},
}
fn enum_backtrace() {
let io_error = std::io::Error::new(
std::io::ErrorKind::Other,
"io failure"
);
let app_error = AppError::Io(io_error, Backtrace::capture());
// The backtrace is accessible via Error::backtrace()
let bt = std::error::Error::backtrace(&app_error);
}Enum variants can also have backtrace fields, each marked with #[backtrace].
Capturing Backtrace at Creation
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Failed to process {item}")]
struct ProcessError {
item: String,
#[backtrace]
backtrace: Backtrace,
}
// Common pattern: Constructor that captures backtrace
impl ProcessError {
fn new(item: impl Into<String>) -> Self {
Self {
item: item.into(),
backtrace: Backtrace::capture(), // Captured here
}
}
}
fn usage() {
// Caller doesn't need to think about backtrace
let error = ProcessError::new("item-123");
// Backtrace shows where new() was called
}A constructor pattern ensures the backtrace is always captured at error creation.
Backtrace Propagation in Error Chains
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Low-level error")]
struct LowLevelError {
#[backtrace]
backtrace: Backtrace,
}
#[derive(Error, Debug)]
#[error("High-level error")]
struct HighLevelError {
#[source]
source: LowLevelError,
#[backtrace]
backtrace: Backtrace,
}
fn error_chain() {
// Low-level error created
let low = LowLevelError {
backtrace: Backtrace::capture(), // Backtrace A
};
// Later, wrapped in high-level error
let high = HighLevelError {
source: low,
backtrace: Backtrace::capture(), // Backtrace B
};
// Each error has its own backtrace
// Backtrace A: Where LowLevelError was created
// Backtrace B: Where HighLevelError was created
// When debugging, you see both creation points
}Each error in a chain can have its own backtrace, showing where each error variant was created.
Backtrace vs Source Trace
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Service error")]
struct ServiceError {
#[source]
source: InnerError,
#[backtrace]
backtrace: Backtrace,
}
#[derive(Error, Debug)]
#[error("Inner error")]
struct InnerError {
#[backtrace]
backtrace: Backtrace,
}
fn trace_comparison() {
// source chain shows ERROR TYPES
// backtrace shows CODE LOCATIONS
// Example output:
// Error: Service error
// Caused by: Inner error
//
// Stack backtrace:
// 0: ServiceError::new at src/service.rs:15
// 1: InnerError::new at src/inner.rs:10
// 2: main at src/main.rs:5
// The backtrace complements the error chain
// Error chain: WHAT went wrong (error types)
// Backtrace: WHERE it went wrong (code locations)
}Backtraces show code locations; the error chain shows what went wrong.
Conditional Backtrace Capture
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Operation failed")]
struct OperationError {
#[backtrace]
backtrace: Backtrace,
}
// Backtrace capture depends on RUST_BACKTRACE:
// - RUST_BACKTRACE not set: Backtrace::capture() returns disabled
// - RUST_BACKTRACE=1: Full capture
// - RUST_BACKTRACE=full: Full capture with filenames
fn conditional_capture() {
// No environment check needed in code
// Backtrace::capture() handles it automatically
let error = OperationError {
backtrace: Backtrace::capture(),
};
// In production (no RUST_BACKTRACE): Empty backtrace
// In development (RUST_BACKTRACE=1): Full stack trace
}The environment variable controls capture; code doesn't need to check explicitly.
Generic Error with Backtrace
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Failed to parse data")]
struct ParseError<E: std::error::Error + 'static> {
#[source]
source: E,
#[backtrace]
backtrace: Backtrace,
}
// Generic errors can also include backtrace
// The backtrace is still captured at ParseError creationGeneric error types work with the #[backtrace] attribute the same way.
Backtrace in Display Output
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Critical failure")]
struct CriticalError {
#[backtrace]
backtrace: Backtrace,
}
fn display_behavior() {
let error = CriticalError {
backtrace: Backtrace::capture(),
};
// The #[error] message is used by Display
println!("{}", error); // "Critical failure"
// Backtrace is NOT automatically included in Display
// You need to explicitly print it:
println!("Error: {}", error);
if let Some(bt) = std::error::Error::backtrace(&error) {
println!("Backtrace:\n{}", bt);
}
}The #[error] message defines Display, not backtrace formattingāyou must print backtraces explicitly.
Integration with anyhow/eyre
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Application error")]
struct AppError {
#[backtrace]
backtrace: Backtrace,
}
// anyhow and eyre support backtraces automatically
// When you convert an error with backtrace:
fn anyhow_integration() {
// anyhow::Error supports backtrace()
// If the inner error implements Error::backtrace(),
// anyhow will use it
let err: anyhow::Error = AppError {
backtrace: Backtrace::capture(),
}.into();
// anyhow::Error::backtrace() returns the captured backtrace
let bt = err.backtrace();
}Error handling libraries like anyhow and eyre integrate with Error::backtrace().
Performance Considerations
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Performance test")]
struct PerfError {
#[backtrace]
backtrace: Backtrace,
}
fn performance_impact() {
// Backtrace capture has overhead:
// - RUST_BACKTRACE unset: Minimal (returns disabled)
// - RUST_BACKTRACE=1: Significant (walks stack)
// Recommendation:
// - Only capture when needed
// - Use RUST_BACKTRACE for dev/debug only
// - Don't capture in hot paths in production
// Alternative: Lazy capture
#[derive(Error, Debug)]
#[error("Lazy error")]
struct LazyError {
backtrace: Option<Backtrace>,
}
// Capture only when needed:
impl LazyError {
fn capture_backtrace(&mut self) {
if self.backtrace.is_none() {
self.backtrace = Some(Backtrace::capture());
}
}
}
}Backtrace capture has performance cost; use RUST_BACKTRACE to control it globally.
Multiple Backtrace Fields
use thiserror::Error;
use std::backtrace::Backtrace;
// Only ONE backtrace field should be marked per error
#[derive(Error, Debug)]
#[error("Multi-backtrace error")]
struct MultiError {
#[backtrace]
primary_backtrace: Backtrace,
// This is the one returned by Error::backtrace()
// Another unmarked backtrace field:
secondary_backtrace: Option<Backtrace>,
// This is just a regular field, not used by Error::backtrace()
}Only one field per error should be marked #[backtrace]āthat's the one Error::backtrace() returns.
Backtrace with Message Formatting
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Failed to process {item}: {message}")]
struct DetailedError {
item: String,
message: String,
#[backtrace]
backtrace: Backtrace,
}
// The #[error] attribute formats the Display message
// The #[backtrace] attribute marks the backtrace field
// These are orthogonal concerns
fn detailed_usage() {
let error = DetailedError {
item: "user-data".to_string(),
message: "invalid format".to_string(),
backtrace: Backtrace::capture(),
};
println!("{}", error); // "Failed to process user-data: invalid format"
}The #[error] and #[backtrace] attributes serve different purposesāmessage formatting vs backtrace wiring.
Complete Error Type Pattern
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Service error: {message}")]
struct ServiceError {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
#[backtrace]
backtrace: Backtrace,
}
impl ServiceError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
source: None,
backtrace: Backtrace::capture(),
}
}
fn with_source(message: impl Into<String>, source: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
message: message.into(),
source: Some(Box::new(source)),
backtrace: Backtrace::capture(),
}
}
}
fn complete_pattern() {
let err = ServiceError::new("connection failed");
// Has backtrace automatically
let bt = std::error::Error::backtrace(&err);
}A complete error type combines message, source, and backtrace for comprehensive error information.
Summary Table
fn summary_table() {
// | Attribute | Purpose | Example |
// |-----------|---------|---------|
// | #[backtrace] | Mark backtrace field | #[backtrace] backtrace: Backtrace |
// | #[source] | Mark error source | #[source] source: io::Error |
// | #[error(...)] | Format Display | #[error("Failed: {0}")] |
// | Method | Returns | Notes |
// |--------|---------|-------|
// | Backtrace::capture() | Backtrace | Full if RUST_BACKTRACE=1 |
// | Backtrace::disabled() | Backtrace | Always empty |
// | Error::backtrace(&self) | Option<&Backtrace> | From #[backtrace] field |
// | Environment | Capture Behavior |
// |-------------|------------------|
// | (unset) | Returns disabled |
// | RUST_BACKTRACE=1 | Full capture |
// | RUST_BACKTRACE=full | Full with filenames |
}Synthesis
Quick reference:
use thiserror::Error;
use std::backtrace::Backtrace;
#[derive(Error, Debug)]
#[error("Operation failed")]
struct MyError {
#[backtrace]
backtrace: Backtrace,
}
// Create with backtrace
let error = MyError {
backtrace: Backtrace::capture(),
};
// Access via Error trait
if let Some(bt) = std::error::Error::backtrace(&error) {
println!("Backtrace:\n{}", bt);
}Key insight: The #[backtrace] attribute in thiserror is a wiring mechanism that implements std::error::Error::backtrace() to return a reference to the marked field, but thiserror does not automatically capture the backtraceāyou must explicitly call Backtrace::capture() when creating the error. The backtrace is captured at the point where Backtrace::capture() is called, showing the call stack at error creation time, not propagation time. This separation of concerns allows flexibility: you control when backtraces are captured (typically in error constructors) while thiserror handles the trait implementation. The RUST_BACKTRACE environment variable controls whether captures are full or disabled, making backtraces opt-in for production while available for debugging. When combined with #[source] for error chaining, each error in a chain can have its own backtrace, providing both the "what went wrong" (error chain) and "where it happened" (backtrace) for comprehensive debugging.
