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

flowchart TD A[Something went wrong] --> B{Can a correct program
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 Result when 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 # Panics doc 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.

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)
    }
}

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.

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}"),
    }
}

+ 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 Display, Error and From for your enum

a single opaque error type, anyhow::Error, that wraps any error plus context

Callers can

match on variants

print, log, or downcast

Use in

libraries and domain layers, where callers need to distinguish causes

binaries, CLIs, main, test helpers, top-level application code

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
}

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)
    }
}

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 handle None.

  • Option<Result<Year, ParseError>> — an optional input that must be valid if present; usually transposed to Result<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)
        }
    }
}

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.