Error Handling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust 1.99.0 and the 2024 edition, thiserror 2.0 and anyhow 1.0, checked on 2026-10-06.
Rust separates unrecoverable errors — bugs and broken invariants, which panic! — from recoverable errors — expected failures such as a missing file or invalid input, which are values of type Result<T, E>. There are no
exceptions: every function that can fail says so in its signature, and the caller must decide what to do. The
mechanics of Option, Result, ? and the combinators are on
Option, Result & Combinators; this page is about
designing and using error types.
Choosing the right tool
encounter this?} B -- "No: a bug or broken invariant" --> P[panic! / unreachable! / expect] B -- "Yes: bad input, I/O, network, missing data" --> R[Return Result] R --> C{Who handles it?} C -- "A library: callers match on causes" --> L[Typed error enum with thiserror] C -- "An application: report, log, exit" --> APP[anyhow::Error with context] C -- "An HTTP handler" --> H[AppError enum that implements IntoResponse] R --> O{Is absence normal
and self-explanatory?} O -- "Yes" --> OPT[Option instead of Result]
Unrecoverable errors: panic!
panic!("message") stops the current thread. By default the panic unwinds: it walks back up the stack running every
destructor, prints the message (and a backtrace if RUST_BACKTRACE=1), and ends the thread. A panic in the main
thread ends the program with exit code 101. Many standard operations panic on misuse: out-of-bounds indexing, integer
overflow in debug builds, division by zero, unwrap() on None, a RefCell borrowed twice mutably.
fn page_slice(pages: &[u32], start: usize, len: usize) -> &[u32] {
assert!(len > 0, "len must be positive, got {len}"); // assert! panics if false
&pages[start..start + len] // panics if out of bounds
}
fn main() {
let pages = [10, 20, 30];
println!("{:?}", page_slice(&pages, 1, 2));
let result = std::panic::catch_unwind(|| page_slice(&pages, 2, 5).len());
println!("caught a panic: {}", result.is_err()); // true -- for illustration only
}
Related macros: unreachable!() (a branch that cannot happen), todo!() and unimplemented!() (placeholders),
assert!, assert_eq!, assert_ne! and their debug_ variants (checked only in debug builds).
When to panic
The Book’s guidance, condensed:
-
Panic when continuing would be unsafe or meaningless: a broken invariant inside your own code, a contract violation that the caller could have avoided (a documented "panics if" precondition), or bad state in tests, examples and prototypes.
-
Return
Resultwhen failure is an expected possibility the caller can handle: parsing user input, I/O, network calls, lookups, anything depending on the outside world. -
Libraries should almost never panic on bad input; document any panics in a
# Panicsdoc section.
Unwinding or aborting
Instead of unwinding, a profile can make panics abort the process immediately, with no destructors run:
[profile.release]
panic = "abort" # smaller binaries, no unwinding tables; panics end the whole process
With panic = "abort", std::panic::catch_unwind can no longer catch anything, and a panic in any thread ends the
program. That is often acceptable for CLI tools and is common in embedded code; servers usually keep unwinding so that
one panicking request does not kill the process (axum, for example, can turn a handler panic into a 500 response with
CatchPanicLayer). Unwinding across an FFI boundary into C is undefined behaviour unless the function uses the
"C-unwind" ABI; see FFI & C Interop.
Source: The Rust Reference: Panic
Recoverable errors: designing an error type
An error type is any type; idiomatically it is an enum with one variant per failure cause, which implements
std::fmt::Debug, std::fmt::Display and std::error::Error. Display is the human-readable message (lowercase,
no trailing punctuation, per the API Guidelines); Error::source() returns the underlying cause, so callers and
reporters can print the whole chain.
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum ImportError {
Io(std::io::Error),
BadYear { line: usize, source: ParseIntError },
MissingTitle { line: usize },
}
impl fmt::Display for ImportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ImportError::Io(_) => write!(f, "could not read the import file"),
ImportError::BadYear { line, .. } => write!(f, "invalid year on line {line}"),
ImportError::MissingTitle { line } => write!(f, "missing title on line {line}"),
}
}
}
impl Error for ImportError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ImportError::Io(e) => Some(e),
ImportError::BadYear { source, .. } => Some(source),
ImportError::MissingTitle { .. } => None,
}
}
}
// From impls let `?` convert automatically.
impl From<std::io::Error> for ImportError {
fn from(e: std::io::Error) -> Self {
ImportError::Io(e)
}
}
fn parse_line(n: usize, line: &str) -> Result<(String, u16), ImportError> {
let (title, year) = line.split_once(';').ok_or(ImportError::MissingTitle { line: n })?;
let year = year.trim().parse().map_err(|source| ImportError::BadYear { line: n, source })?;
Ok((title.trim().to_owned(), year))
}
fn import(path: &str) -> Result<Vec<(String, u16)>, ImportError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ImportError via From
text.lines().enumerate().map(|(i, l)| parse_line(i + 1, l)).collect()
}
fn report(err: &dyn Error) {
eprint!("error: {err}");
let mut cause = err.source();
while let Some(c) = cause {
eprint!(": {c}");
cause = c.source();
}
eprintln!();
}
fn main() {
if let Err(e) = parse_line(3, "Dune; 19x5") {
report(&e); // error: invalid year on line 3: invalid digit found in string
}
if let Err(e) = import("/no/such/file.csv") {
report(&e); // error: could not read the import file: No such file or directory (os error 2)
}
}
Source: std::error::Error
Two conventions keep error chains readable: a variant that wraps a cause should not repeat the cause’s message in its
own Display (the reporter prints both), and source() should return the wrapped error.
From conversions power ?
When ? returns early with an error, it calls From::from on it, converting the inner function’s error type into the
outer function’s. Implementing From<LowLevelError> for MyError is therefore all it takes to make ? work across
layers, as import shows above. Without a matching From impl, you get error
E0277 (the message says ? could not convert the error); add the
impl, or convert explicitly with map_err.
Source: Rust by Example: Wrapping errors
Box<dyn Error + Send + Sync>
When a function can fail in many unrelated ways and callers only need to report the error, a boxed trait object saves
defining an enum. Every error type converts into Box<dyn Error + Send + Sync> through a blanket From impl, and so
do String and &str:
use std::error::Error;
type BoxError = Box<dyn Error + Send + Sync + 'static>;
fn load_limit(text: &str) -> Result<u8, BoxError> {
let n: u8 = text.trim().parse()?; // ParseIntError -> BoxError
if n == 0 {
return Err("limit must be positive".into()); // &str -> BoxError
}
Ok(n)
}
fn main() {
println!("{:?}", load_limit("5"));
match load_limit("0") {
Ok(_) => {}
Err(e) => println!("error: {e}"),
}
}
Source: Rust by Example: Boxing errors
+ Send + Sync+ makes the box usable across threads and async tasks (tower and hyper use exactly this alias,
BoxError). The cost: callers can no longer match on causes without downcasting (e.downcast_ref::<ParseIntError>();
see Any & Downcasting).
thiserror for libraries, anyhow for applications
Writing Display, Error and From by hand is repetitive. Two crates by the same author cover the two common cases:
| thiserror | anyhow | |
|---|---|---|
What it is |
a derive macro that writes |
a single opaque error type,
|
Callers can |
|
print, log, or downcast |
Use in |
libraries and domain layers, where callers need to distinguish causes |
binaries, CLIs, |
Appears in public APIs |
yes |
rarely — it hides the error’s type |
Azam (Advanced Rust, chapter 1) recommends the same split. With thiserror 2, the import error above shrinks to:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ImportError {
#[error("could not read the import file")]
Io(#[from] std::io::Error), // #[from] generates From and source()
#[error("invalid year on line {line}")]
BadYear { line: usize, #[source] source: std::num::ParseIntError },
#[error("missing title on line {line}")]
MissingTitle { line: usize },
#[error(transparent)] // forward Display and source() to the inner error
Catalog(#[from] shelf_core::CatalogError),
}
fn main() {
let e = ImportError::MissingTitle { line: 4 };
println!("{e}"); // missing title on line 4
}
Source: thiserror 2.0 documentation
In an application, anyhow::Result<T> (an alias for Result<T, anyhow::Error>) accepts any error with ?, context
and with_context add a message layer, and bail! and ensure! return early with an ad-hoc error. Printing with
{:#} shows the whole chain on one line, and {:?} shows it on several lines with a backtrace if enabled:
use anyhow::{Context, Result, bail, ensure};
fn read_config(path: &str) -> Result<u16> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading config {path}"))?;
let port: u16 = text.trim().parse().context("config must contain a port number")?;
ensure!(port >= 1024, "port {port} is privileged");
if port == 8443 {
bail!("port 8443 is reserved for the admin console");
}
Ok(port)
}
fn main() {
match read_config("/etc/shelf/port.conf") {
Ok(port) => println!("listening on {port}"),
Err(e) => eprintln!("error: {e:#}"),
// error: reading config /etc/shelf/port.conf: No such file or directory (os error 2)
}
}
Source: anyhow 1.0 documentation
Libraries that use thiserror and applications that use anyhow compose well: anyhow wraps the library’s typed error,
and e.downcast_ref::<ImportError>() recovers it where needed. Alternatives with different trade-offs include
snafu (context selectors), miette (rich diagnostics with source snippets) and eyre/color-eyre (anyhow-like with
customisable reports).
Result<Option<T>> or Option<Result<T>>?
Pick the nesting that matches the meaning (see
Option, Result & Combinators for transpose):
-
Result<Option<Book>, DbError>— a lookup that ran successfully and may have found nothing. Callers propagate the error with?and then handleNone. -
Option<Result<Year, ParseError>>— an optional input that must be valid if present; usually transposed toResult<Option<Year>, ParseError>so?can be applied.
Do not turn "not found" into an error variant if callers routinely treat it as normal; do not turn a real failure into
None, which loses the reason.
Errors in main
main can return Result<(), E> for any E: Debug. Returning Err prints the error’s Debug representation and
exits with code 1. For precise exit codes, return std::process::ExitCode, or implement the Termination trait:
use std::process::ExitCode;
fn run() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
anyhow::ensure!(args.len() <= 3, "usage: shelf <command> [arg]");
println!("ok");
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}"); // Display with the chain, not Debug
ExitCode::from(2)
}
}
}
Source: std::process::ExitCode
The shelf CLI uses this pattern: run() returns anyhow::Result<()>, and main prints a one-line message and
chooses the exit code, which gives users better output than the Debug dump of fn main() -> Result<...>.
Errors at the HTTP boundary
Web frameworks need errors to become responses. In axum, handlers return Result<T, AppError> where AppError is a
thiserror enum that implements IntoResponse, mapping each variant to a status code and a Problem Details body, and
logging internal details instead of exposing them. That pattern is covered in
Axum: Error Handling.
Where the books are outdated
Azam (Advanced Rust, chapter 1) covers recoverable versus unrecoverable errors, ? chaining, multiple error types,
transpose, and the anyhow/thiserror split, in agreement with the official documentation. Since it was written,
thiserror reached version 2 (late 2024; typical derive usage is unchanged, and it now supports no_std crates),
and std::error::Error has been available in core as core::error::Error since 1.81, which lets no_std libraries implement it. Yoon’s Beginning Axum (chapter 3)
uses unwrap() throughout request handlers; the axum section replaces those with ? and an AppError.
References
-
Rust by Example: Error handling (panic, abort and unwind, multiple error types, boxing, wrapping errors)
-
Rust API Guidelines: Error types are meaningful and well-behaved