Skip to content
Codeloom
Rust

Rust Error Handling with anyhow and thiserror

Master Rust error handling with anyhow for applications and thiserror for libraries. Practical patterns, conversions, and context-rich error reporting.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • When to use anyhow vs thiserror
  • Create custom error types with thiserror derive macros
  • Use anyhow for ergonomic error propagation in applications
  • Add context to errors for better debugging

Prerequisites

  • Rust basics — see /blog/rust-variables-and-types
  • Result and Option types — see /blog/rust-error-handling-with-result

Rust’s standard library gives you Result<T, E> and the ? operator for error handling, but building real applications quickly reveals gaps. Defining custom error types requires boilerplate. Propagating errors across different libraries means writing From implementations. Adding context to errors so you can debug failures in production requires even more code.

Two crates solve these problems cleanly: thiserror for defining error types and anyhow for propagating them. They work together or independently, and they are the de facto standard in the Rust ecosystem.

The Rule of Thumb

  • Libraries should use thiserror to define structured error types that callers can match on.
  • Applications should use anyhow to collect and propagate errors from different sources with minimal boilerplate.

This is not a hard rule — some applications use thiserror, some libraries use anyhow internally — but it is a useful starting point.

Setting Up

Add both to your project:

[dependencies]
anyhow = "1"
thiserror = "2"

thiserror: Custom Error Types Without Boilerplate

Without thiserror, defining an error enum that implements Display, Error, and From is tedious:

use std::fmt;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
    NotFound(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
            AppError::NotFound(name) => write!(f, "{} not found", name),
        }
    }
}

impl std::error::Error for AppError {}

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

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}

That is 30 lines of glue code. With thiserror, the same thing is:

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("{0} not found")]
    NotFound(String),
}

The #[error("...")] attribute generates Display. The #[from] attribute generates From implementations. The Error trait is derived automatically.

Using thiserror in Practice

use std::fs;
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("failed to read config file: {0}")]
    ReadFile(#[from] std::io::Error),

    #[error("invalid port number: {0}")]
    InvalidPort(#[from] std::num::ParseIntError),

    #[error("missing required field: {field}")]
    MissingField { field: String },

    #[error("port {port} is out of range (1-65535)")]
    PortOutOfRange { port: u32 },
}

struct Config {
    host: String,
    port: u16,
}

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let content = fs::read_to_string(path)?; // auto-converts io::Error

    let mut host = None;
    let mut port = None;

    for line in content.lines() {
        let parts: Vec<&str> = line.splitn(2, '=').collect();
        if parts.len() != 2 { continue; }

        match parts[0].trim() {
            "host" => host = Some(parts[1].trim().to_string()),
            "port" => {
                let p: u32 = parts[1].trim().parse()?; // auto-converts ParseIntError
                if p == 0 || p > 65535 {
                    return Err(ConfigError::PortOutOfRange { port: p });
                }
                port = Some(p as u16);
            }
            _ => {}
        }
    }

    Ok(Config {
        host: host.ok_or(ConfigError::MissingField {
            field: "host".to_string(),
        })?,
        port: port.ok_or(ConfigError::MissingField {
            field: "port".to_string(),
        })?,
    })
}

Callers can pattern-match on ConfigError to handle specific cases:

fn main() {
    match load_config("app.conf") {
        Ok(config) => println!("Loaded: {}:{}", config.host, config.port),
        Err(ConfigError::MissingField { field }) => {
            eprintln!("Please add '{}' to your config file", field);
        }
        Err(ConfigError::ReadFile(e)) if e.kind() == std::io::ErrorKind::NotFound => {
            eprintln!("Config file not found, using defaults");
        }
        Err(e) => eprintln!("Config error: {}", e),
    }
}

Advanced thiserror Features

You can use #[source] to set the error source without generating a From impl, and #[backtrace] to capture backtraces:

use thiserror::Error;

#[derive(Debug, Error)]
enum DatabaseError {
    #[error("connection failed to {host}:{port}")]
    ConnectionFailed {
        host: String,
        port: u16,
        #[source]
        cause: std::io::Error,
    },

    #[error("query failed: {query}")]
    QueryFailed {
        query: String,
        #[source]
        cause: Box<dyn std::error::Error + Send + Sync>,
    },
}

The #[source] attribute links the error chain so tools like anyhow can print the full cause chain.

anyhow: Ergonomic Error Propagation

While thiserror helps you define errors, anyhow helps you propagate them. The anyhow::Result<T> type is an alias for Result<T, anyhow::Error>, where anyhow::Error can hold any error type that implements std::error::Error.

use anyhow::{Context, Result};
use std::fs;

fn read_config() -> Result<String> {
    let content = fs::read_to_string("config.toml")
        .context("failed to read config.toml")?;
    Ok(content)
}

fn get_port() -> Result<u16> {
    let config = read_config()?;
    let port: u16 = config
        .lines()
        .find(|l| l.starts_with("port"))
        .context("port not found in config")?
        .split('=')
        .nth(1)
        .context("invalid port line format")?
        .trim()
        .parse()
        .context("port is not a valid number")?;
    Ok(port)
}

fn main() -> Result<()> {
    let port = get_port()?;
    println!("Server starting on port {}", port);
    Ok(())
}

When this fails, you get a rich error chain:

Error: port is not a valid number

Caused by:
    invalid digit found in string

Adding Context

The .context() method is the killer feature of anyhow. It wraps an error with a human-readable message that explains what you were trying to do:

use anyhow::{Context, Result};
use std::fs;

fn process_user_file(user_id: u64) -> Result<Vec<String>> {
    let path = format!("/data/users/{}.json", user_id);

    let content = fs::read_to_string(&path)
        .with_context(|| format!("failed to read user file for user {}", user_id))?;

    let lines: Vec<String> = serde_json::from_str(&content)
        .with_context(|| format!("failed to parse JSON for user {}", user_id))?;

    Ok(lines)
}

Use .context("static string") when the message is fixed, and .with_context(|| format!(...)) when you need to include dynamic values. The closure version avoids formatting the string when there is no error.

anyhow::bail! and anyhow::ensure!

For early returns with error messages, anyhow provides convenient macros:

use anyhow::{bail, ensure, Result};

fn validate_age(age: i32) -> Result<()> {
    ensure!(age > 0, "age must be positive, got {}", age);
    ensure!(age < 150, "age {} is unrealistic", age);
    Ok(())
}

fn process_command(cmd: &str) -> Result<String> {
    match cmd {
        "hello" => Ok("Hello, World!".to_string()),
        "time" => Ok(format!("Current timestamp: {}", std::time::UNIX_EPOCH.elapsed()?.as_secs())),
        other => bail!("unknown command: {}", other),
    }
}

fn main() -> Result<()> {
    validate_age(25)?;
    let response = process_command("hello")?;
    println!("{}", response);
    Ok(())
}

bail! is equivalent to return Err(anyhow::anyhow!(...)). ensure! is equivalent to if !condition { bail!(...) }.

Combining anyhow and thiserror

The most powerful pattern uses both crates together. Define your library errors with thiserror, and use anyhow in your application code:

// In your library crate
use thiserror::Error;

#[derive(Debug, Error)]
pub enum StorageError {
    #[error("key not found: {0}")]
    NotFound(String),

    #[error("storage is full (capacity: {capacity})")]
    Full { capacity: usize },

    #[error("corruption detected in block {block}")]
    Corrupted { block: u64 },
}

pub struct Storage {
    data: std::collections::HashMap<String, Vec<u8>>,
    capacity: usize,
}

impl Storage {
    pub fn new(capacity: usize) -> Self {
        Storage {
            data: std::collections::HashMap::new(),
            capacity,
        }
    }

    pub fn get(&self, key: &str) -> Result<&[u8], StorageError> {
        self.data
            .get(key)
            .map(|v| v.as_slice())
            .ok_or_else(|| StorageError::NotFound(key.to_string()))
    }

    pub fn put(&mut self, key: String, value: Vec<u8>) -> Result<(), StorageError> {
        if self.data.len() >= self.capacity {
            return Err(StorageError::Full {
                capacity: self.capacity,
            });
        }
        self.data.insert(key, value);
        Ok(())
    }
}
// In your application crate
use anyhow::{Context, Result};

fn run_app() -> Result<()> {
    let mut storage = Storage::new(100);

    storage
        .put("user:1".into(), b"Alice".to_vec())
        .context("failed to store user")?;

    let user = storage
        .get("user:1")
        .context("failed to load user")?;

    let name = std::str::from_utf8(user)
        .context("user data is not valid UTF-8")?;

    println!("User: {}", name);
    Ok(())
}

fn main() {
    if let Err(e) = run_app() {
        eprintln!("Error: {:#}", e); // {:#} prints the full chain
        std::process::exit(1);
    }
}

Downcasting anyhow Errors

Sometimes application code needs to check if an anyhow error is a specific type. Use downcast_ref:

use anyhow::Result;

fn handle_result(result: Result<()>) {
    if let Err(e) = result {
        if let Some(storage_err) = e.downcast_ref::<StorageError>() {
            match storage_err {
                StorageError::NotFound(key) => {
                    println!("Creating missing key: {}", key);
                }
                StorageError::Full { capacity } => {
                    println!("Storage full at {} items, triggering cleanup", capacity);
                }
                _ => eprintln!("Storage error: {}", storage_err),
            }
        } else {
            eprintln!("Unexpected error: {:#}", e);
        }
    }
}

Error Handling Patterns

Converting Between Error Types

When you have multiple error types and need to unify them without anyhow:

use thiserror::Error;

#[derive(Debug, Error)]
enum ApiError {
    #[error("database error: {0}")]
    Database(#[from] DatabaseError),

    #[error("validation error: {0}")]
    Validation(#[from] ValidationError),

    #[error("internal error: {0}")]
    Internal(String),
}

#[derive(Debug, Error)]
enum DatabaseError {
    #[error("connection lost")]
    ConnectionLost,
}

#[derive(Debug, Error)]
enum ValidationError {
    #[error("field {field} is required")]
    Required { field: String },
}

The #[from] attributes generate From implementations, so ? converts automatically.

Attaching Structured Data to Errors

use thiserror::Error;

#[derive(Debug, Error)]
#[error("request failed: {method} {url} returned {status}")]
struct HttpError {
    method: String,
    url: String,
    status: u16,
    body: String,
}

fn make_request(url: &str) -> Result<String, HttpError> {
    // Simulated failure
    Err(HttpError {
        method: "GET".to_string(),
        url: url.to_string(),
        status: 503,
        body: "Service Unavailable".to_string(),
    })
}

Wrapping Up

Rust error handling follows a clear pattern once you know the tools:

  • Use thiserror to define error enums with derived Display, Error, and From implementations. This is ideal for libraries where callers need to match on specific error variants.
  • Use anyhow for application code where you care about reporting errors, not matching on them. The .context() method is essential for debuggable error messages.
  • Combine both: thiserror for your error types, anyhow for propagation and context.
  • Use bail! and ensure! for concise early returns with error messages.
  • Format with {:#} to print the full error chain.

Start with anyhow in your application, introduce thiserror when you need callers to distinguish between error kinds, and you will have clean, maintainable error handling throughout your Rust codebase.