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:

  1. Rust prints an error message to standard error (stderr), including the file, line, and column where the panic occurred.
  2. The stack starts unwinding: Rust walks back up through every function call currently in progress, popping each stack frame and running its Drop implementation, 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.
  3. 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:

  1. remove_stock(10, 20) is called; a new stack frame is pushed with current = 10 and amount = 20.
  2. current.checked_sub(amount) computes 10 - 20 using overflow-checked subtraction on an unsigned type and, since the result would be negative (impossible for u32), returns None rather than wrapping or crashing silently.
  3. The match takes the None arm, which calls panic! with a formatted message.
  4. The panic runtime records the message plus the source location, then prints both to stderr.
  5. Stack unwinding begins: the remove_stock frame is popped (there is nothing with a Drop implementation here, so nothing extra runs), then unwinding continues up into main.
  6. 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 second println! in main never 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 in Result.
  • 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 with match, if let, or the ? operator instead.
  • Use assert!, assert_eq!, and assert_ne! to document and enforce invariants, especially in tests, where a panic is exactly the right way to fail loudly.
  • Prefer checked_*, saturating_*, or wrapping_* arithmetic methods over relying on debug-mode overflow panics, since release builds behave differently by default.
  • Do not use std::panic::catch_unwind as 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=1 environment variable to see the full call stack that led to it.
  • In library code, prefer returning Result over 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) -> i32 that panics with a descriptive message like "cannot divide by zero" when b is zero. Call it once with valid arguments and print the result, then explain (in a comment) what would happen if you called it with b = 0.
  • Given let numbers = vec![10, 20, 30];, write code that safely reads the element at index 5 using numbers.get(5) instead of numbers[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 using match on the Result so the program prints an error message instead of crashing.

Summary

  • panic! is for unrecoverable errors: bugs and broken invariants, not expected failures, which belong in Result.
  • By default, a panic unwinds the stack, running Drop implementations along the way, then exits the thread (and the process, if it was main) with a non-zero status.
  • Setting panic = "abort" in Cargo.toml skips unwinding entirely for a smaller, faster binary that does not clean up resources on panic.
  • .unwrap() and .expect("msg") panic on None or Err; prefer expect with a clear message, and avoid both on data that might legitimately be missing.
  • assert!, assert_eq!, and assert_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_unwind exists but should not be treated as ordinary error handling; a panic is meant to be a loud signal that something is wrong.