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 |
| Expected failure conditions (file not found, parse error) |
Unrecoverable |
| 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.
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 elementsInteger overflow in debug builds — overflowing a
u8past 255 panics in debug mode (wraps silently in release).unwrap() on None — calling
.unwrap()on anOption::Nonevalue.unwrap() on Err — calling
.unwrap()on aResult::Errvalue.expect("msg") on None or Err — same as unwrap but with a custom message
Explicit
panic!("message")calls in your code
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
}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.
# Linux / macOS RUST_BACKTRACE=1 cargo run # Windows PowerShell $env:RUST_BACKTRACE=1; cargo run # Windows cmd set RUST_BACKTRACE=1 && cargo run
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 |
|---|---|---|
| Something went fatally wrong | Invariant violated, impossible state reached |
| This code path should never execute | Exhaustive match arms that logically cannot happen |
| Not yet implemented | Placeholder during development — compiles but panics if reached |
| Intentionally not implemented | Trait methods deliberately left out |
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:
# 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
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.
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
}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.
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");
}
}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.
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");
}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.
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
}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!(viaassert!,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
User input validation — return
Errso the caller can show a meaningful messageFile and network operations — failures are routine; propagate errors with
?Library code — callers cannot catch panics cleanly; always return
ResultResource exhaustion — out-of-memory or disk-full conditions deserve proper error handling
Guidelines Summary
Scenario | Recommended approach |
|---|---|
Violating a function precondition |
|
File not found |
|
Parsing user input |
|
Impossible match arm |
|
Feature not yet written |
|
Prototype / example code |
|
Test: bad input should panic |
|
FFI boundary protection |
|