panic! and Unrecoverable Errors
Sometimes a program reaches a state it has no sensible way to recover from: a broken invariant, a bug that should never happen, or corrupted internal data. Rust handles these unrecoverable errors with the panic! macro. Calling it stops the current thread immediately, unwinds the stack, and reports exactly what went wrong instead of quietly continuing on bad data. This is different from Rust’s other error-handling tool, Result<T, E>, which is for failures the caller is expected to handle gracefully. This lesson covers how panics work under the hood, when panic! is the right call, and the everyday mistakes (like calling .unwrap() on the wrong value) that trigger panics by accident.
Overview: Recoverable vs. Unrecoverable Errors
Rust splits errors into two categories, and picking the right one is a core design skill. Recoverable errors are expected failure modes: a file might not exist, a network request might time out, user input might be malformed. These are represented with Result<T, E> and handled with match, if let, or the ? operator, letting the caller decide what to do next. Unrecoverable errors are different: they signal that a program invariant has been violated, that a bug exists, or that continuing would be unsafe or meaningless. For these, Rust gives you panic!.
Think of a panic as Rust’s way of saying: “I refuse to keep running with this data, because I can no longer guarantee correctness.” When panic! runs, this sequence happens by default:
- Rust prints an error message to standard error (
stderr), including the file, line, and column where the panic occurred. - The stack starts unwinding: Rust walks back up through every function call currently in progress, popping each stack frame and running its
Dropimplementation, exactly as if each function had returned early. This is what lets a panic clean up open files, unlock mutexes, and free heap memory even though the thread is shutting down. - Once unwinding reaches the top of the thread, the thread exits. If that thread is
main, the whole process exits with a non-zero status code (101 by convention on most platforms).
This is conceptually similar to an uncaught exception in languages like Python or Java, but with one important difference: Rust does not expect you to catch panics as routine control flow. A function called std::panic::catch_unwind exists, but it is reserved for special cases, such as a plugin host isolating a crash or a thread pool worker reporting a failure, not as a substitute for Result. If you find yourself wanting to “catch” a panic in ordinary application code, that is usually a sign the error should have been a Result in the first place.
Unwinding vs. Aborting
By default, a Rust binary unwinds on panic, as described above. A crate can instead be configured to abort immediately on panic by setting panic = "abort" under [profile.release] in Cargo.toml. With abort, the process terminates instantly without running destructors or unwinding the stack, producing a smaller, slightly faster binary at the cost of not cleaning up resources. Rust also automatically switches to abort behavior if a second panic happens while the first is still unwinding (a “double panic”), since unwinding through an already-unwinding stack has nowhere sensible to go.
Syntax
The most direct way to trigger a panic is the panic! macro, which accepts a plain string or a format string exactly like println!. Rust also provides several helpers built on top of panicking, shown together here:
panic!("error message");
panic!("formatted {} error", value);
result.unwrap();
result.expect("descriptive message");
assert!(condition, "optional message");
assert_eq!(left, right);
assert_ne!(left, right);
unreachable!();
todo!();
unimplemented!();
| Form | Panics when | Notes |
|---|---|---|
panic!("msg") |
Always, as soon as it runs | Use for invariant violations you detect explicitly |
.unwrap() |
Called on None or Err |
Panic message is generic and gives no context |
.expect("msg") |
Called on None or Err |
Panics with your custom message, easier to debug than unwrap |
assert!(cond) |
cond is false |
Checked in every build, including release |
assert_eq!(a, b) / assert_ne!(a, b) |
Values are unequal / equal | Prints both values in the panic message |
unreachable!() |
The line is ever executed | Documents “this branch can’t happen” |
todo!() / unimplemented!() |
Called at all | Placeholders for unfinished code that still type-checks |
Examples
Example 1: A Basic panic!
fn main() {
let quantity = -5;
println!("Starting order processing...");
if quantity < 0 {
panic!("Invalid quantity: {} (quantity cannot be negative)", quantity);
}
println!("Order processed for {} items.", quantity);
}
Output:
Starting order processing...
thread 'main' panicked at src/main.rs:6:9:
Invalid quantity: -5 (quantity cannot be negative)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The first println! runs normally, but once the program checks quantity < 0 and finds it true, panic! fires. Execution never reaches the final println!: the thread unwinds and the process exits with a non-zero status. Note that this passed compilation just fine, since a negative quantity is valid data at the type level; the problem is a business-logic invariant, exactly the kind of thing panic! is for.
Example 2: unwrap and expect on a Lookup
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Alice", 95);
scores.insert("Bob", 87);
let alice_score = scores.get("Alice").expect("Alice should always have a score");
println!("Alice's score: {}", alice_score);
let carol_score = scores.get("Carol");
match carol_score {
Some(score) => println!("Carol's score: {}", score),
None => println!("Carol has not been scored yet."),
}
}
Output:
Alice's score: 95
Carol has not been scored yet.
HashMap::get returns Option<&i32>, since a key might not be present. For "Alice", we are certain the entry exists (we just inserted it), so .expect("...") is a reasonable, self-documenting way to unwrap it: if the assumption ever turns out to be wrong, the panic message explains exactly which assumption broke. For "Carol", the code cannot assume the key exists, so it uses match instead of unwrapping, handling the missing case without ever risking a panic.
Example 3: Panicking on a Genuine Invariant Violation
fn remove_stock(current: u32, amount: u32) -> u32 {
match current.checked_sub(amount) {
Some(remaining) => remaining,
None => {
panic!(
"Cannot remove {} items: only {} in stock",
amount, current
);
}
}
}
fn main() {
let stock = 10;
let sold = 3;
let remaining = remove_stock(stock, sold);
println!("Remaining stock: {}", remaining);
let remaining = remove_stock(stock, 20);
println!("Remaining stock: {}", remaining);
}
Output:
Remaining stock: 7
thread 'main' panicked at src/main.rs:6:13:
Cannot remove 20 items: only 10 in stock
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
checked_sub returns None instead of panicking when subtraction would underflow, which lets remove_stock decide what to do. The first call (10 minus 3) succeeds normally. The second call asks to remove more stock than exists, a state that should never be reachable if the rest of the program is correct, so the function treats it as a bug and panics with a message describing exactly what went wrong, rather than silently returning a nonsensical number.
How It Works Step by Step
Walking through Example 3’s failing call shows what the compiler and runtime actually do:
remove_stock(10, 20)is called; a new stack frame is pushed withcurrent = 10andamount = 20.current.checked_sub(amount)computes10 - 20using overflow-checked subtraction on an unsigned type and, since the result would be negative (impossible foru32), returnsNonerather than wrapping or crashing silently.- The
matchtakes theNonearm, which callspanic!with a formatted message. - The panic runtime records the message plus the source location, then prints both to
stderr. - Stack unwinding begins: the
remove_stockframe is popped (there is nothing with aDropimplementation here, so nothing extra runs), then unwinding continues up intomain. main‘s frame is popped in turn; since this is the last frame on the main thread, the thread finishes and the process exits with status code 101. The secondprintln!inmainnever executes.
If any value along that unwind path owned a resource, such as a File, a Vec, or a locked Mutex, its Drop implementation would still run during step 5, which is exactly why unwinding (rather than an instant abort) is the default: it gives your program a chance to clean up even while crashing.
Common Mistakes
Mistake 1: Reaching for unwrap() on a Value That Might Genuinely Be Missing
.unwrap() is convenient, but on real (non-guaranteed) data it turns an expected “not found” case into a crash with almost no context:
fn find_user_age(name: &str) -> u32 {
let ages = vec![("Alice", 30), ("Bob", 25)];
let entry = ages.iter().find(|(n, _)| *n == name);
entry.unwrap().1 // panics if `name` is not found
}
fn main() {
let age = find_user_age("Charlie");
println!("Age: {}", age);
}
Output:
thread 'main' panicked at src/main.rs:4:11:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This compiles perfectly, which is exactly the trap: nothing warns you that "Charlie" might not be in the list. The fix is to let the function’s type reflect that the age might not exist, and let the caller decide what to do:
fn find_user_age(name: &str) -> Option<u32> {
let ages = vec![("Alice", 30), ("Bob", 25)];
ages.iter().find(|(n, _)| *n == name).map(|(_, age)| *age)
}
fn main() {
match find_user_age("Charlie") {
Some(age) => println!("Age: {}", age),
None => println!("No user named Charlie found."),
}
}
Output:
No user named Charlie found.
Mistake 2: Unsigned Integer Subtraction That Overflows
Rust’s integer types are fixed-width, and unsigned types like u32 cannot represent negative numbers. In debug builds, arithmetic that would go out of range panics instead of silently producing a wrong answer:
fn main() {
// offset by args().count() (always 1 with no extra args) so the compiler
// can't prove the subtraction overflows ahead of time
let cart_total: u32 = 10 + std::env::args().count() as u32;
let discount: u32 = 20;
let final_price = cart_total - discount;
println!("Final price: {}", final_price);
}
Output:
thread 'main' panicked at src/main.rs:4:23:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This is a genuinely sharp edge: the same code compiled in release mode (without debug assertions) would not panic at all, it would silently wrap around to a huge number instead, which is arguably worse. Either way, the fix is to use checked arithmetic and decide explicitly what should happen when the operation cannot succeed:
fn main() {
let cart_total: u32 = 15;
let discount: u32 = 20;
match cart_total.checked_sub(discount) {
Some(final_price) => println!("Final price: {}", final_price),
None => println!("Discount exceeds cart total; final price is $0."),
}
}
Output:
Discount exceeds cart total; final price is $0.
Best Practices
- Reserve
panic!for bugs and broken invariants, things that should never happen if the rest of the program is correct, not for expected failure modes like missing files or bad user input, which belong inResult. - Prefer
.expect("message explaining the assumption")over bare.unwrap(). When it does panic, the message tells you which assumption was wrong instead of just “called unwrap on a None value.” - Avoid
.unwrap()or.expect()on anything derived from user input, file contents, or network data; handle those withmatch,if let, or the?operator instead. - Use
assert!,assert_eq!, andassert_ne!to document and enforce invariants, especially in tests, where a panic is exactly the right way to fail loudly. - Prefer
checked_*,saturating_*, orwrapping_*arithmetic methods over relying on debug-mode overflow panics, since release builds behave differently by default. - Do not use
std::panic::catch_unwindas a substitute for ordinary error handling; treat a panic as a bug report, not a control-flow tool. - When debugging a panic, run with the
RUST_BACKTRACE=1environment variable to see the full call stack that led to it. - In library code, prefer returning
Resultover panicking wherever a failure is plausible, so the calling application can decide how to respond.
Practice Exercises
- Write a function
divide(a: i32, b: i32) -> i32that panics with a descriptive message like"cannot divide by zero"whenbis zero. Call it once with valid arguments and print the result, then explain (in a comment) what would happen if you called it withb = 0. - Given
let numbers = vec![10, 20, 30];, write code that safely reads the element at index5usingnumbers.get(5)instead ofnumbers[5], printing a friendly message when the index is out of range instead of letting the program panic. - Take the expression
"42a".parse::<i32>().unwrap(), which panics because"42a"is not a valid number, and rewrite it usingmatchon theResultso the program prints an error message instead of crashing.
Summary
panic!is for unrecoverable errors: bugs and broken invariants, not expected failures, which belong inResult.- By default, a panic unwinds the stack, running
Dropimplementations along the way, then exits the thread (and the process, if it wasmain) with a non-zero status. - Setting
panic = "abort"inCargo.tomlskips unwinding entirely for a smaller, faster binary that does not clean up resources on panic. .unwrap()and.expect("msg")panic onNoneorErr; preferexpectwith a clear message, and avoid both on data that might legitimately be missing.assert!,assert_eq!, andassert_ne!check invariants in every build and panic with a descriptive message when they fail.- Unsigned integer arithmetic that underflows or overflows panics in debug builds but silently wraps in release builds; use
checked_*methods to handle both cases explicitly. catch_unwindexists but should not be treated as ordinary error handling; a panic is meant to be a loud signal that something is wrong.
