How to Use Backtrace for Better Panic Messages (RUST_BACKTRACE=1)

Set the `RUST_BACKTRACE` environment variable to `1` before running your Rust binary to automatically include a full stack trace whenever a panic occurs.

When the panic message isn't enough

You run your program. It crashes. The terminal spits out thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 5'. You stare at the line number. That line looks fine. The logic is three functions deep. You have no idea where the bad data came from. You're debugging blind.

The panic message tells you what went wrong. It does not tell you how you got there. Rust hides the call stack by default to keep panic output clean and fast. That default is annoying when you're hunting a bug. You need the stack trace. You need to see the chain of function calls that led to the explosion.

Set the RUST_BACKTRACE environment variable to 1. Rust prints the full call stack on every panic. You get the file names, line numbers, and function names for every frame on the stack. The mystery vanishes.

Turn on the backtrace. Stop guessing.

The call stack is your map

A backtrace is a snapshot of your program's call stack at the moment it dies. Every time a function calls another function, Rust pushes a frame onto the stack. That frame holds the return address, local variables, and arguments. When a panic happens, the runtime walks that stack from the top down. It records each frame. That list is the backtrace.

Think of it like a receipt. The panic message is the total price. The backtrace is the list of items you bought. You can see exactly which functions were active and in what order.

The backtrace has two parts. Capture walks the stack and collects raw addresses. Symbolication turns those addresses into human-readable names and line numbers. Symbolication requires debug information. Without debug info, you get a list of hex addresses that are useless for debugging.

The stack trace is the truth. The panic message is just the headline.

Minimal example: turning on the trace

Here is a program that panics. Run it normally, and you get a short error. Run it with RUST_BACKTRACE=1, and you get the full story.

fn main() {
    let items = vec![10, 20, 30];
    // WHY: This index is out of bounds. The vec has length 3.
    // The panic happens here, but the backtrace shows the context.
    println!("{}", items[5]);
}

Run the code without the variable:

thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 5', src/main.rs:4:20
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Rust gives you a hint. It tells you exactly how to fix the output. Set the variable and run again:

RUST_BACKTRACE=1 cargo run

The output expands:

thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 5', src/main.rs:4:20
stack backtrace:
   0: rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::panicking::panic_bounds_check
   3: my_app::main
   4: core::ops::function::FnOnce::call_once

Frame 3 is my_app::main. That's your code. Frames 0 to 2 are the panic machinery. Frame 4 is the runtime entry point. You can see the path. In a larger program, you'd see your helper functions in the middle.

Convention aside: The community treats RUST_BACKTRACE=1 as the default debugging stance. If you see a panic in CI logs and there's no backtrace, the build config is wrong. Fix the config. Don't ignore the missing trace.

How the backtrace gets built

When you set RUST_BACKTRACE, the panic hook checks the variable. If it's 1 or full, the hook calls std::backtrace::Backtrace::capture(). This function walks the stack. It uses platform-specific APIs to find the return addresses. On Linux, it reads the stack pointer. On Windows, it uses CaptureStackBackTrace.

After capture, Rust symbolicates the addresses. It looks up the function names and line numbers in the debug information embedded in the binary. This is where debug symbols matter. cargo run builds in debug mode by default. Debug mode includes full debug info. The backtrace shows precise line numbers.

Release mode is different. cargo build --release optimizes for speed and size. It strips debug info by default. If you panic in a release binary without debug info, the backtrace shows function names but no line numbers. You get my_app::main but not main.rs:42. That makes debugging much harder.

To get line numbers in release mode, you need to keep the debug info. Add this to your Cargo.toml:

[profile.release]
debug = true

This tells Cargo to embed debug symbols in the release binary. The binary gets larger. The backtrace gets useful.

Convention aside: RUST_BACKTRACE=full includes source code snippets in the output. It prints a few lines of code around each frame. This is handy for local debugging when you don't want to open files. Use 1 for CI logs to keep them concise. Use full when you're staring at the terminal and want context fast.

Realistic scenario: tracking down bad data

Real bugs rarely happen in main. They happen deep in a call chain. A function receives bad data, processes it, and panics. The panic message points to the processing function. You need to find where the data went wrong.

Here is a realistic example. A parser calls a validator. The validator panics.

/// Processes a list of user inputs.
fn process_inputs(inputs: Vec<String>) {
    for input in inputs {
        let value = parse_input(&input);
        println!("Parsed: {}", value);
    }
}

/// Parses a single input string into an integer.
fn parse_input(input: &str) -> i32 {
    // WHY: trim() removes whitespace. parse() fails if the string isn't a number.
    // This panic hides the fact that the input came from process_inputs.
    input.trim().parse::<i32>().expect("Input must be a valid integer")
}

fn main() {
    let data = vec![" 42 ".to_string(), "bad_data".to_string(), "100".to_string()];
    process_inputs(data);
}

Run this with RUST_BACKTRACE=1. The output shows the chain:

thread 'main' panicked at 'Input must be a valid integer', src/main.rs:13:30
stack backtrace:
   0: rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::result::unwrap_failed
   3: my_app::parse_input
   4: my_app::process_inputs
   5: my_app::main

Frame 3 is parse_input. That's where the panic happened. Frame 4 is process_inputs. That's where the data came from. Frame 5 is main. You can see the flow. The bad data started in main, went to process_inputs, and caused a crash in parse_input.

Read the backtrace from bottom to top. The bottom frames are where execution started. The top frames are where it died. The cause is usually in the middle.

Read the bottom up. The panic is the symptom. The top frames are the cause.

Pitfalls and release mode traps

Backtraces can fail silently. You set the variable. You run the program. You get no backtrace. Several things can go wrong.

Release builds often strip symbols. If you built with strip or a profile that sets strip = true, the debug info is gone. The backtrace shows addresses. You can't map them to lines. Check your profile. Ensure debug = true if you need to debug release builds.

Windows uses PDB files for debug info. By default, Cargo puts PDBs in the target directory. If you move the binary, the PDB might not follow. The backtrace fails to symbolicate. Keep the PDB next to the binary, or use cargo build which handles this correctly.

Libraries can suppress backtraces. If a library panics, the binary's panic hook controls the output. Some binaries disable backtraces for performance. If you're debugging a library panic and see no trace, try RUST_LIB_BACKTRACE=1. This variable forces libraries to print backtraces even if the binary suppresses them.

Convention aside: RUST_LIB_BACKTRACE=1 is the secret weapon for library authors. Binaries can suppress backtraces. Libraries respect this variable to override that suppression. If you're maintaining a library and users report panics without traces, tell them to set this variable.

The backtrace crate vs std::backtrace. The standard library uses the backtrace crate internally. You can use the crate directly for more control. The crate supports advanced features like resolving symbols in release builds with external debug info. The standard library API is simpler. Stick to std::backtrace unless you need the crate's features.

Debug symbols are not optional for debugging. Ship them if you need to debug.

Programmatic backtraces and error types

Environment variables work for panics. They don't work for errors. If you return a Result::Err, the program doesn't panic. No backtrace prints. You need to capture the backtrace manually and attach it to the error.

The standard library provides std::backtrace::Backtrace. You can capture it at any point.

use std::backtrace::Backtrace;

/// Represents an application error with a backtrace.
#[derive(Debug)]
struct AppError {
    message: String,
    trace: Backtrace,
}

impl AppError {
    /// Creates a new error and captures the current backtrace.
    fn new(message: &str) -> Self {
        // WHY: force_capture() ensures we get a backtrace even if RUST_BACKTRACE is off.
        // This is useful for error types that always want a trace.
        Self {
            message: message.to_string(),
            trace: Backtrace::force_capture(),
        }
    }
}

fn do_work() -> Result<(), AppError> {
    // WHY: Simulate an error condition.
    if true {
        return Err(AppError::new("Something went wrong"));
    }
    Ok(())
}

fn main() {
    match do_work() {
        Ok(_) => println!("Success"),
        Err(e) => {
            eprintln!("Error: {}", e.message);
            // WHY: Print the backtrace attached to the error.
            eprintln!("Backtrace:\n{}", e.trace);
        }
    }
}

Backtrace::capture() respects RUST_BACKTRACE. If the variable is off, it returns an empty backtrace. Backtrace::force_capture() ignores the variable and always captures. Use force_capture() when you want the backtrace regardless of environment settings.

Convention aside: The community prefers anyhow::Error for application-level error handling. anyhow captures backtraces automatically when RUST_BACKTRACE is set. You don't need to write custom error types. If you're building a CLI tool, use anyhow. It handles backtraces for you.

Pick the tool that matches your error strategy. Panics get environment variables. Errors get types.

Decision: choosing your backtrace strategy

Backtraces come in different forms. Pick the right one for your situation.

Use RUST_BACKTRACE=1 when you need a quick stack trace for a panic without changing code. This is the standard way to debug panics in development.

Use RUST_BACKTRACE=full when you want source code snippets inlined with the trace for faster context switching. This is useful for local debugging when you don't want to open files.

Use RUST_LIB_BACKTRACE=1 when a library panics but the binary suppresses the backtrace. This forces the library to print the trace.

Use std::backtrace::Backtrace::force_capture() when you need to capture a backtrace programmatically and attach it to an error type. This ensures you get the trace even if the environment variable is off.

Use the backtrace crate when you need advanced features like resolving symbols in release builds or custom formatting. The crate gives you full control over capture and symbolication.

Use anyhow::Backtrace when you are using anyhow for error handling and want automatic backtrace attachment. anyhow integrates seamlessly with RUST_BACKTRACE.

Trust the borrow checker. It usually has a point. Trust the backtrace too. It shows you exactly where the code went wrong.

Where to go next