Rust error handling patterns with Result, Option, thiserror, and anyhow. Use whenever implementing error types, adding error propagation with ?, choosing between panic and Result, or working with .unwrap()/.expect(). Covers library vs application error strategy, error chaining, context, and custom error types. Also use when seeing unwrap abuse or unclear error propagation in code review.
Is this failure expected or a bug?
This single question drives every error handling decision:
Result<T, E>Option<T>panic! / expect()If you're unsure, it's almost certainly expected — use Result.
| Symptom | Don't Just Say | Ask Instead |
|---|---|---|
| Unwrap everywhere | "Add expect message" | What should happen when this fails? |
Box<dyn Error> return |
| "It works" |
| Can the caller handle specific errors? |
| Error type has 15 variants | "Be thorough" | Are these all distinct failure modes the caller cares about? |
.context() on every line | "More context is better" | What context does the caller actually need? |
| Panic in library code | "Document the panic" | Should this be a Result instead? |
| Scenario | Use | Why |
|---|---|---|
| Library with typed errors callers match on | thiserror | Generates Error impl, typed variants |
| Application-level error handling | anyhow | Easy context, no custom types needed |
| Library public API + internal convenience | thiserror public, anyhow internal | Best of both |
| Function can fail in expected ways | Result<T, E> | Caller decides how to handle |
| Value may or may not exist | Option<T> | No error info needed |
| Invariant violated (bug in code) | panic! / unreachable! | Should never happen in correct code |
| Test assertions | unwrap() / expect() | Panics give clear test failures |
| One-off script / prototype | anyhow::Result in main | Quick iteration, good error display |
| CLI tool with source-pointing errors | miette | Rich diagnostics with labeled source spans and help text |
| Application with colorized backtraces | color-eyre | Enhanced eyre with span traces and color |
| User-facing HTTP error | Custom AppError implementing IntoResponse | Safe message for users, full context in logs |
| CLI exit codes | 0 = success, 1 = error, 2 = usage | Convention that scripts and CI rely on |
? is the backbone of Rust error handling. It propagates
errors up the call stack, converting types via From
automatically.
fn load_config(path: &Path) -> Result<Config, AppError> {
let content = std::fs::read_to_string(path)?; // io::Error → AppError via From
let config: Config = toml::from_str(&content)?; // toml::Error → AppError via From
Ok(config)
}
Add context when the error alone doesn't tell the story:
use anyhow::Context;
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {}", path.display()))?;
| Context | Crate | Pattern |
|---|---|---|
| Library | thiserror | Typed, matchable errors callers can inspect |
| Application | anyhow | Easy propagation with context chains |
| Both | thiserror for public API | anyhow for internal plumbing |
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParseError {
#[error("invalid syntax at line {line}: {message}")]
Syntax { line: usize, message: String },
#[error("unexpected end of file")]
UnexpectedEof,
#[error("io error reading input")]
Io(#[from] std::io::Error),
}
use anyhow::{Context, Result, bail, ensure};
fn run() -> Result<()> {
let config = load_config("app.toml")
.context("failed to initialize")?;
ensure!(config.port > 0, "port must be positive, got {}", config.port);
if config.debug && config.production {
bail!("cannot enable debug in production");
}
Ok(())
}
"invalid input" not "Invalid input.""DNS lookup failed", "HTTP 503""failed to load config: invalid toml: expected '=' at line 3"Scenario 1: "I'm writing a library — how should I define errors?"
→ Use thiserror. Create an enum with one variant per
failure mode the caller cares about. Use #[from] for
automatic conversion from underlying errors. Don't expose
internal error types — wrap them.
Scenario 2: "I have unwrap() calls everywhere in my application"
→ Replace with ? and anyhow::Result. Add .context()
where the error alone isn't enough to diagnose the problem.
Keep unwrap() only in tests and for invariants you've
already validated.
Scenario 3: "Should I use expect or return an error here?"
→ If the caller can do something about it (retry, use a
default, report to user), return Result. If it means the
program's logic is broken (invariant violation),
expect("reason this should never fail") is appropriate.
| File | Read When |
|---|---|
| error-patterns | Setting up thiserror/anyhow, error chaining, #[from]/#[source], context patterns, documentation |
| error-decisions | Deciding panic vs Result, designing custom error types, when-to-use-what scenarios, diagnostic crates (miette, color-eyre), user-facing vs internal errors, CLI exit codes |
| When | Check |
|---|---|
| Error types involving ownership/lifetimes | rust-ownership → Quick Decisions |
| Async error handling patterns | rust-async → Quick Decisions |
| Documenting # Errors sections | rust-api → Quick Decisions |
| Clippy lints for error handling | rust-quality → Quick Decisions |
| Error logging, span traces | rust-tracing → Quick Decisions |
| Error responses in web handlers | rust-architecture → Quick Decisions |