Errors — Result, ?, and panic
Lesson 09 made absence and choice into types the compiler makes you open. Failure has the same shape — an answer, or a reason there is none — so it becomes one more enum, and the exhaustiveness proof now covers the unhappy path. Two problems remain: opening every result by hand is noise, and some failures are bugs, not events. This lesson removes the noise with a one-character operator that is an ordinary return, chooses the error type, and sets bugs apart: they panic, and a panic runs exactly the drops a return would, because Lesson 03 made every frame's drop schedule known at compile time.
New idea: failure is a value the type system makes you handle,
Result<T, E>, forwarded with one character, ? — an ordinary return. Bugs are not failures: they panic, and a panic is a second return path the compiler already knows how to run, because every frame's drop schedule is static.Forces next: We can model data precisely and handle failure — but every function so far works on one concrete type. How do we write code once for many types and still have the compiler verify that each type can do what the code needs?
Result cannot be used unread. (2) One character that forwards a failure — a return, not a throw. (3) The error type: a closed enum for a library, an open box for an application. (4) Bugs set apart; why unwinding needs no run-time bookkeeping. (5) Return, unwind, abort and exit traced through one stack. (6) A decision rule — and what we kept writing once per type.1 · Three ways to say "I failed"
The smallest fallible function worth writing turns the text of a port into a number: "8080" is a port, "http" is not a number, "70000" does not fit in sixteen bits. What goes in fn parse_port(text: &str) -> ???? There are three roads.
An in-band value: return the number and reserve one value for "failed" — the C convention, which the C++ track's Lesson 15 files under the value channel. It is free, and it has two defects the type cannot rule out: it is forgeable (the reserved value is also a value) and ignorable (nothing makes the caller look).
// The contract lives in a comment: "returns -1 if text is not a port".
int parse_port(const char* text);
void serve(const char* text) {
int port = parse_port(text); // "http" gives -1 ...
bind_to(port); // ... and nothing made us check it
}
An exception cannot be ignored — uncaught, it ends the program — but it leaves the signature (the first of three costs the Go track's Lesson 07 names): uint16_t parse_port(const std::string&) reads as if it always succeeds, and every call becomes a possible exit elsewhere. A second return value, Go's (port, err), puts the failure in the signature, but as a separate variable: nothing in the types stops you from using port without reading err, and _ discards it on purpose.
Rust takes the third road and closes its gap with Lesson 09's tool: the outcomes become the two variants of one enum, enum Result<T, E> { Ok(T), Err(E) } (as Option<T>, filled in per use), and the T is reachable only by finding out which variant you have — Lesson 00's question what does the signature promise?, answered for failure. A result is not a number:
use std::num::ParseIntError;
fn parse_port(text: &str) -> Result<u16, ParseIntError> {
text.parse() // the return type asks for a u16
}
fn main() {
let port: u16 = parse_port("http"); // use the answer as if it succeeded
println!("listening on {port}");
}
error[E0308]: mismatched types
--> src/main.rs:8:21
8 | let port: u16 = parse_port("http"); // use the answer as if it succeeded
| --- ^^^^^^^^^^^^^^^^^^ expected `u16`, found `Result<u16, ParseIntError>`
help: consider using `Result::expect` to unwrap the `Result<u16, ParseIntError>` value, panicking if the value is a `Result::Err`
(parse is the standard text-to-number conversion; the return type picks the number, and ParseIntError is its reason for failing.) The help offers .expect("REASON"), which turns the failure into a stop — sometimes right (§6), but the compiler cannot know whether "http" is a typo in a config file or a bug. It knows only that a Result is not a u16. So you match, and exhaustiveness makes you write the error arm:
use std::num::ParseIntError;
fn parse_port(text: &str) -> Result<u16, ParseIntError> {
text.parse()
}
fn main() {
parse_port("8080"); // the answer is dropped unread
for text in ["8080", "http", "70000"] {
match parse_port(text) { // the only way to reach the u16
Ok(port) => println!("{text}: port {port}"),
Err(e) => println!("{text}: {e}"),
}
}
}
8080: port 8080 http: invalid digit found in string 70000: number too large to fit in target type
One way to ignore a failure is left — discard the whole result, as the first line of main does. Result is marked #[must_use], so that draws a warning by default: unused `Result` that must be used, with the note that it may be an Err variant, which should be handled.
And the cost? Lesson 09's tag plus payload, nothing hidden:
use std::mem::size_of;
use std::num::ParseIntError;
fn main() {
println!("u16 {} bytes", size_of::<u16>());
println!("Result<u16, ParseIntError> {} bytes", size_of::<Result<u16, ParseIntError>>());
}
u16 2 bytes Result<u16, ParseIntError> 4 bytes
A port result is four bytes, twice the port: an ordinary value, returned like any other — nothing is allocated, nothing is thrown. The price appears once a function makes several fallible calls — each needs its own match, the same four lines every time.
2 · One character, and it is a return
Here is a function that adds two ports, written with the matches and again with ?, run on the same inputs:
use std::num::ParseIntError;
fn sum_ports(a: &str, b: &str) -> Result<u32, ParseIntError> {
let x: u16 = a.parse()?; // one character each
let y: u16 = b.parse()?;
Ok(x as u32 + y as u32)
}
fn sum_ports_by_hand(a: &str, b: &str) -> Result<u32, ParseIntError> {
let x: u16 = match a.parse() { // what each ? stands for
Ok(v) => v,
Err(e) => return Err(From::from(e)),
};
let y: u16 = match b.parse() {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
};
Ok(x as u32 + y as u32)
}
fn main() {
for (a, b) in [("80", "443"), ("80", "http"), ("70000", "1")] {
println!("{:?} same: {}", sum_ports(a, b), sum_ports(a, b) == sum_ports_by_hand(a, b));
}
}
Ok(523) same: true
Err(ParseIntError { kind: InvalidDigit }) same: true
Err(ParseIntError { kind: PosOverflow }) same: true
They agree because the second function spells out what ? does. ? is a desugaring: on a Result, expr? stands for the match of the second function —Ok(v) makes the value v, and Err(e) makes the enclosing function return Err(From::from(e)); on an Option, None returns None.
Two consequences separate ? from an exception. It is a return: control goes to the immediate caller along a path the signature announces; nothing unwinds, no handler is searched for, and the caller decides again. And the error passes through From::from, a conversion the compiler looks up by the pair of types — from the error you have to the one your function declares (the identity when the two are the same type); Lesson 11 explains such look-ups, and §3 writes one. ? will not turn an Option into a Result — a None carries no error, so you supply one (ok_or, §3) — and it needs somewhere to return to:
fn main() {
let port: u16 = "8080".parse()?; // main returns (): nowhere to send an Err
println!("listening on {port}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:2:35
1 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
2 | let port: u16 = "8080".parse()?; // main returns (): nowhere to send an Err
| ^ cannot use the `?` operator in a function that returns `()`
help: consider adding return type
The message states what ? needs: it returns an Err (or a None) from the enclosing function, and a function returning () has no such value. The help — make main return Result<(), Box<dyn std::error::Error>> — is a real option (§3 uses it), but it is a local edit standing in for a design decision the compiler cannot make: should this function forward the failure, or handle it here — report it, use a default port, ask again? E0277 means "a type lacks a capability this code needs"; here, () cannot carry a failure back. This opens a box that recurs: a missing trait bound (Lessons 11–12), an Fn bound a parameter does not meet (14), Rc not Send (16).So ? forwards any error that converts into the declared one — but cannot tell you what the declared one should be when a function fails in several ways, with several types.
3 · What is E? A closed set, or an open one
Parse an address such as db:5432. It fails three ways — no colon (split_once returns None), a port that is not a number (ParseIntError), a number that is not a port (0) — and a caller may want to treat them differently: no colon might mean "use the default port".
E is String. It reads well in a log and fails every other test: a caller that must react to which failure happened compares English text, which breaks when a message is reworded; the ParseIntError inside is flattened into characters; and the compiler cannot check that every case is handled.For a library, E is a closed enum: one variant per way to fail, each carrying the context a caller needs — so callers can match on it:
use std::num::ParseIntError;
#[derive(Debug)] // lets {:?} print it
enum AddrError {
NoColon, // "db": nothing to split
BadPort(ParseIntError), // "db:http": and why the number failed
PortZero, // "db:0": a number, but not a port
}
impl From<ParseIntError> for AddrError { // the conversion ? looks up
fn from(e: ParseIntError) -> Self { AddrError::BadPort(e) }
}
fn parse_addr(text: &str) -> Result<(&str, u16), AddrError> {
let (host, port) = text.split_once(':').ok_or(AddrError::NoColon)?;
let port: u16 = port.parse()?; // ParseIntError becomes AddrError
if port == 0 { return Err(AddrError::PortZero); }
Ok((host, port))
}
fn main() {
for text in ["db:5432", "db", "db:http", "db:0"] {
match parse_addr(text) {
Err(AddrError::NoColon) => println!("{text}: no port given, using 80"),
other => println!("{text}: {other:?}"),
}
}
}
db:5432: Ok(("db", 5432))
db: no port given, using 80
db:http: Err(BadPort(ParseIntError { kind: InvalidDigit }))
db:0: Err(PortZero)
#[derive(Debug)] asks the compiler to write the {:?} printer (Lesson 11 shows what it writes). impl From<ParseIntError> for AddrError is the conversion ? looks up, so port.parse()? leaves the function as AddrError::BadPort. ok_or turns split_once's Option into a Result by supplying the error for None. The caller reacts to one variant and prints the rest.
Two more impls make it an error the ecosystem recognizes: Display (the text a human reads) and std::error::Error (which requires Debug and Display, and offers an optional source() for the underlying cause). The macro crate thiserror generates both from attributes; this series writes them by hand:
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum AddrError { NoColon, BadPort(ParseIntError), PortZero }
impl fmt::Display for AddrError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AddrError::NoColon => write!(f, "expected host:port"),
AddrError::BadPort(e) => write!(f, "bad port: {e}"),
AddrError::PortZero => write!(f, "port 0 is not a port"),
}
}
}
impl Error for AddrError {} // Debug + Display were the requirements
pub fn port_only(text: &str) -> Result<u16, AddrError> {
text.parse().map_err(AddrError::BadPort) // name the conversion at the call site
}
The last function shows what map_err adds to From. From is looked up by type, so each source type converts one way; if the address also carried a worker count, both fields would fail with a ParseIntError and one From could not say which. map_err(AddrError::BadPort) names the conversion at the call site (a variant with a payload is a function from payload to enum).
An application has the opposite problem: it calls many libraries and mostly wants to do one thing with any of their errors — report and stop. For that there is an open set, Box<dyn Error>: one pointer type that can hold any type implementing Error, with conversions from every such type (and from &str and String), so ? accepts them all:
use std::error::Error;
fn load(port: &str, ratio: &str) -> Result<(u16, f64), Box<dyn Error>> {
let port: u16 = port.parse()?; // ParseIntError -> Box<dyn Error>
let ratio: f64 = ratio.parse()?; // ParseFloatError -> Box<dyn Error>
if ratio > 1.0 {
return Err("ratio must be at most 1".into()); // &str -> Box<dyn Error>
}
Ok((port, ratio))
}
fn main() -> Result<(), Box<dyn Error>> {
println!("{:?}", load("8080", "0.5")?);
println!("{:?}", load("8080", "half")?); // Err: main returns it
println!("never printed");
Ok(())
}
(8080, 0.5)
The box erased the types: main never names ParseFloatError, and code holding the box can print it but has no variants to match on. It costs a heap allocation and calls through a table of functions (Lesson 13). Returning Err from main is a policy too: the error's Debug form goes to standard error and the process exits with a failure status — here Error: ParseFloatError { kind: Invalid } and status 1. A library returns a closed enum and lets callers decide; an application collects one box and decides. Some failures no caller can act on.
4 · Bugs are not events
What would an error value mean for an index past the end of a slice (Lesson 02)? Every v[i] would return a Result, and every caller would handle an error only a bug can produce. The same holds for unwrap() on a None that could not be None (Lesson 09), a RefCell borrowed twice (Lesson 15), and overflow in a debug build (Lesson 02): not events to respond to, but evidence the program is wrong. For these Rust panics: the function does not return normally, and by default the thread unwinds — each frame between the panic and the edge of the thread is ended, its live values dropped as if they had left scope.
The budget should object: walking the stack running destructors sounds like run-time bookkeeping of every live value. It needs none, because of Lesson 03. Every frame's drop schedule is fixed at compile time — at each call the compiler knows which locals may be live (Lesson 04's drop flag settles the doubtful ones) and in what order they end, since it emits those drops for the normal return anyway. So at each call that might panic it can also emit a cleanup path: the same drops, taken when control leaves through the call instead of past it — a second return path with no source syntax. Run both paths through one stack:
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) { println!("drop {}", self.0); }
}
fn serve(fail: bool) { let _conn = Guard("conn"); load(fail); }
fn load(fail: bool) { let _file = Guard("file"); let _lock = Guard("lock"); parse(fail); }
fn parse(fail: bool) { let _buf = Guard("buf"); if fail { panic!("bad header"); } }
fn main() {
serve(false); // three ordinary returns
println!("-- again, with a panic in parse --");
serve(true); // three cleanup paths
}
drop buf drop lock drop file drop conn -- again, with a panic in parse -- drop buf drop lock drop file drop conn
Same four drops, same order: innermost frame first and, inside load, lock before file (reverse declaration order). The panic needed no new rule, only a new door. (The process then ends with status 101.) C++ unwinds the same way (its Lesson 15), for whatever you wrapped in a destructor; in Rust every owning value already has a scheduled drop. The Nomicon's cost model: being ready to unwind should cost nothing at run time where nothing panics, so unwinding itself is comparatively slow — another reason it is for bugs.
Two exits skip the cleanup path. The panic strategy can be set to abort (-C panic=abort, or panic = "abort" in a Cargo profile): a panic prints its message and ends the process on the spot, and std::process::exit ends it likewise without a panic. Built with -C panic=abort, the program above prints the first run's four drops, the separator and nothing more, and dies on the abort signal (status 134 here) with four guards created and none dropped. That is allowed — Lesson 03 showed that leaking is safe, and the Reference lists ending a program without running destructors among the behaviors not considered unsafe — but it is a loss (a buffered write never flushed), so choose abort knowingly: it applies to every panic in the program, though the Book suggests it for smaller release binaries. A destructor that panics while the thread is already unwinding forces the same outcome, since two panics in flight have no sensible continuation:
struct Flush(&'static str); // a destructor that can itself fail
impl Drop for Flush {
fn drop(&mut self) { println!("drop {}", self.0); panic!("disk full"); }
}
fn main() {
let _first = Flush("first");
let _second = Flush("second");
panic!("bad header"); // unwinding drops _second first, and its drop panics ...
} // ... so _first is never dropped
drop second
Standard error ends with panic in a destructor during cleanup and thread caused non-unwinding panic. aborting., and first is never dropped. C++ has the same rule (a destructor that throws during unwinding calls std::terminate), so in both languages a destructor should not be the only place a failure is reported.
Can a panic be caught? std::panic::catch_unwind runs a closure (Lesson 14) and returns Err if it panicked; a spawned thread's join does likewise (Lesson 17). Neither is try/catch: they catch only unwinding panics, the documentation discourages catch_unwind as general error handling, and its closure must meet an UnwindSafe bound encoding exception safety. Their place is a boundary — a thread pool that must outlive one bad job, or Rust called from C, where a panic reaching an extern "C" function's edge aborts (since Rust 1.81). Poisoning (Lesson 17) and panic safety (Lesson 20) build on this. So a failing frame has four exits, differing in who may intervene and which destructors run.
5 · Return or unwind? Trace it
The widget runs all four on one stack, main → serve → load → parse → …: each frame has one call and up to two guards before or after it, and the guard constructor also prints new NAME. The engine applies Lesson 03's rules — reverse declaration order per frame, innermost frame first, a tuple's fields first to last, let _ = dropped at once — and prints the exact stdout of the program the configuration stands for, listed one line per frame under this header:
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) { println!("drop {}", self.0); }
}
fn guard(name: &'static str) -> Guard { println!("new {name}"); Guard(name) }
#[derive(Debug)]
struct Fail;
type R = Result<(), Fail>; // the Err + ? mode
fn fail(at: &str) -> R { println!("{at} fails"); Err(Fail) }
// panic modes: fn fail(at: &str) { println!("{at} fails"); panic!("{at} failed"); }
// exit mode: fn fail(at: &str) { println!("{at} fails"); std::process::exit(1); }
The engine never reads that program; the oracle tools/rust_verify/10_unwind.js compiled and ran 371 of them — 11 named configurations and 360 random ones over the four modes (abort built with -C panic=abort) — and stdout and exit status matched the prediction every time.
What to try. The default is §4's program: the drops read buf, lock, file, conn. Drag the failing frame to none: the same four drops, then main ends. Switch to Err + ?: stdout does not change by one line, but the status is 1, not 101 — ordinary returns instead of cleanup paths. Press serve handles it: sess, declared after serve's call, exists only because the Err was handled (5 created, 5 dropped, status 0); switch that to unwind and sess never exists, since a panic carries no value to match on. Abort or exit: 4 created, 0 dropped. Last, pair + let _: (file, lock) drops file first, and let _ = guard("buf") drops buf before parse fails. The trace shows what each exit does, not which one a failure deserves.
6 · Choosing: a rule that survives contact
One question does most of the work: can a caller reasonably do something different when this fails? If so, return a Result — malformed input, a missing file, a rate limit: the caller may retry, fall back or report. If the failure means the program's own reasoning is wrong — an invariant broken, an index computed badly, a state the types could not rule out — panic, because continuing would build on a false premise. The Book draws the same line, adding the case where continuing could be harmful: that is why an out-of-bounds index panics.
unwrap() and expect(msg) are how code says "this cannot fail": both panic on Err or None. They are assertions, and an assertion needs a reason; expect writes the reason where the next reader will see it and makes it the panic message:
fn main() {
let ports: Vec<u16> = Vec::new();
let first = ports.first().expect("the config lists at least one port");
println!("first port {first}");
}
The run prints the config lists at least one port and ends with status 101, telling whoever reads the log which belief broke. The Book's own example of a justified expect parses an IP address written as a literal: it cannot fail, and the message says why. A library returns Result on bad input and panics only when a caller breaks a documented contract; an application — a prototype, a test, the top of main — may unwrap, because there stopping is the decision (a test fails by panicking).
| C++ · trusts you | Go · constrains you | Rust · verifies you | |
|---|---|---|---|
| Ignoring it | silent, unless [[nodiscard]] | on purpose, with _ | the value is unreachable unhandled; dropping it warns |
| A bug | often undefined behavior | panic (programmer errors), unwinding through defers | panic: unwinds running Drop, or aborts |
| If a failure escapes | a guarantee: basic, strong or no-throw | — | memory safety; unsafe code must survive it (Lesson 20) |
Common mistakes / failure modes
? is exceptions"return (§2): the error goes to the immediate caller as an ordinary value, as the signature says, and nothing unwinds. Replace each ? with its match and nothing changes.unwrap is always wrong"expect("why") so the reason becomes the panic message (§6).catch_unwind is a boundary tool its documentation discourages as error handling (§4).Result is slow"? is a match and a return (§1–2). Unwinding is the comparatively slow path, which is why it is kept for bugs.panic = "abort", not on process::exit, not after a second panic while unwinding, not after mem::forget (Lesson 03). Each is safe; none is cleanup (§4, the widget).Checkpoint exercise
conn · call · sess, load (file, lock) · call, parse buf · call · tmp, decode row key · call. Make decode fail with Err + ?, handled by load. Before you look, write the stdout line by line and name the guards never created; then check it, and switch the mode to unwind — which lines disappear, and why? You should find 7 guards created and 7 dropped, tmp never created, and load handles the Err between drop buf and drop file; under unwind, sess never exists either: 6 created, status 101. To check without trusting the widget, put the readout's five program lines under §5's header in a file and run it with rustc.Where this points next
Failure is now a value, forwarded by one visible character, and bugs stop the thread through a cleanup path derived from the drop schedule. But every piece was written for one concrete type — parse_port returns Result<u16, ParseIntError> and nothing else — and making AddrError an error took impl Display, impl Error and impl From, each a promise about one type. ? and Box<dyn Error> accepted any error only because each type had made such promises. Lesson 11 brings that promise into the open to answer the question it raises: how do we write a function once, for every type with the abilities it uses, and have the compiler check both that the body asks for nothing more and that each type delivers?
Result<T, E> is an enum, so the value is reachable only through a match. ? removes the noise and hides nothing: Err(e) returns Err(From::from(e)) to the immediate caller — no unwinding, no handler search. Libraries declare a closed enum callers can match on; applications collect an open Box<dyn Error> and decide to stop. Bugs panic, and unwinding runs exactly the drops a return would, because every frame's drop schedule is static; abort, process::exit and a panic in a destructor while unwinding skip them — safe, but no cleanup. If a caller can reasonably do something different, return Result; if the program is wrong, panic, with expect("why").Interview prompts
- How does
?work, and what doesFromhave to do with it? (§2 —expr?is a match:Ok(v)givesv,Err(e)returnsErr(From::from(e));Fromis a conversion looked up by type, into the function's declared error.) - Why is
?not an exception, and why does Rust not need exceptions? (§2, §4 —?is an ordinary return to the immediate caller, announced by the return type; bugs panic, and unwinding reuses the drop schedule the compiler already had.) - Result or panic — how do you decide? (§6 — if a caller can reasonably do something different,
Result; if the failure means the program is wrong, panic, withexpectsaying why it cannot happen; libraries do not panic on bad input.) - What happens to local variables when a thread panics? (§4 — by default each frame's live locals drop in the order a normal return would use, via cleanup paths built from the static drop schedule; under abort, or after a destructor panics during unwinding, the rest are never dropped.)
- When an error enum, and when
Box<dyn Error>? (§3 — an enum for a library: a closed set callers can match on; the box for an application: an open set any error converts into, for an allocation, dynamic dispatch and no variants to match on.) - What does
panic = "abort"change? (§4 — a panic ends the process without unwinding: no destructors run andcatch_unwindhas nothing to catch. Skipping destructors is not unsafe, only uncleaned.)
Companion reads: C++ · 15 Error handling (unwinding, its cost model, the three guarantees), C++ · 05 RAII (why cleanup belongs in destructors), Go · 07 Errors are values (failure in the signature; panic is not an exception), and FP · 11 Option, Try, Either (the same sum types, with a typed error channel).