The panic! Macro

A detailed guide to Rust's panic! macro, its syntax, behavior, edition differences, and how to understand the output and backtrace it generates

Rust's panic! macro is the language's way of saying "something has gone wrong in a way that the program cannot continue." When you call panic!, the current thread halts, an error message is printed, and the stack is unwound (by default) until the program terminates. It is the mechanism for handling truly unrecoverable situations — bugs, violated invariants, or states that should never occur.

Panic vs. Result:

panic! is for errors you cannot recover from. For expected failures like a missing file or invalid user input, Rust provides the Result type. The next chapter section covers when to choose one over the other.

What Happens When You Call panic!

The simplest use of panic! is a bare call with no arguments:

fn main() {
    panic!();
}

Running this program prints something like:

thread 'main' panicked at src/main.rs:2:5:
explicit panic
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The output tells you:

  • which thread panicked (here the main thread)
  • the exact file, line, and column where panic! was called
  • a default message "explicit panic" when no custom message is supplied
  • a hint to set RUST_BACKTRACE=1 for a stack trace

The program stops immediately after printing this information. No more code in the panicking thread runs after the macro executes.

Why the panic! Macro Exists

At the lowest level, panic! protects your program from continuing in a broken state. In languages like C, accessing an array beyond its bounds is undefined behavior — the program might crash, return garbage data, or silently corrupt memory. Rust refuses to do that. If you attempt an out‑of‑bounds access on a vector, Rust calls panic! internally and halts execution:

fn main() {
    let v = vec![1, 2, 3];
    let _item = v[99]; // This triggers a panic
}

The output points to the standard library code that detected the problem, not your own code:

thread 'main' panicked at src/main.rs:3:19:
index out of bounds: the len is 3 but the index is 99

This behavior turns a dangerous memory bug into a deterministic, loud failure. You know exactly where the program stopped and why. It’s a safety feature, not just a crash.

Beyond bounds checks, panic! is the tool you use to enforce your own invariants — things that must be true for your code to work correctly. For example, a function that expects a non‑empty slice can panic if it receives an empty one:

fn average(values: &[f64]) -> f64 {
    if values.is_empty() {
        panic!("average called with an empty slice");
    }
    values.iter().sum::<f64>() / values.len() as f64
}

If a caller ever passes an empty slice, the program stops immediately with a clear message. This is far better than returning a meaningless value like zero, which could hide the bug until much later.

The Format String and Arguments

panic! accepts a format string followed by formatting arguments, just like println! or format!. This lets you build a detailed error message using the values that caused the panic:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("attempted to divide {a} by zero", a = a);
    }
    a / b
}
fn main() {
    let result = divide(10, 0);
    println!("Result: {result}");
}

Running this prints:

thread 'main' panicked at src/main.rs:3:9:
attempted to divide 10 by zero

Side effects in format arguments are safe:

You might worry that a formatting argument like {counter += 1; counter} could be evaluated multiple times if the macro expanded it repeatedly. This is not the case. panic! uses format_args! internally, which evaluates each argument exactly once. The payload string is fully built before the panic begins.

You can also use named parameters and other formatting syntax:

panic!("Invalid state: {name} has count {count}", name = "queue", count = -1);

The message you write is the payload that gets stored with the panic. By default, it is printed to standard error. Later you’ll see that you can capture this payload in a custom panic hook.

Edition Differences in panic! Syntax

The behavior of panic! changed between Rust editions. The differences affect what you can pass as an argument and how a single argument is interpreted.

In Rust 2021 and later, panic! always expects a format string. You cannot pass an arbitrary non‑string value as the payload.

// Rust 2021: a bare panic!() is fine; default message is "explicit panic"
panic!();
// A string literal with no placeholders: works as a format string
panic!("something went wrong");
// Formatting with arguments: works as expected
panic!("value is {value}, code is {code}", value = 42, code = 7);
// This does NOT compile: a single non‑string‑literal argument is forbidden
let msg = String::from("bad");
panic!(msg); // ERROR: expected a format string literal

If you need to panic with a payload that is not a format string (like a custom type), use std::panic::panic_any:

use std::panic::panic_any;
let custom_error = MyError { code: 42 };
panic_any(custom_error);

How panic! Terminates the Program

When panic! is called, Rust:

  1. Constructs a panic payload from the format string and arguments.
  2. Invokes the panic hook (a function that can be customized, see next section).
  3. By default, the hook prints the panic message and location to stderr.
  4. Starts unwinding — walking back up the stack and running Drop implementations for all local variables.
  5. Once unwinding completes (or if unwinding is disabled), the thread exits. If the main thread panics, the whole process terminates with exit code 101.

The unwinding process is covered in detail in the next section. For now, the key point is that calling panic! sets off a chain of cleanup that ensures resources are freed before termination, unless you explicitly configure the program to abort immediately.

Customizing Panic Output with Hooks

You can replace the default panic output with your own logic by registering a panic hook. This is useful for logging, sending telemetry, or adding extra context to error messages before the program exits.

A hook is a closure that receives a &PanicInfo<'_> struct. You can inspect the payload, the file location, and a backtrace (if available).

use std::panic;
fn main() {
    panic::set_hook(Box::new(|info| {
        // Print a custom banner
        eprintln!("=== PANIC ===");
        // Print the default panic message (file, line, and payload)
        eprintln!("{info}");
        eprintln!("=== END ===");
    }));
    panic!("something terrible happened");
}

Preserving original diagnostics:

This pattern prints your custom header and footer while still showing the file/line information and the message. If you replace the hook entirely without printing info, that diagnostic information is lost, which can make debugging much harder.

The hook runs synchronously during the panic. Avoid doing anything that might itself panic (like allocating memory in a fallible way) because a double panic will abort the process immediately without further cleanup.

Hooks are per‑process:

set_hook affects the entire program, not just the current thread. If you only want to catch panics in a specific section, look into std::panic::catch_unwind (discussed in the concurrency chapters) rather than a global hook.

Panicking with a Non‑String Payload

If you need to panic with a custom type (for example, an error code that another part of the program can catch), use std::panic::panic_any. This is the only way to panic with a value that is not a string in Rust 2021.

use std::panic::panic_any;
#[derive(Debug)]
struct CriticalError {
    code: u32,
    description: &'static str,
}
fn main() {
    let error = CriticalError {
        code: 42,
        description: "data corruption detected",
    };
    panic_any(error);
}

When using panic_any, the payload is stored as a Box<dyn Any + Send>. If you use catch_unwind, you can downcast the payload back to the original type. For most error reporting, however, a formatted string via panic! is simpler and just as effective.

Reading the Backtrace

When a panic occurs, the output includes a note:

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Setting RUST_BACKTRACE=1 before running the program prints a full stack trace of function calls that led to the panic. You can also set RUST_BACKTRACE=full for even more detail.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:3:9:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::panicking::panic_bounds_check
   3: <alloc::vec::Vec<T> as core::ops::index::Index<usize>>::index
   4: my_project::main
   5: std::rt::lang_start::{{closure}}
   6: ...

Reading a backtrace takes practice, but the rule of thumb is straightforward: start from the top and read until you see a file you wrote. The first frame that mentions your own code is where the chain of events began that led to the panic. In the example above, frame 4 points to my_project::main at line 3 — the direct cause.

Frames above that line (like 3, 2, 1) are library or standard library code that your code called. Frames below are runtime functions that called your main. The backtrace helps you answer: "What path did the program take to get to the panic?"

Debug symbols are required:

Backtraces only show file names and line numbers when the binary was compiled with debug symbols. Debug builds (cargo build without --release) include them by default. Release builds strip them unless you explicitly set debug = true in the [profile.release] section.

Common Mistakes

When you are new to panic!, a few pitfalls are easy to walk into.

Panicking for recoverable errors:

The most damaging mistake is using panic! where a Result would be appropriate. If a function can fail in a way that the caller might want to handle (e.g., a network timeout, a missing config file), return a Result. Panics are for bugs and broken invariants — situations the caller cannot reasonably fix at runtime. Once a panic fires, there is no recovery path (outside of catch_unwind, which is not intended for normal control flow).

Forgetting format string in Rust 2021:

If you are working in a 2021‑edition crate and write panic!(some_variable) where some_variable is a String or &str, you will get a compilation error because panic! expects a string literal as a format string. Use panic!("{}", some_variable) instead, or panic_any if you need a non‑string payload.

Another subtle issue is assuming panic! behaves like a function call. Because it is a macro, it can insert control flow you do not immediately see. A panic! inside a deeply nested condition will still unwind through all callers. That is expected, but it means the code after the panic! is dead code — the compiler may warn you about unreachable expressions.

compile_error! is for build‑time failures:

Do not confuse panic! with compile_error!. The latter stops compilation with an error message at build time, while panic! only runs when the program executes that line. If you want to reject invalid configurations at compile time, compile_error! is the tool.

Summary

The panic! macro is Rust's loud, explicit signal that an unrecoverable error has occurred. It provides a formatted error message, pinpoint file location, and an optional backtrace — everything you need to understand why the program stopped. It exists to protect the program from continuing in a corrupted or invalid state, turning dangerous undefined behavior into a clean termination.

The macro works by building a payload, triggering a customizable hook, and then unwinding the stack (by default) to run destructors before exit. In Rust 2021, it always expects a format string; in older editions, a single argument is treated as a raw payload. Understanding the backtrace output and setting a panic hook are essential debugging skills that deepen your control over how failures are reported.