Skip to content
Codeloom
Rust

Rust Error Handling with thiserror and anyhow

Learn practical Rust error handling using thiserror for library errors and anyhow for application errors, with real-world patterns and examples.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • When to use thiserror vs anyhow
  • How to define custom error types with thiserror
  • How anyhow simplifies application error handling
  • Patterns for converting between error types

Prerequisites

  • Basic Rust knowledge
  • Understanding of Result and the ? operator

Rust’s standard library gives you Result<T, E> and the ? operator, but it leaves the error type design up to you. Two crates dominate the ecosystem: thiserror for defining structured error types in libraries, and anyhow for quick, flexible error handling in applications. This guide shows you how and when to use each.

The Problem with Manual Error Types

Without helper crates, defining a proper error type requires implementing Display, Error, and From for every source error:

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

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
    Custom(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::Custom(msg) => write!(f, "{msg}"),
        }
    }
}

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 a lot of boilerplate for three variants. thiserror eliminates it.

thiserror: Structured Errors for Libraries

thiserror is a derive macro that generates Display, Error, and From implementations from attributes:

use thiserror::Error;

#[derive(Debug, Error)]
enum DatabaseError {
    #[error("connection failed: {0}")]
    Connection(String),

    #[error("query failed: {query}")]
    Query {
        query: String,
        #[source]
        cause: sqlx::Error,
    },

    #[error("record not found: {table}/{id}")]
    NotFound { table: String, id: u64 },

    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error("migration failed")]
    Migration(#[from] MigrationError),
}

#[derive(Debug, Error)]
#[error("migration {version} failed: {reason}")]
struct MigrationError {
    version: u32,
    reason: String,
}

Key attributes:

  • #[error("...")] generates the Display implementation. You can interpolate fields by name or position.
  • #[from] generates a From implementation and marks the field as the error source.
  • #[source] marks a field as the error source without generating From.
  • #[error(transparent)] delegates both Display and source() to the inner error.

Using thiserror in a Library

use thiserror::Error;
use std::io;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("failed to read config file: {path}")]
    ReadFile {
        path: String,
        #[source]
        cause: io::Error,
    },

    #[error("invalid config: {0}")]
    Invalid(String),

    #[error("missing required field: {0}")]
    MissingField(&'static str),
}

pub fn load_config(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
        path: path.to_string(),
        cause: e,
    })?;

    if content.is_empty() {
        return Err(ConfigError::Invalid("file is empty".into()));
    }

    parse_config(&content)
}

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

fn parse_config(content: &str) -> Result<Config, ConfigError> {
    // Simplified parsing
    let host = content
        .lines()
        .find(|l| l.starts_with("host="))
        .map(|l| l.trim_start_matches("host=").to_string())
        .ok_or(ConfigError::MissingField("host"))?;

    Ok(Config { host, port: 8080 })
}

Callers can match on specific variants and access structured information:

fn main() {
    match load_config("app.conf") {
        Ok(config) => println!("Host: {}", config.host),
        Err(ConfigError::MissingField(field)) => {
            eprintln!("Add '{field}' to your config file");
        }
        Err(e) => eprintln!("Config error: {e}"),
    }
}

anyhow: Flexible Errors for Applications

anyhow takes a different approach. Instead of defining error types, you use anyhow::Result<T> which wraps any error implementing std::error::Error. It is designed for application code where you care about displaying errors, not matching on them.

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<String> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {path}"))?;
    Ok(content)
}

fn parse_port(s: &str) -> Result<u16> {
    let port: u16 = s.parse()
        .with_context(|| format!("invalid port number: {s}"))?;

    anyhow::ensure!(port > 0, "port must be positive, got {port}");

    Ok(port)
}

fn main() -> Result<()> {
    let content = read_config("app.conf")?;
    let port = parse_port("8080")?;
    println!("Port: {port}");
    Ok(())
}

Key features of anyhow:

  • anyhow::Result<T> is an alias for Result<T, anyhow::Error>. It accepts any error type.
  • .context() and .with_context() add human-readable context to errors, building a chain.
  • anyhow::bail!("msg") is shorthand for return Err(anyhow::anyhow!("msg")).
  • anyhow::ensure!(condition, "msg") is like assert! but returns an error instead of panicking.

Error Chain Display

When you stack contexts, anyhow shows the full chain:

use anyhow::{Context, Result};

fn connect_db(url: &str) -> Result<()> {
    Err(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused"))
        .with_context(|| format!("failed to connect to database at {url}"))?;
    Ok(())
}

fn init_app() -> Result<()> {
    connect_db("postgres://localhost/mydb")
        .context("application initialization failed")?;
    Ok(())
}

fn main() {
    if let Err(e) = init_app() {
        // Prints the full chain:
        // Error: application initialization failed
        //
        // Caused by:
        //    0: failed to connect to database at postgres://localhost/mydb
        //    1: connection refused
        eprintln!("Error: {e:?}");
    }
}

When to Use Which

The guideline is simple:

Use thiserror in libraries. Library consumers need to match on error variants, inspect structured fields, and handle specific failure modes. thiserror makes defining these types easy without losing type information.

Use anyhow in applications. Application code typically logs or displays errors rather than matching on them. anyhow lets you propagate any error with ? and add context without defining custom types.

You can use both together. A library defines errors with thiserror, and the application wraps them with anyhow:

// In the library
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("rate limited, retry after {retry_after}s")]
    RateLimited { retry_after: u64 },

    #[error("authentication failed")]
    Unauthorized,

    #[error("request failed: {0}")]
    Network(#[from] reqwest::Error),
}

// In the application
use anyhow::{Context, Result};

fn fetch_data() -> Result<String> {
    let response = api_client::get("/data")
        .context("failed to fetch data from API")?;
    Ok(response)
}

Downcasting with anyhow

When you need to handle specific error types from anyhow, use downcasting:

use anyhow::Result;

fn process() -> Result<()> {
    // ... some operation that might fail
    Ok(())
}

fn main() {
    if let Err(e) = process() {
        // Try to downcast to a specific error type
        if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
            match io_err.kind() {
                std::io::ErrorKind::NotFound => eprintln!("File not found"),
                std::io::ErrorKind::PermissionDenied => eprintln!("Permission denied"),
                _ => eprintln!("IO error: {io_err}"),
            }
        } else {
            eprintln!("Error: {e:?}");
        }
    }
}

Wrapping Up

Rust’s error handling ecosystem is built on a clean split: thiserror generates the boilerplate for structured error types that library consumers can match on, while anyhow provides a flexible, context-rich error type for application code where display matters more than matching. Use thiserror when you are writing a library, anyhow when you are writing an application, and combine them freely at the boundary between the two.