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
Resultwhen 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:
- It forces every correct caller to handle an error that only an incorrect caller can trigger.
-
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 amatcharm 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 whendebug_assertionsis 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,NonZeroU32everywhere. 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!andexpectliberally 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 makescatch_unwindimpossible; 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
Nonegenuinely 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:
-
Can the caller’s data cause this? →
Result. - Can only the caller’s code cause this? → panic, and document it.
- Can I make it impossible? → change the types; best answer when available.
-
Am I in
main, a test, or a prototype? →expectwith a real message is fine; the message is the reason it is fine. -
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.