Error Handling
Two Kinds of Errors
Section titled “Two Kinds of Errors”Rust divides errors into two fundamentally different categories, and handles each one differently.
Unrecoverable errors — bugs in your program that you did not anticipate and cannot sensibly continue from: an array index that is out of bounds, a failed assertion, or a logic invariant that should never be violated. Rust handles these with panic!, which unwinds the stack and terminates the thread.
Recoverable errors — situations where failure is expected and the caller should be able to react: a file might not exist, user input might not parse, or a network request might time out. Rust models these with the Result<T, E> enum, which forces every caller to explicitly handle or propagate the error.
This distinction is enforced by the type system. If a function can fail recoverably, its return type is Result<T, E>. If it can panic, there is no extra annotation — panics can happen anywhere, but they should be reserved for genuine programming bugs.
What This Module Covers
Section titled “What This Module Covers”| Lesson | Topic |
|---|---|
| Panics | panic!, .unwrap(), .expect(), when panicking is appropriate |
| Result | Result<T, E>, Ok/Err, matching, .unwrap_or, .unwrap_or_else |
| The ? Operator | Early-return propagation, chaining, main returning Result |
| Custom Errors | Error enums, Display + Error traits, From conversions |
A First Taste
Section titled “A First Taste”The snippet below shows both error kinds side by side: a panic! that you would only call when the input is a logic error, and a Result-returning function that the caller must handle.
fn divide(a: i32, b: i32) -> Result<i32, String> { if b == 0 { Err(String::from("division by zero")) } else { Ok(a / b) }}
fn main() { // Recoverable error: use Result match divide(10, 2) { Ok(result) => println!("10 / 2 = {}", result), Err(e) => println!("Error: {}", e), } match divide(5, 0) { Ok(result) => println!("5 / 0 = {}", result), Err(e) => println!("Error: {}", e), } println!("Program continues normally after handling errors.");}Compiling…
Result vs Panic: A Decision Table
Section titled “Result vs Panic: A Decision Table”The hardest part of writing idiomatic Rust error handling is not the syntax — it is deciding, for each failure mode, whether it is a bug (panic) or an expected outcome (Result). Use this table every time you are unsure:
| Question | If yes | If no |
|---|---|---|
| Can the failure come from outside your program (files, network, user input, another process)? | Result | keep asking |
| Would continuing after the failure corrupt data or violate an invariant your code promised? | panic! | keep asking |
Is this a debug_assert!/precondition check that should never fire in correct code? | panic! | keep asking |
| Is this library code where the caller, not you, should decide what “handle” means? | Result | Result (default) |
Reserve panic! for the narrow case where continuing is unsafe or meaningless: an index you computed yourself going out of bounds, a .unwrap() on a value you just constructed as Some, or a violated internal invariant. Everything that can plausibly fail because of the outside world — a file that might not exist, a string that might not parse, a socket that might time out — is a Result.
flowchart TD
A[Failure can happen] --> B{Caused by external input,
environment, or another party?}
B -->|Yes| R[Return Result]
B -->|No| C{Continuing would corrupt
state or violate an invariant?}
C -->|Yes| P[panic!]
C -->|No| D{Are you writing
library code?}
D -->|Yes| R
D -->|No| R2[Result is still the safe default] What the Compiler Says: An Ignored Result
Section titled “What the Compiler Says: An Ignored Result”Result is #[must_use] in the standard library, so calling a Result-returning function and discarding the value produces a warning by default. This course’s snippet harness compiles with -A warnings so intentional demos never fail the build — but an explicit #![deny(unused_must_use)] at the top of the file overrides that blanket allow, exactly like a stricter Cargo project (RUSTFLAGS="-D warnings") would. The transcript below is from a real rustc --edition 2024 run of this exact file, without -A warnings, so you can see what your own cargo build would show:
// @skip-verify demonstrates a lint-denied error with no E-code; harness -A warnings// would silence the plain warning form, so the real transcript is quoted below instead#![deny(unused_must_use)]
fn might_fail() -> Result<i32, String> { Err(String::from("nope"))}
fn main() { might_fail(); println!("done");}error: unused `Result` that must be used --> deny_must_use.rs:8:5 |8 | might_fail(); | ^^^^^^^^^^^^ | = note: this `Result` may be an `Err` variant, which should be handlednote: the lint level is defined here --> deny_must_use.rs:1:9 |1 | #![deny(unused_must_use)] | ^^^^^^^^^^^^^^^help: use `let _ = ...` to ignore the resulting value |8 | let _ = might_fail(); | +++++++
error: aborting due to 1 previous errorWithout #![deny(...)], a plain cargo build still shows this as a warning (same message, warning: instead of error:, exit code 0) — easy to miss in a noisy build log. The question-mark and result lessons come back to #[must_use] in more detail.
Mental Model
Section titled “Mental Model”Think of a Rust function’s return type as a contract with its caller. -> T says “I always produce a T.” -> Result<T, E> says “I might fail in a way you should plan for — here is exactly what the failure looks like, and you must acknowledge it.” A panic! breaks that contract instead of extending it: it says “the contract itself was violated by a bug, and no return value can fix that, so I am unwinding past you entirely.” Reading a function’s signature should tell you which of the two you are dealing with before you read a single line of its body.
Trade-offs
Section titled “Trade-offs”panic! is unforgiving but honest — the moment something is wrong, the program stops instead of continuing on bad assumptions, and you get a stack location for free. Its cost is availability: one bad input from an untrusted source can take down a whole process (or, in a server, one request handler at a time if you catch it, which is rarely worth doing). Result is composable and lets callers decide policy — retry, fall back, log and continue — but it pushes a real cost onto every caller: they must write a match, a ?, or an explicit .unwrap()/.expect() that turns the Result back into a panic anyway. Choosing Result for everything “just in case” makes signatures noisy and pushes decisions onto callers who have no more information than you did.
Common Misconceptions
Section titled “Common Misconceptions”- “Panicking is like throwing an exception, so I should use
Resultfor everything to be safe.” OverusingResultfor things that truly can never fail (parsing a hardcoded literal, indexing a fixed-size array you just built) just moves a fake decision onto every caller. If failure is genuinely impossible,unwrap()it and say so in a comment. - “A
Resultthat’sOkmost of the time is basically free.”Result<T, E>still costs a branch and, depending onE’s size, extra stack space — usually negligible, but not literally free like exceptions-that-never-throw are sometimes assumed to be. - “Rust doesn’t have exceptions, so a panic can’t happen in production.” It can, and does — an out-of-bounds index, an integer overflow in debug builds, or a third-party crate’s own
unwrap()will all panic in a running service exactly as they would in a test.
Reading AI-generated code
Section titled “Reading AI-generated code”AI assistants frequently reach for panic! (or an .expect() with a generic message) the moment a failure comes from outside the program — reading a config file, parsing a request body, opening a socket — because it is the shortest code to type, not because it is the right call for the caller.
🐛 Spot the bug
fn load_config(path: &str) -> Config { let text = std::fs::read_to_string(path) .unwrap_or_else(|_| panic!("config file missing: {}", path)); parse_config(&text)}Show the answer
A missing config file is exactly the kind of failure the decision table above sends to Result, not panic! — it comes from the environment (a user’s disk, a deploy that forgot to mount a volume), and the caller may well want to fall back to defaults or print a clean error message instead of crashing the whole process. The fix is fn load_config(path: &str) -> Result<Config, ConfigError> with a ? or explicit match at the call site, reserving panic! for a truly unreachable invariant inside parse_config itself (like a hardcoded default that you constructed and know is valid).