Skip to content
← Back to explainers

Rust explainer

When panic! is correct, and when it is a bug

About 13 min to read. Allow extra time to work through examples.

A rule of thumb for choosing between returning Result or Option and calling panic!, what each panicking macro communicates, which Clippy lints help, and where reasonable Rust projects choose differently.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Rust gives you two ways to report that something went wrong.

  • Return a value such as Result<T, E> or Option<T>. The caller sees the failure in the function's type and decides what to do about it.
  • Panic, with panic! or something that calls it, such as .unwrap(), .expect("..."), assert! or an out-of-bounds index like v[10]. A panic stops the current thread. By default it unwinds the thread's stack, running destructors on the way, and prints a message saying where it happened. If the panicking thread is the main thread, the program exits with an error status. A program built with panic = "abort" (a Cargo profile setting) still prints the message, but then ends the whole process without unwinding.

Both can be overused. Code that panics on ordinary problems is quick to write, but one malformed line typed into a command-line tool can end the program with a message like called `Option::unwrap()` on a `None` value, which tells the user nothing about what they did wrong. Code that returns Result from every function has the opposite cost: each caller has to handle errors that cannot actually occur, and the error types fill with variants such as Impossible. Readers can then no longer tell which error paths matter.

This article gives a rule of thumb for choosing, then covers the panicking macros, a few relevant Clippy lints, and the places where reasonable projects choose differently.

The rule of thumb

Return Result (or Option) when the failure is expected.

Panic when the program has reached a state its own code should have made impossible: an assumption, contract or invariant has been broken. This is most appropriate when (a) the state is not a routine outcome, (b) the code that follows depends on the state not happening, and (c) you cannot rule the state out with the types.

"Expected" does not mean "likely". It means the design anticipates it. Malformed input is expected; handling it is the reason a parser exists. A rate-limited HTTP response is expected. A missing file is expected. These come from the world outside the program, and a caller will usually want to respond to them: show a message, retry, use a default.

An index past the end of a vector is different. It usually means earlier code computed a wrong number, so the program's own reasoning has gone wrong, and continuing could produce wrong results.

Check your understanding Your function takes a &[i32] and returns the median. What should it do if the slice is empty? Show answer

There is no single right answer, so the reasoning is what matters.

Return Option<f64> if an empty slice is a normal thing for callers to have. A statistics library may well be handed empty datasets. Returning None lets each caller choose a response, for example .unwrap_or(0.0) or an error message.

Panic if an empty slice can only mean the caller has a bug. Suppose the function is internal to a pipeline that has already checked that the data is not empty. Returning Option would make every call site handle a case that cannot occur, and that extra handling makes the cases that can occur harder to spot. If you choose this, document it under a # Panics heading.

Often better: accept a type that cannot be empty. That is clause (c) of the rule. The standard library has no non-empty slice type, but you can write one. Its constructor checks once and returns Option; after that, the median function needs neither a check nor an Option:

pub struct NonEmpty<'a>(&'a [i32]);

impl<'a> NonEmpty<'a> {
    pub fn new(values: &'a [i32]) -> Option<Self> {
        if values.is_empty() { None } else { Some(NonEmpty(values)) }
    }
}

pub fn median(values: &NonEmpty<'_>) -> f64 {
    let mut sorted = values.0.to_vec();
    sorted.sort_unstable();
    let mid = sorted.len() / 2;
    if sorted.len().is_multiple_of(2) {
        (f64::from(sorted[mid - 1]) + f64::from(sorted[mid])) / 2.0
    } else {
        f64::from(sorted[mid])
    }
}

Because the field is private, code outside the module can only obtain a NonEmpty through new, so sorted always has at least one element and sorted[mid] is always in bounds. (Converting each i32 to f64 before adding also avoids integer overflow when the two middle values are large.)

The standard library makes both of the first two choices on slices: v.first() returns Option<&T> and gives None for an empty slice, while the indexing expression v[0] panics. They return the same element when it exists; they differ only in what happens when it does not, because they serve different callers.

Contract violations

A function's contract is what its documentation requires of the caller, such as "n must be less than items.len()". If a caller breaks that requirement, the mistake is in the calling code, not in the data the program was given. Returning Result for this case has two costs:

  1. Every correct caller must handle an error that only an incorrect caller can trigger.
  2. The bug becomes an ordinary runtime value that is easy to ignore, for example with let _ = ... or an if let Err(_) = ... written only to make the code compile.

A panic makes the bug visible at the point it is detected. A test that exercises the bad call fails instead of passing quietly.

/// Returns the item at position `n`.
///
/// # Panics
///
/// Panics if `n >= items.len()`.
#[track_caller]
pub fn nth_item(items: &[u32], n: usize) -> u32 {
    assert!(n < items.len(), "n ({n}) must be less than len ({})", items.len());
    items[n]
}

The default panic message names the file, line and column where the panic happened. For nth_item(&[1, 2], 5) without #[track_caller], that would be the assert! line inside nth_item. With the attribute, the reported location is the line of the call, which points at the code that broke the contract. A full stack backtrace is printed only when backtraces are enabled, for example by setting the environment variable RUST_BACKTRACE=1.

The other half of the rule: data is not a contract. If n was read from a configuration file or typed by a user, an out-of-range value is expected input, and the right response is a Result with an error the user can act on.

Check your understanding Vec has both fn get(&self, index: usize) -> Option<&T> and indexing, v[i], which panics when i is out of bounds. Isn't that just indecision? Show answer

No. The two forms let the caller state what they know about the index.

Writing v[i] says: I have already established that i is in bounds. Perhaps you wrote the loop and know the length, or the index comes from data you control. If that turns out to be false, your reasoning was wrong, and a panic reports that.

Writing v.get(i) says: I do not know whether i is in bounds. The index came from user input, from a parsed file, or from arithmetic that might have produced a value outside the range. You get an Option and decide what to do with None.

This is a useful pattern for your own APIs: where it makes sense, provide both a panicking version and a checked version, and let the caller choose.

One caution about tooling. Clippy's missing_panics_doc lint (in the opt-in clippy::pedantic group) warns about public functions that can panic but whose documentation has no # Panics section. In Rust 1.98.1 it reports panic!, .unwrap(), .expect(...) and assert! written in the function's own body. It does not report slice or Vec indexing such as v[i], and it does not follow calls into other functions that panic. Do not rely on it to find every way a function can panic.

The panicking macros, and what each one says

All of these panic when reached. They differ in what they tell a reader.

  • panic!("..."): the general case. The message describes what went wrong.
  • unreachable!(): "execution cannot get here." Use it when you have reasoned that a path is impossible but the compiler cannot see it, typically a match arm. If it ever runs, the reasoning was wrong. Its default message is internal error: entered unreachable code.
  • todo!(): "not written yet, but intended to be." The macro's type is ! (the never type), so it fits wherever any value is expected and lets an unfinished function compile. Default message: not yet implemented.
  • unimplemented!(): "not implemented", without the promise that it will be. A common use is a trait method that does not apply to a particular type. Default message: not implemented.
  • assert!(cond, "..."): checks a condition in every build, including optimized ones.
  • debug_assert!(cond, "..."): checks a condition only when the debug_assertions setting is on. The condition is still type-checked when it is off, but it is not evaluated.

The last difference matters on this site. The local runner compiles Rust with the equivalent of rustc -O, and optimized builds have debug_assertions off unless you turn it on explicitly. So a debug_assert! in your submission never runs there. Use assert! when the check must happen; reserve debug_assert! for checks that are too expensive to keep in optimized builds and that you can afford to skip there.

Check your understanding assert_eq!(a, b) versus assert!(a == b): is there any reason to prefer one? Show answer

Yes, the failure message. With a = 3 and b = 4, assert!(a == b) reports the expression but not the values:

assertion failed: a == b

assert_eq!(a, b) reports both values:

assertion `left == right` failed
  left: 3
 right: 4

Seeing the values often tells you what went wrong without further debugging. The cost is that both sides must implement Debug as well as PartialEq.

Two related Clippy lints, neither enabled by default:

  • manual_assert (pedantic) flags if !cond { panic!("...") }, which is assert!(cond, "...") written out by hand.
  • assertions_on_result_states (restriction) flags assert!(r.is_ok()). When that assertion fails, the message says only that r.is_ok() was false, and the error inside r is lost. In a test, r.unwrap() is more informative: its panic message includes the error's Debug output, for example called `Result::unwrap()` on an `Err` value: "bad".

Where projects choose differently

The middle of this topic is widely agreed; the edges are matters of judgement.

Widely agreed: a library should not panic on input its caller is allowed to pass; panicking on a documented contract violation is acceptable; unwrap and expect in tests and quick prototypes are fine; and a panic that untrusted input can trigger is a denial-of-service risk, because whoever controls the input can stop the work.

Choices that differ between projects:

  • Some projects deny Clippy's panic, unwrap_used and expect_used lints (all in the opt-in restriction group) at the crate root, for example with #![deny(clippy::panic, clippy::unwrap_used, clippy::expect_used)], and encode invariants in types instead: newtypes, builders that cannot produce invalid values, NonZeroU32 instead of u32 with a check. This takes more work up front and leaves fewer places that can panic. These lints do not catch every panic: indexing and arithmetic overflow, for example, are not covered by them.
  • Other projects use assert! and expect freely, reasoning that an invariant written as a check is more likely to stay true than one written in a comment, and that a well-tested internal function is a reasonable place for a bug to surface.
  • Unwinding versus panic = "abort" is a separate choice. With abort, the process ends at the panic: destructors do not run, and std::panic::catch_unwind cannot catch anything. Projects choose it for simpler failure behavior or to drop the unwinding machinery from their own code. Unwinding lets a program contain a panic: a thread that panics ends, destructors run on the way out, and the thread that joins it receives an Err.

What these positions share is that each is a deliberate decision. Calling .unwrap() because you did not consider the None case is not one of them.

Check your understanding You are writing a web server. A request handler hits an unexpected None deep in some business logic. Panic, or Result? Show answer

First, what a panic does here depends on the setup, and it is not the same as in a command-line tool.

With unwinding, a panic ends the thread or task it happens in, not necessarily the whole process. A panicking thread started with std::thread::spawn makes join return Err, and async runtimes such as Tokio report a panicking spawned task as an error to whoever awaits it. Other requests can keep being served. What the client sees depends on the framework and its middleware: some catch the panic and send a 500 response, while without that the connection may simply be closed. With panic = "abort", any panic ends the whole server.

With that in mind:

  • If the None means a bug, an invariant your own code should have maintained, a panic is defensible: the request fails, and the panic message and logs point to the bug.
  • If it means bad input or missing data, return a Result, so the handler can send a specific response such as 400 Bad Request or 404 Not Found with a useful message.

Two further points often decide it. First, if the panic happens while the handler holds a lock on a std::sync::Mutex, the mutex becomes poisoned: later calls to lock() return Err. Code that calls .lock().unwrap() will then panic in every later request that uses that mutex. (The data can still be recovered through PoisonError::into_inner, but only by code written to do so.) Second, if untrusted input can reach the panic, it is a denial-of-service risk, not just a style question.

A reasonable default: return Result for anything a client can influence, and panic only for states that your own code should have made impossible.

A checklist for the moment of decision

When you are holding an Option or Result and deciding what to do:

  1. Can the caller's data or the outside world cause this? Return Result (or Option).
  2. Can only a bug in the calling code cause this? Panic, and document it under # Panics.
  3. Can a type make it impossible? Change the types. When this is practical, it is usually the best choice.
  4. Is this main in a small tool, a test, or a prototype? expect with a message saying what you assumed is fine. The message is what turns an unhelpful panic into a useful one.
  5. Are you reaching for .unwrap() because you have not thought about the failure case? Stop and answer questions 1 to 3 first.

step 13 of 24

Errors Are Values

Up next Define a structured validation error