| name | rust-panic-vs-result |
| description | Language-level Rust error-handling philosophy (Ch 9). Covers panic! vs Result<T,E> vs Option, the ? operator, From-based error conversion, unwrap/expect discipline, Result in main, and the \"bad state vs expected failure\" decision rule from the Book. Keywords: panic, unwrap, expect, Result, Option, ? operator, error propagation, unreachable, todo, unimplemented, recoverable, unrecoverable, From conversion, propagate, panic!, panic macro, unwrap_or, unwrap_or_else, ok_or, ok_or_else, map_err, Box<dyn Error>, exit code. Auto-triggers on ANY `.unwrap()`/`.expect()` outside tests, any `panic!`/`unreachable!`/`todo!`/`unimplemented!` in non-test code, any fn returning Result, any `?` usage, any error-conversion question. For framework wiring (AppError, thiserror, IntoResponse, ServerFnError) see [[rust-error-handling]]. Use when this capability is needed. |
Panic vs Result (Error-Handling Philosophy)
The Book (Ch 9) draws a sharp line: panic! = unrecoverable bug; Result<T,E> = expected, recoverable failure. Rust has no exceptions. The compiler enforces that every Result is handled, making error paths explicit and auditable at compile time.
"Most languages don't distinguish between these two kinds of errors and handle both in the same way, using mechanisms such as exceptions. Rust doesn't have exceptions."
— The Rust Programming Language, Ch 9 intro
"When you choose to return a Result value, you give the calling code options."
— The Rust Programming Language, Ch 9.3
When to Use
Invoke this skill when any of the following appear:
- Any
.unwrap() or .expect() call outside a #[cfg(test)] or #[test] context
- Any
panic!, unreachable!, todo!, or unimplemented! in non-test production code
- Any function signature returning
Result<T, E> or using the ? operator
- Any question about when to panic vs. propagate an error
- Any
Option in a service or repo return type where None represents a domain error
Decision Table
| Situation | Correct mechanism |
|---|
| Impossible state that proves a bug exists | panic! / unreachable! (document the invariant) |
| Provable invariant — compiler can't see it | .expect("invariant: …") with an explanation string |
Example, prototype, or #[cfg(test)] block | .unwrap() / .expect() acceptable |
| Expected failure — user input, I/O, network | Result<T, E> + ? propagation |
| Absent value where absence is meaningful/normal | Option<T> |
| Absent value where absence is an error | Result<T, E> — not Option |
| Library / service / handler production path | Result<T, E> — never unwrap() |
| Contract violation by a caller (bad args to lib fn) | panic! — it is the caller's bug |
| Security risk from invalid values | panic! — fail loudly rather than proceed unsafely |
Startup / config loading in main.rs | Result<(), E> from main, or anyhow glue |
Core Idioms
? over explicit match-and-return
fn load_config(path: &str) -> Result<Config, AppError> {
let text = std::fs::read_to_string(path)?;
let cfg: Config = toml::from_str(&text)?;
Ok(cfg)
}
fn load_config(path: &str) -> Result<Config, AppError> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => return Err(e.into()),
};
}
unwrap_or / unwrap_or_else / ok_or for inline recovery
let port: u16 = env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse()
.unwrap_or(8080);
let user = cache.get(&id).ok_or(AppError::NotFound)?;
let port: u16 = env::var("PORT").unwrap().parse().unwrap();
expect("invariant: …") with a documented invariant
let home: IpAddr = "127.0.0.1"
.parse()
.expect("invariant: 127.0.0.1 is a valid IpAddr literal");
let cfg = CONFIG.get().expect("not initialized yet");
Result from main for startup errors
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = Config::from_env()?;
run(cfg)?;
Ok(())
}
fn main() {
let cfg = Config::from_env().unwrap();
run(cfg).unwrap();
}
Forbidden Patterns
Forbidden 1 — .unwrap() in library / service / handler production paths
Forbidden:
let conn = pool.get().unwrap();
let val: Foo = serde_json::from_str(&body).unwrap();
Why (Book Ch 9.3): "When you choose to return a Result value, you give the calling code options." Calling .unwrap() in a shared path makes the decision to abort on behalf of every caller, removes recovery options, and produces an opaque thread-panicked message instead of a typed error.
Fix:
let conn = pool.get()?;
let val: Foo = serde_json::from_str(&body)?;
grep -rn '\.unwrap()' src/ \
| grep -v '#\[cfg(test\|mod tests\|// @allow-unwrap' \
| grep -v '\.unwrap_or'
Forbidden 2 — .expect() without a documented invariant
Forbidden:
let val = map.get("key").expect("key should be there");
Why (Book Ch 9.2): "In production-quality code, most Rustaceans choose expect rather than unwrap and give more context about why the operation is expected to always succeed." An expect with a vague message is marginally better than .unwrap() for debugging but does nothing to document the invariant — the reason the code should never reach the Err/None branch.
Fix: Either eliminate the expect with ?, or document the provable invariant:
let val = map.get("key")
.expect("invariant: key is inserted unconditionally in the constructor");
grep -rn '\.expect(' src/ \
| grep -v 'invariant:\|#\[cfg(test\|mod tests\|// @allow-unwrap'
Forbidden 3 — panic! / unreachable! / todo! / unimplemented! in non-test production code
Forbidden:
fn handle_event(e: Event) {
match e {
Event::Start => start(),
_ => unreachable!("only Start is sent here"),
}
}
fn coming_soon() -> Foo {
todo!()
}
Why: These macros unconditionally abort the thread. todo! and unimplemented! expand to panic! with a hardcoded message at runtime — they are prelude macros documented at std::todo! and std::unimplemented!; there is no Book ch09 citation that describes them. unreachable! is sometimes defensible at a provable exhaustion point, but if the match arms are truly exhaustive the compiler already enforces it — the macro adds a runtime abort where the compiler would give a compile error.
Fix: Return Result with a typed error, or if the branch is truly unreachable by construction, use a compile-time exhaustiveness check instead:
Event::Unknown(tag) => Err(AppError::UnknownEvent(tag))
fn coming_soon() -> Result<Foo, AppError> {
Err(AppError::NotImplemented)
}
grep -rn 'panic!\|unreachable!\|todo!\|unimplemented!' src/ \
| grep -v '#\[cfg(test\|mod tests\|// @allow-panic\|#\[test\]\|tests/'
Forbidden 4 — Manual match-propagation instead of ?
Forbidden:
let user = match repo.find(id).await {
Ok(u) => u,
Err(e) => return Err(e.into()),
};
Why (Book Ch 9.2): "The ? placed after a Result value is defined to work in almost the same way as the match expressions." The manual pattern is not wrong — it is exactly what ? expands to — but writing it by hand adds noise that hides intent and is one of the named anti-patterns in this codebase.
Fix:
let user = repo.find(id).await?;
grep -rn 'return Err(.*\.into())' src/ | grep -v '//'
grep -rn 'return Err(From::from' src/ | grep -v '//'
Forbidden 5 — Swallowing errors silently
Forbidden:
let _ = audit_log.write(entry);
some_result.ok();
channel.send(msg).ok();
Why (Book Ch 9): Rust requires you to acknowledge every Result. Using let _ = … or .ok() on a result that represents a meaningful failure deliberately circumvents this guarantee. Silent discard is almost always a bug: the caller has no way to know the operation failed, and downstream code may proceed on stale or corrupt state.
Fix: Log and/or propagate:
audit_log.write(entry)?;
if let Err(e) = audit_log.write(entry) {
tracing::warn!(error = %e, "audit write failed — non-fatal");
}
grep -rn 'let _ =' src/ | grep -v 'test\|// @allow-discard'
grep -rn '\.ok()' src/ | grep -v 'test\|// @allow-ok-discard\|Option'
Forbidden 6 — .unwrap() on .lock() ignoring poisoning semantics
Forbidden:
let guard = mutex.lock().unwrap();
Why: A Mutex::lock returns Err(PoisonError) only when another thread panicked while holding the lock, leaving the protected data in an unknown state. Calling .unwrap() here re-panics the current thread, cascading the failure. In service code this can bring down the entire worker thread pool.
Fix: Decide consciously:
let guard = mutex.lock().unwrap_or_else(|e| e.into_inner());
let guard = mutex.lock().map_err(|_| AppError::LockPoisoned)?;
grep -rn '\.lock()\.unwrap()' src/ | grep -v 'test\|// @allow-unwrap'
Forbidden 7 — Option where absence is an error
Forbidden:
pub fn find_active_campaign(id: Uuid) -> Option<Campaign> { … }
Why (Book Ch 9.2): Option communicates "absence is a normal, expected outcome." When absence is a domain error — e.g., a referenced entity that must exist — use Result so the error type carries context and the ? operator can propagate it cleanly. Returning Option where None means an error forces every caller to invent their own error wrapping.
Fix:
async fn find_active_campaign(id: Uuid) -> Result<Campaign, AppError> {
repo.get(id).await?.ok_or(AppError::NotFound)
}
grep -rn 'pub.*fn.*-> Option<' src/ \
| grep -v 'test\|// @option-ok'
Book References
Related Skills
- [[rust-error-handling]] — Framework wiring:
AppError, thiserror, anyhow, IntoResponse, FromServerFnError, #[from]/#[source], handler patterns. Start here for project-level error type design; the present skill covers the language idioms underneath.
- [[leptos-error-handling]] —
<ErrorBoundary>, server fn error propagation to the Leptos UI, ServerFnError display.
- [[rust-pattern-matching]] — Exhaustive match,
if let, while let, guards — the foundation for manual Result/Option destructuring when ? is not applicable.
Source: adelabdelgawad/rust-fullstack-agents — distributed by TomeVault.