Skip to main content

Error Handling

TL;DR Rust has no exceptions. Recoverable errors use Result<T, E>; unrecoverable errors use panic!. The ? operator propagates errors up the call stack without boilerplate. Libraries define typed errors with thiserror; applications use anyhow for flexible error context.

Result and ?

use std::fs;
use std::io;

fn read_config(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?; // ? = early return on Err
Ok(content)
}

? desugars to: if Err, convert the error with From::from and return early.

Defining Library Errors with thiserror

For library crates, define a concrete error type. thiserror generates Display and Error impl from derive macros.

# Cargo.toml
[dependencies]
thiserror = "2"
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),

#[error("not found: {resource} with id {id}")]
NotFound { resource: &'static str, id: u64 },

#[error("invalid input: {0}")]
Validation(String),
}

#[from] implements From<sqlx::Error> for AppError, so ? converts automatically.

Application Error Context with anyhow

For binary crates and application code, anyhow::Error is a type-erased error that supports adding context messages.

[dependencies]
anyhow = "1"
use anyhow::{Context, Result};

fn load_user(id: u64) -> Result<User> {
let row = db.query_one(id)
.context("failed to query user table")?;

let user = parse_user(row)
.with_context(|| format!("failed to parse user {id}"))?;

Ok(user)
}

anyhow::Result<T> is shorthand for Result<T, anyhow::Error>.

Rule of thumb:

  • Library crate → thiserror with typed errors (callers can match on variants)
  • Application binary → anyhow for ergonomic context propagation
  • Don't mix: an anyhow::Error erases type information, so libraries shouldn't return it

Error Conversion

From enables automatic conversion via ?:

impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}

fn load() -> Result<(), AppError> {
let _ = fs::read("file")?; // io::Error → AppError::Io automatically
Ok(())
}

Panics

Use panic! only for logic bugs — states that should never happen. Never for expected error conditions.

// ok: programmer error (index invariant broken)
fn get(v: &[i32], i: usize) -> i32 {
v[i] // panics on out-of-bounds
}

// bad: network error is expected, not a bug
fn fetch() -> String {
reqwest::get(url).unwrap() // don't do this in production
}

In tests and prototypes, .unwrap() / .expect("msg") are fine. In production library code, always return Result.

Collecting Errors

Collect a Vec<Result<T, E>> into a Result<Vec<T>, E>:

let results: Result<Vec<_>, _> = inputs
.iter()
.map(|x| parse(x))
.collect();

Fails fast: stops at the first error. For all-errors collection, use a Vec<Result<...>> and filter manually.

Ownership ModelWhy Result requires explicit handling — no implicit exception propagation