Skip to content
← All articles

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

A crisp rule for the judgement call that separates competent error handling from expert error handling — plus an honest account of where the Rust community disagrees.

Beginners oscillate between two failure modes here, and both are expensive.

Panicking everywhere. It is fast to write, it feels like exceptions, and the code looks clean. Then someone types a malformed line into your CLI and the whole process dies with called Option::unwrap() on a None value.

Returning Result for everything. Every function grows an error type, every caller grows a ?, every error enum grows an Impossible variant, and the signal-to-noise ratio of the codebase collapses. Nobody can tell which of the forty Err paths can actually happen.

The rule that prevents both is short.

The rule

Return Result when the failure is expected.

panic! when the code has reached a bad state — some assumption, guarantee, contract or invariant has been broken — and that state is (a) unexpected rather than routine, (b) something the code after it must be able to rely on not being in, and (c) not expressible in the types.

“Expected” is doing the work in the first line, and it does not mean “likely”. It means anticipated by the design. Malformed input is expected — that is why you wrote a parser. A rate-limited HTTP response is expected. A missing file is expected. None of those are surprising; they are the ordinary weather of a program that talks to the outside world, and a caller will want to react.

A slice index past the end of a vector is not weather. It means something earlier computed a wrong number, and nothing after that line can be trusted.

💡Your function takes a &[i32] and returns the median. What should it do if the slice is empty? click to reveal

This is genuinely a design decision and both answers are defensible, so the useful thing is the reasoning.

Option<f64> / Result if “empty” is a normal thing for your callers to have. A statistics library gets handed empty datasets all the time; making the caller decide is right, and it costs them one unwrap_or(0.0).

Panic if an empty slice would mean the caller has a bug. If your median function is internal to a pipeline that has already validated non-emptiness, returning Option forces every call site to handle a case that cannot occur — noise that hides the cases that can.

The best answer is often neither: take a type that cannot be empty. If your parameter is a NonEmptySlice or a (f64, &[f64]), the question disappears at compile time, nobody handles anything, and the invariant is checked once at construction. That is clause (c) of the rule — not expressible in the types — and it is worth actually trying before you settle for a runtime check.

slice::first in std picks Option. slice::[0] picks panic. Both are in the same standard library, on the same type, for the same operation, because they serve different callers.

The corollary that decides most real cases

A contract violation is always a caller-side bug.

If your function documents “n must be less than len“ and someone passes a bigger n, the mistake is in their code, not in the input data. Returning Result in that situation is actively wrong for two reasons:

  1. It forces every correct caller to handle an error that only an incorrect caller can trigger.
  2. It converts a bug into a runtime condition, which means it gets caught late, in production, by an if let Err(_) = … somebody wrote to make the compiler stop complaining.

Panicking on a contract violation puts the failure exactly where it belongs: loud, immediate, with a stack trace pointing at the caller, during the test run that would have caught it.

The flip side is that the caller’s input data is not a contract. If the n came from a config file rather than from a programmer, it is expected input, and it is a Result.

💡Two functions. fn get(&self, index: usize) -> Option<&T> and fn index(&self, index: usize) -> &T (the one behind v[i]). Both exist on Vec. Isn't that just indecision? click to reveal

No, they encode two different claims about who made the mistake.

v[i] says: I have already established that i is in bounds. You wrote the loop, you know the length, the index is derived from something you control. If that turns out to be false, your reasoning was wrong, and a panic is the correct response to wrong reasoning.

v.get(i) says: I do not know whether i is in bounds. The index came from user input, from a parsed file, from arithmetic that might have gone somewhere unexpected. You get an Option and you decide.

Same operation, different epistemic state, and the API lets you say which one you are in. That is a good design pattern to steal: when you can, offer the panicking version and the checked version, and let the caller declare what they know.

One practical warning: v[i] panicking is not caught by clippy::missing_panics_doc — verified. Clippy sees panic!, unwrap, expect and assert!, not slice indexing. Do not assume the lint has your back on every panic source.

The macros, and what each one means

They all panic. The difference is what they say.

  • panic!("…") — a bad state, described in the message. The general case.
  • unreachable!() — “control flow cannot get here”. Use it when you have proved it, typically in a match arm the type system cannot rule out. If it ever fires, your proof was wrong, and that is exactly what you want to hear.
  • todo!() — “I will write this”. Compiles, type-checks as ! so it fits anywhere, panics if reached. The right way to stub out a function while you work on its neighbour.
  • unimplemented!() — “this will never be written”. A trait method that does not apply to this implementor. Same panic, different promise: todo! is a note to yourself, unimplemented! is a note to your users.
  • assert!(cond, "…") — a contract check that runs in every build, including release.
  • debug_assert!(cond, "…") — a contract check that is compiled out when debug_assertions is off.

That last distinction has teeth in this course. The harness compiles with -O, so debug_assertions is off and every debug_assert! you write is a no-op. Use assert! when the check must actually happen; use debug_assert! only for expensive checks whose absence in release is acceptable.

💡assert_eq!(a, b) versus assert!(a == b) — is there any reason to prefer one? click to reveal

Yes, and it is entirely about the failure message.

assert!(a == b) prints only that the assertion failed. assert_eq!(a, b) prints both values:

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

which is usually the whole debugging session. The cost is that assert_eq! requires both sides to be Debug, which is a fine trade.

There is also a clippy lint, manual_assert, for the shape if !cond { panic!("…") } — that is just assert!(cond, "…") written out, and the macro version is clearer and shorter.

And a subtler one, assertions_on_result_states, which objects to assert!(r.is_ok()). It fires because that assertion throws away the error: when it fails you learn that something went wrong and nothing about what. In a test, r.unwrap() is genuinely better — the panic message includes the error’s Debug output.

Where the community actually disagrees

It would be dishonest to present this as settled. It is settled in the middle and contested at the edges.

Broad agreement: library code should not panic on data the caller legitimately supplied; contract violations may panic; unwrap in main or in tests is fine; a panic that can be triggered by untrusted input is a denial-of-service bug.

Genuine disagreement:

  • Some codebases put #![deny(clippy::panic, clippy::unwrap_used, clippy::expect_used)] at the crate root and encode every invariant in the type system instead — newtypes, builders that cannot produce invalid values, NonZeroU32 everywhere. This is more work up front and produces code with very few runtime failure modes. Embedded and safety-critical work tends here.
  • Others use assert! and expect liberally on the grounds that an invariant stated in a line of code is more likely to stay true than one stated in a comment, and that a panic in a well-tested internal function is a fine place for a bug to surface. Application and tooling code tends here.
  • panic = "abort" versus unwinding is a third axis. Aborting is smaller and faster and makes catch_unwind impossible; unwinding lets a server isolate a bad request to one task. Neither is “correct”.

What is not a live disagreement: nobody thinks .unwrap() scattered through a library because the author did not want to think about the None case is acceptable. Every position above is a considered one.

💡You are writing a web server. A request handler hits an unexpected None deep in some business logic. Panic, or Result? click to reveal

The interesting part of this question is that “panic” here does not mean what it means in a CLI.

Most async runtimes and web frameworks catch panics at the task boundary. A panicking handler turns into a 500 for that one request; the process keeps serving. So panicking is not automatically catastrophic — it is, in effect, an extremely blunt error path with a stack trace attached.

That makes the answer situational:

  • If the None genuinely indicates a bug — an invariant your own code should have maintained — panicking is defensible. You get a 500, a stack trace, and an alert, which is roughly what you want for a bug.
  • If it indicates bad input or missing data, it is a Result, because you want a 400 or a 404 and a message, not a 500 and a page.

Two caveats that decide it in practice. First, panicking while holding a Mutex poisons it, so a bug in one request can degrade every subsequent one — a failure mode a Result does not have. Second, if untrusted input can reach the panic, it is a denial-of-service vector, and that moves it from a style question to a security one.

The usual verdict: Result for anything an attacker can steer, panic only for “this cannot happen and if it does I want to know loudly”.

A checklist for the moment of decision

When you are staring at an Option and wondering:

  1. Can the caller’s data cause this?Result.
  2. Can only the caller’s code cause this? → panic, and document it.
  3. Can I make it impossible? → change the types; best answer when available.
  4. Am I in main, a test, or a prototype?expect with a real message is fine; the message is the reason it is fine.
  5. Am I reaching for .unwrap() because I do not want to think about it? → that is the one case that is always wrong, and it is the most common one.