Skip to content

Panics and Unwrap

A panic is Rust’s way of saying “this situation is a bug — I cannot continue safely.” When a panic fires, Rust unwinds the call stack, runs destructors, and terminates the current thread. In a single-threaded program that means the whole process exits.

You can trigger a panic explicitly with the panic! macro:

// @expect-panic
fn main() {
panic!("something went terribly wrong");
}

Several standard operations also panic automatically when used incorrectly:

// @expect-panic
fn main() {
let v = vec![1, 2, 3];
println!("{}", v[10]); // index out of bounds — panic!
}
// @expect-panic
fn main() {
let x: Option<i32> = None;
let _ = x.unwrap(); // called unwrap() on a None value — panic!
}

What the Compiler Says: The 1.96 Panic Message

Section titled “What the Compiler Says: The 1.96 Panic Message”

The exact wording of a panic message is not just cosmetic — it is what you paste into a bug report or grep for in a log. On rustc/cargo 1.96, the message format includes a numeric thread id in parentheses after the thread name:

// @expect-panic
fn main() {
let x: Option<i32> = None;
let _ = x.unwrap();
}
thread 'main' (41674254) panicked at p2.rs:3:15:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'main' (<id>) panicked at <file>:<line>:<col>: is the header every panic prints, followed by the panic message on the next line. .unwrap() on None always prints exactly called \Option::unwrap()` on a `None` value— no custom message, which is exactly why.expect()` exists.

.unwrap() on an Option<T> or Result<T, E> extracts the inner value if it is Some/Ok, and panics if it is None/Err. It is a shortcut that trades safety for brevity.

.expect(msg) does the same thing but lets you supply a descriptive message that appears in the panic output, making debugging much easier.

fn main() {
let some_value: Option<i32> = Some(42);
let n = some_value.unwrap(); // fine — value is Some
println!("n = {}", n);
let parsed: Result<i32, _> = "99".parse();
let m = parsed.expect("input should always be a valid integer");
println!("m = {}", m);
}

A good .expect() message states why the invariant should hold, not just what failed — so that whoever reads the panic (often future-you, at 3am) immediately knows whether it is a real bug or an assumption that just broke. The Rust community convention is to phrase it as a statement of the invariant, in the form “should be X because Y” or simply “this X should exist”:

fn main() {
let env_value = std::env::var("PATH");
// Bad: restates the operation, tells you nothing you didn't know
// env_value.expect("failed to get env var");
// Good: states the invariant and why it should hold
let _path = env_value.expect("PATH should be set by the shell that launched this process");
println!("PATH lookup succeeded");
}

Every .unwrap() and .expect() in a codebase is a claim that “this cannot fail here.” Before merging code — your own or an AI assistant’s — grep for them and check each one against that claim:

Terminal window
grep -rn 'unwrap()' src/

For each hit, ask: is the None/Err truly unreachable given everything the compiler knows at this point, or does it depend on an external fact (a file existing, a network call succeeding) that can change? If it is the latter, it belongs on the Result side of the decision table, not behind an .unwrap(). clippy::unwrap_used (a restriction lint, off by default) can enforce this automatically in a library crate — the concurrency-testing-cargo module covers running it.

Panics are appropriate when:

  • The situation represents a bug in your code, not a user or environment failure.
  • You are writing tests or prototypes where crashing fast is useful.
  • You have already validated the preconditions elsewhere and the invariant truly cannot be violated (e.g., unwrap() on a value you just inserted).

Panics are not appropriate when:

  • The failure can come from external input (files, network, user data).
  • The caller might want to recover and try something else.
  • You are writing library code that other programs call.

Safer Defaults with .unwrap_or and .unwrap_or_else

Section titled “Safer Defaults with .unwrap_or and .unwrap_or_else”

When you want a fallback value instead of a panic, use .unwrap_or(default) or .unwrap_or_else(|| compute_default()).

fn safe_sqrt(n: f64) -> Result<f64, String> {
if n < 0.0 {
Err(format!("cannot take sqrt of negative number: {}", n))
} else {
Ok(n.sqrt())
}
}
fn main() {
// .unwrap_or provides a default instead of panicking
let a: Option<i32> = Some(42);
let b: Option<i32> = None;
println!("a unwrap_or: {}", a.unwrap_or(0));
println!("b unwrap_or: {}", b.unwrap_or(0));
// .expect gives a custom panic message (use only when failure is a bug)
let good = safe_sqrt(16.0).expect("sqrt should not fail on positive numbers");
println!("sqrt(16) = {}", good);
// .unwrap_or_else with a closure for recoverable defaults
let result = safe_sqrt(-4.0).unwrap_or_else(|e| {
println!("Handled: {}", e);
0.0
});
println!("result = {}", result);
}

By default, a Rust binary unwinds on panic: it walks back up the call stack running every Drop impl along the way before the process exits. You can opt into abort instead — the process terminates immediately, with no unwinding and no destructors run — by setting a profile in Cargo.toml:

[profile.release]
panic = "abort"

abort produces smaller binaries (no unwinding tables) and is required on some embedded targets, but it means Drop guarantees stop applying once a panic starts. The two real runs below use the same program — a Guard whose Drop prints a message — compiled once with the default unwind strategy and once with panic = "abort":

$ rustc --edition 2024 unwind_drop.rs -o unwind_drop && ./unwind_drop; echo "exit=$?"
thread 'main' (41688821) panicked at unwind_drop.rs:9:5:
boom
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
dropping guard
exit=101
$ cargo build --quiet && ./target/debug/abort-scratch; echo "exit=$?" # Cargo.toml has panic = "abort"
thread 'main' (41687919) panicked at src/main.rs:9:5:
boom
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=134

Under unwind, dropping guard prints before the process exits with code 101. Under abort, that line never prints at all, and the exit code becomes 134 (128 + SIGABRT) — the OS killed the process, Rust never got a chance to run any destructor.

std::panic::catch_unwind lets you stop an unwind partway up the stack and keep running — it only works with the unwind strategy, never with abort:

fn main() {
let result = std::panic::catch_unwind(|| {
println!("about to panic");
panic!("caught you");
});
match result {
Ok(_) => println!("no panic"),
Err(_) => println!("recovered from a panic"),
}
println!("program continues");
}

std::process::exit(code) terminates the process immediately, exactly like panic = "abort" in this respect: no destructors run, not even for values still in scope in main, because there is no unwind at all — the process just stops.

// @no-run exits with code 3, not 0 — see the real transcript in prose below
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("Dropping guard: {}", self.0);
}
}
fn main() {
let _g1 = Guard("normal scope end");
{
let _g2 = Guard("about to exit early");
println!("calling process::exit(3)");
std::process::exit(3);
}
}
$ rustc --edition 2024 exit_demo.rs -o exit_demo && ./exit_demo; echo "exit=$?"
calling process::exit(3)
exit=3

Running this prints only calling process::exit(3) and exits with code 3 — neither _g1 nor _g2’s Drop ever runs, even though _g1 is still very much in scope. If a Drop impl is responsible for flushing a file buffer, releasing a lock, or closing a network connection, process::exit silently skips all of it. Reach for a normal return from main (or letting it fall off the end) whenever you need those destructors to run.

flowchart TD
  P[panic! fires] --> S{Panic strategy}
  S -->|unwind - default| U[Walk stack,
run every Drop]
  U --> T[Thread exits
(process exits, 101, if main thread)]
  S -->|abort - Cargo.toml| A[Process terminates
immediately, no Drop]
  A --> X[Exit via SIGABRT, 134]
  E[std::process::exit] --> A
Unwind vs abort vs process::exit

A panic is Rust deciding, on your behalf, that the safest thing to do is stop rather than guess. Unwinding is the “polite” way to stop — it gives every value on the stack a chance to clean up via Drop before the thread ends. abort and process::exit are the “impolite” way — they stop right now, and any cleanup that was supposed to happen in a destructor simply does not happen. Choosing .unwrap() in your own code is choosing the polite version of “stop right now”; choosing process::exit anywhere outside of main’s very last line is choosing the impolite version, usually without meaning to.

Unwinding costs binary size (unwind tables) and a small amount of runtime overhead on the happy path in some ABIs, but it preserves Drop-based cleanup and enables catch_unwind at FFI or thread-pool boundaries. abort is smaller and required on some no-std/embedded targets, but any Drop your program relies on for correctness (flushing, releasing a lock, closing a socket) silently stops happening the moment you switch to it. process::exit gives you precise control over the exit code from anywhere in the program, at the cost of the exact same silent-skip-of-Drop behavior as abort — call it only when you are certain nothing still needs to clean up.

  • “.expect("some short reason") is always better than .unwrap().” Only if the message actually states the invariant. .expect("failed") is barely more useful than a bare .unwrap() — the convention is “should be X because Y”, not a restatement of the operation.
  • “catch_unwind makes panics safe to ignore, like try/catch in other languages.” It is a narrow escape hatch for FFI and worker isolation, not general control flow — the panicking code may have left invariants broken, and catch_unwind does nothing to repair that.
  • “process::exit is just a faster return from main.” It skips every pending destructor in the entire program, not just in main — including guards, file handles, and lock releases anywhere on the stack, not only in the calling function.

AI assistants often reach for .expect("") with an empty or near-empty string, or repeat the operation name back to you, because it satisfies the type checker without the writer actually reasoning about why the invariant should hold.

🐛 Spot the bug

fn get_admin_email(config: &Config) -> String {
config.admin_email.clone().expect("")
}
Show the answer

An empty .expect("") message gives you nothing beyond the bare .unwrap() panic text — you lose the one thing .expect() exists to provide. Worse, it hides why admin_email was assumed to always be present. The fix follows the message convention from this lesson: .expect("admin_email should be set because Config::load validates it at startup") — or, if the field can legitimately be absent, return Result/Option instead of panicking here at all.

What happens when you call .unwrap() on a None value?
What is the difference between .unwrap() and .expect(msg)?
Which situation is a legitimate use of panic!?
What does this program print, and with what exit code?
struct Guard;
impl Drop for Guard {
    fn drop(&mut self) { println!("dropping guard"); }
}
fn main() {
    let _g = Guard;
    std::process::exit(3);
}
What does .unwrap_or(default) do when the Option is None?
Compared to the default unwind panic strategy, what changes when Cargo.toml sets panic = "abort"?