Rustpanic! & Unrecoverable Errors

panic! and Unrecoverable Errors

Rust divides errors into two broad categories: recoverable errors and unrecoverable errors. Recoverable errors are expected situations — a file not found, a network timeout, bad user input — and are represented by the Result<T, E> type. Unrecoverable errors are bugs: out-of-bounds array access, logic violations, violated invariants that should never happen in a correct program. For unrecoverable errors, Rust provides the panic! macro.

The Two Categories of Errors

Category

Represented by

Use when

Recoverable

Result<T, E>

Expected failure conditions (file not found, parse error)

Unrecoverable

panic!

Bugs, violated invariants, logic that must never happen

The panic! Macro

Calling panic! immediately terminates the current thread, prints an error message, and (by default) unwinds the stack. The program exits with a non-zero status code.

RUST
fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("division by zero: cannot divide {} by 0", a);
    }
    a / b
}

fn main() {
    let result = divide(10, 0); // triggers panic
    println!("{}", result);     // never reached
}
When Panics Happen Automatically

You do not always need to call panic! explicitly. Rust triggers panics automatically in several common situations:

  • Out-of-bounds indexing — accessing v[10] on a Vec with 3 elements

  • Integer overflow in debug builds — overflowing a u8 past 255 panics in debug mode (wraps silently in release)

  • .unwrap() on None — calling .unwrap() on an Option::None value

  • .unwrap() on Err — calling .unwrap() on a Result::Err value

  • .expect("msg") on None or Err — same as unwrap but with a custom message

  • Explicit panic!("message") calls in your code

RUST
fn main() {
    // Out-of-bounds panic
    let v = vec![1, 2, 3];
    // println!("{}", v[10]); // panics: index out of bounds: the len is 3 but the index is 10

    // unwrap on None
    let name: Option<&str> = None;
    // name.unwrap(); // panics: called `Option::unwrap()` on a `None` value

    // unwrap on Err
    let n: Result<i32, _> = "abc".parse::<i32>();
    // n.unwrap(); // panics: called `Result::unwrap()` on an `Err` value

    // expect with a custom message
    let x: Option<i32> = None;
    // x.expect("x must have a value here"); // panics: x must have a value here
}
Note
In **release** builds (`cargo build --release`), integer overflow wraps around silently instead of panicking. This is a deliberate performance trade-off — if overflow would be a bug, use the checked arithmetic methods such as `checked_add`, `saturating_add`, or `wrapping_add` explicitly.
RUST_BACKTRACE: Getting a Full Stack Trace

When a panic occurs, Rust prints only a short message by default. Set the RUST_BACKTRACE environment variable to 1 (or full for even more detail) to see the complete call stack, which shows exactly which function called which on the way to the panic.

Bash
# Linux / macOS
RUST_BACKTRACE=1 cargo run

# Windows PowerShell
$env:RUST_BACKTRACE=1; cargo run

# Windows cmd
set RUST_BACKTRACE=1 && cargo run
Tip
Set `RUST_BACKTRACE=full` to include inlined frames and symbol information. The output is lengthy but invaluable when diagnosing panics deep inside library code or async runtimes.
panic! vs Related Macros

Rust ships several macros that trigger a panic but communicate different intent to readers of your code. Choosing the right one makes the code self-documenting at a glance.

Macro

Meaning

Use when

panic!("msg")

Something went fatally wrong

Invariant violated, impossible state reached

unreachable!()

This code path should never execute

Exhaustive match arms that logically cannot happen

todo!()

Not yet implemented

Placeholder during development — compiles but panics if reached

unimplemented!()

Intentionally not implemented

Trait methods deliberately left out

RUST
enum Direction { North, South, East, West }

fn compass_degrees(d: &Direction) -> u32 {
    match d {
        Direction::North => 0,
        Direction::South => 180,
        Direction::East  => 90,
        Direction::West  => 270,
    }
}

fn parse_direction(s: &str) -> Direction {
    match s {
        "N" => Direction::North,
        "S" => Direction::South,
        "E" => Direction::East,
        "W" => Direction::West,
        _   => panic!("invalid direction string: {:?}", s),
    }
}

fn future_feature() -> String {
    todo!("implement this in the next sprint")
}

trait Animal {
    fn name(&self) -> &str;
    fn sound(&self) -> &str {
        unimplemented!("this animal makes no sound")
    }
}

struct Cat;
impl Animal for Cat {
    fn name(&self) -> &str { "cat" }
    fn sound(&self) -> &str { "meow" }
}

fn show(dir: &Direction) {
    // This arm is logically unreachable given our parse_direction above
    let degrees = compass_degrees(dir);
    if degrees > 360 {
        unreachable!("compass degrees are always 0..=360");
    }
    println!("{}°", degrees);
}
Stack Unwinding vs Aborting

When a panic occurs, Rust has two strategies for cleaning up: - Unwind (default): Rust walks back up the call stack, running destructors (Drop) for every value that goes out of scope. This is safe and predictable but adds binary size and runtime overhead. - Abort: The process terminates immediately without running any destructors. The OS reclaims memory. This produces smaller binaries and is required on targets where unwinding is not supported (e.g., some embedded and WebAssembly targets). You configure the strategy per Cargo profile in Cargo.toml:

TOML
# Cargo.toml

[profile.release]
panic = "abort"   # smaller binary, no unwinding overhead in production

[profile.dev]
panic = "unwind"  # default — run Drop impls, required for catch_unwind
Note
When `panic = "abort"` is set, `std::panic::catch_unwind` cannot catch panics — the process simply exits. Always test with the same profile you will deploy.
Catching Panics with catch_unwind

The standard library provides std::panic::catch_unwind to intercept a panic before it terminates the thread. This is uncommon in normal application code, but it is important in two situations: 1. FFI boundaries — if Rust panics through a C function call, the behaviour is undefined. Wrap the Rust code in catch_unwind to prevent that. 2. Test harnesses and task runners — isolate individual jobs so one failure does not bring down the whole system.

RUST
use std::panic;

fn risky_operation(x: i32) -> i32 {
    if x < 0 {
        panic!("negative input not allowed: {}", x);
    }
    x * 2
}

fn main() {
    let result = panic::catch_unwind(|| risky_operation(-5));

    match result {
        Ok(value) => println!("success: {}", value),
        Err(_)    => println!("caught a panic — the thread is still alive"),
    }

    println!("program continues normally after caught panic");

    // A second call that succeeds
    let ok = panic::catch_unwind(|| risky_operation(10));
    println!("second call: {}", ok.unwrap()); // 20
}
Warning
`catch_unwind` only works when `panic = "unwind"`. It is not a general-purpose error handling mechanism — use `Result` for that. After catching a panic, any data the closure was mutating may be in a partially-modified state. Treat it with caution.
The #[should_panic] Attribute in Tests

When writing tests, you sometimes want to verify that invalid input triggers a panic. Mark the test function with #[should_panic] — the test passes if the body panics, and fails if it completes without panicking.

RUST
fn parse_positive(s: &str) -> u32 {
    let n: i32 = s.parse().expect("not a number");
    if n <= 0 {
        panic!("value must be positive, got {}", n);
    }
    n as u32
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_input_works() {
        assert_eq!(parse_positive("42"), 42);
    }

    #[test]
    #[should_panic(expected = "value must be positive")]
    fn negative_input_panics() {
        parse_positive("-5");
    }

    #[test]
    #[should_panic]
    fn non_numeric_panics() {
        parse_positive("hello");
    }
}
Tip
Always use `#[should_panic(expected = "...")]` with a substring of the panic message. This prevents the test from accidentally passing due to a different, unrelated panic that happens to fire in the same function.
Custom Panic Hooks

By default, Rust prints the panic message and file location to stderr. You can replace this with std::panic::set_hook to integrate with your logging infrastructure, send a crash report, or format the output for structured log aggregators.

RUST
use std::panic;

fn main() {
    panic::set_hook(Box::new(|info| {
        let location = info.location().map_or(
            "unknown location".to_string(),
            |l| format!("{}:{}", l.file(), l.line()),
        );

        let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
            s.to_string()
        } else if let Some(s) = info.payload().downcast_ref::<String>() {
            s.clone()
        } else {
            "unknown panic payload".to_string()
        };

        // Could write to a file or a crash reporter here
        eprintln!("[CRASH] {} — {}", message, location);
    }));

    panic!("something went very wrong");
}
Note
Call `panic::take_hook()` to retrieve and store the previous hook before installing a new one. This lets you chain hooks — for example, run your custom logging and then call the default hook to preserve the standard output.
Panic Safety and UnwindSafe

The UnwindSafe marker trait indicates that a type remains in a consistent state after a panic is caught with catch_unwind. Most types automatically implement it. Types that do not — such as &mut T or MutexGuard — require wrapping with AssertUnwindSafe if you are certain that the invariants hold despite the panic.

RUST
use std::panic::{self, AssertUnwindSafe};

fn main() {
    let mut counter = 0u32;

    // &mut u32 is not UnwindSafe — wrap it with AssertUnwindSafe
    let result = panic::catch_unwind(AssertUnwindSafe(|| {
        counter += 1;
        panic!("mid-mutation panic");
    }));

    // counter was incremented before the panic fired
    println!("caught: {}", result.is_err()); // true
    println!("counter after panic: {}", counter); // 1 — partially mutated
}
Warning
`AssertUnwindSafe` is a promise to the compiler that you accept responsibility for any partially-mutated state. Only use it when you have genuinely verified that the invariants of all captured values remain intact after a panic.
When to Panic
  • Programmer errors (bugs): When the caller violates a documented precondition — for example passing a negative index to a function that requires non-negative input. Panicking makes the bug immediately visible rather than silently continuing with bad data.

  • Violated invariants: When internal state reaches an impossible configuration that indicates a logic error in the implementation.

  • Tests: Using panic! (via assert!, assert_eq!, unwrap) in tests is idiomatic — test failures should be loud.

  • Prototyping: Using .unwrap() and .expect() during early development is acceptable; replace with proper error handling before shipping.

  • Documentation examples: Short examples can use unwrap() to keep focus on the concept being illustrated.

When NOT to Panic
Warning
Do not use `panic!` for expected error conditions. If a function receives user input, reads a file, or calls a network service, failures are expected — use `Result<T, E>` so callers can handle them gracefully. Libraries in particular should almost never panic on inputs received from callers; surface errors as `Result` and let the application decide what to do.
  • User input validation — return Err so the caller can show a meaningful message

  • File and network operations — failures are routine; propagate errors with ?

  • Library code — callers cannot catch panics cleanly; always return Result

  • Resource exhaustion — out-of-memory or disk-full conditions deserve proper error handling

Guidelines Summary
Success
The core rule: if a situation is the **caller's fault** (a bug), panic. If a situation is the **environment's fault** (a missing file, a bad network), return `Result`. Libraries should almost always return `Result`; binaries can panic on programmer errors since the developer controls all call sites.

Scenario

Recommended approach

Violating a function precondition

panic! or assert!

File not found

Result<T, io::Error>

Parsing user input

Result<T, ParseError>

Impossible match arm

unreachable!()

Feature not yet written

todo!()

Prototype / example code

.unwrap() / .expect()

Test: bad input should panic

#[should_panic]

FFI boundary protection

std::panic::catch_unwind