Rust Error Handling
Rust has no exceptions and no null. Operations that can fail return Result<T, E>, either Ok(value) or Err(error), and values that might be absent are Option<T>, either Some(value) or None. The type system forces you to deal with both cases before you can use the value, so forgetting to handle an error is a compile error, not a crash in production.
The ? operator keeps this from becoming verbose: it returns early with the error if there is one and unwraps the value otherwise. Combined with two popular crates (thiserror for defining library errors and anyhow for application-level errors), Rust error handling is both rigorous and concise.
TL;DR
- Recoverable errors are
Result<T, E>; absence isOption<T>; bugs arepanic!. ?propagates errors up the call stack, converting them withFromalong the way.- Libraries define specific error enums (often with
thiserror) so callers can match on variants. - Applications often use
anyhow::Resultand add context with.context("..."). - Avoid
.unwrap()and.expect()in production paths except where failure is truly impossible or should crash. - Combinators (
map,and_then,ok_or,unwrap_or_default) transform results withoutmatchboilerplate.
Quick Example
A small application using anyhow for context and ? for propagation:
If the file is missing, the program exits with a readable chain:
Core Concepts
Result and Option
Both are ordinary enums. You handle them with match, if let, let ... else, or combinators:
Convert between them with .ok() (Result → Option) and .ok_or(err) / .ok_or_else(|| err) (Option → Result).
The ? Operator
? on a Result returns Err(e) from the current function (after converting e with From::from) or evaluates to the Ok value. On an Option, it returns None early. The function's return type must be compatible, which is why main can return Result<()>.
Custom Error Types
Libraries should expose errors callers can inspect. The idiomatic shape is an enum implementing std::error::Error, which thiserror derives for you:
Callers can then match on variants: return 404 for NotFound, 409 for Conflict, 500 for Database.
Error Sources and Chains
The Error trait's source() method links an error to its cause, forming a chain like Go's wrapped errors (see Go error handling). #[from] and #[source] in thiserror set it; anyhow's .context() adds a layer on top. Reporters print the whole chain.
thiserror vs anyhow
Many projects use both: thiserror inside library crates and domain modules, anyhow at the application edges where errors are logged and reported.
Panic vs Result
panic! unwinds (or aborts) the current thread. Use it for:
- Bugs and broken invariants: an index that must be in bounds, a state that "can't happen".
- Tests (
assert!,unwrap()in test code is fine). - Unrecoverable startup failures in binaries, where
.expect("reason")documents why.
Don't panic on anything a caller could reasonably handle: bad input, missing files, network failures. Libraries in particular should almost never panic on user input.
Best Practices
Use expect With a Reason Over unwrap
If you must assert success, .expect("config validated at startup") states the invariant and makes panics diagnosable. Lint with clippy::unwrap_used in production crates.
Add Context at Each Layer
A bare No such file or directory is useless. Add what the program was trying to do and with what: reading config file app.toml. Context belongs where the knowledge is: the function that knows the path adds the path.
Keep Library Errors Stable and Specific
Public error enums are part of your API. Mark them #[non_exhaustive] so you can add variants without a breaking change, and avoid leaking every dependency's error type as a public variant.
Map Errors at Boundaries
Convert domain errors into HTTP responses, exit codes, or user messages in one place, such as an IntoResponse impl in Axum (see Axum). Don't scatter status-code logic through business code.
Common Mistakes
Unwrapping in Production Code
Stringly-Typed Errors
Result<T, String> loses the source chain and makes matching impossible. Use an error enum or anyhow instead.
Discarding Errors With let _ = or .ok()
let _ = file.flush(); silently hides failures. If ignoring an error is intentional, comment why; otherwise handle or propagate it.
FAQ
Why doesn't Rust have exceptions?
Exceptions create invisible control flow and make it easy to forget handling. Rust puts failure in the type signature: a function returning Result visibly can fail, and callers must acknowledge it. ? keeps propagation to a single character, so explicitness doesn't cost much verbosity.
Should I use thiserror or anyhow?
thiserror for libraries and any code whose callers need to react differently to different failures. anyhow for applications where errors are mostly reported, not matched. Using thiserror in your domain crates and anyhow in main is a common, sensible split.
What's Box<dyn Error>?
A trait object that can hold any error type. It's a quick, dependency-free catch-all for small programs and examples, and ? converts most errors into it automatically. anyhow is a more capable version of the same idea, with context and backtraces.
How do I get a backtrace?
anyhow captures backtraces when RUST_BACKTRACE=1 (or RUST_LIB_BACKTRACE=1) is set. For panics, RUST_BACKTRACE=1 prints the stack at the panic site. std's std::backtrace::Backtrace can be captured manually in custom error types.
Related Topics
- Rust — The language overview
- Rust Traits —
Error,From, andDisplay - Error Handling — Strategies across languages
- Go Error Handling — Errors as values in Go
- Axum — Turning errors into HTTP responses