In the previous tutorial, we learned testing in Rust. Now we take error handling to the next level with thiserror and anyhow — the two crates that every production Rust project uses.

In Tutorial #8, we learned the basics: Result, Option, the ? operator, and custom error types. That works fine for small programs. But as your project grows, writing Display, From, and Error implementations by hand gets tedious. That is where thiserror and anyhow come in.

The Problem with Manual Error Types

In Tutorial #8, we wrote custom errors like this:

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

#[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 boilerplate for three error variants. Every time you add a new variant, you update three places: the enum, the Display impl, and the From impl. This does not scale.

thiserror — Derive Custom Error Types

The thiserror crate generates all that boilerplate with derive macros. Add it to Cargo.toml:

[dependencies]
thiserror = "2"

Now rewrite the same error type:

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

That is it. Ten lines instead of thirty. The #[error("...")] attribute generates the Display implementation. The #[from] attribute generates the From implementation. The Error trait is derived automatically.

How #[error] Works

The #[error("...")] attribute uses format string syntax:

#[derive(Debug, Error)]
enum ConfigError {
    // {0} refers to the first field (tuple variant)
    #[error("file not found: {0}")]
    FileNotFound(String),

    // Named fields use {field_name}
    #[error("invalid value '{value}' for key '{key}'")]
    InvalidValue { key: String, value: String },

    // .source() on inner errors
    #[error("failed to read config")]
    ReadFailed(#[from] std::io::Error),

    // No fields
    #[error("configuration is empty")]
    Empty,
}

Each variant gets its own error message. The macro generates Display::fmt for you.

How #[from] Works

The #[from] attribute generates a From implementation:

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

    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),
}

Now the ? operator converts these error types automatically:

fn load_config(path: &str) -> Result<Config, DataError> {
    let content = std::fs::read_to_string(path)?;  // io::Error → DataError::Io
    let config: Config = serde_json::from_str(&content)?;  // serde_json::Error → DataError::Json
    Ok(config)
}

Each ? calls the generated From implementation. No manual conversion needed.

The #[source] Attribute

Use #[source] when you want to keep the original error as a source but do not want automatic From conversion:

#[derive(Debug, Error)]
enum ApiError {
    #[error("request failed: {message}")]
    RequestFailed {
        message: String,
        #[source]
        source: reqwest::Error,
    },
}

The difference between #[from] and #[source]:

  • #[from] generates From<T> + sets the error source
  • #[source] only sets the error source (no From)

Use #[source] when you need to attach extra data (like a message) alongside the original error.

Transparent Errors

Sometimes you want to wrap a single error without changing its message:

#[derive(Debug, Error)]
#[error(transparent)]
struct MyError(#[from] anyhow::Error);

The transparent attribute forwards Display and source() to the inner error. This is useful when you want a named wrapper around another error type.

anyhow — Quick and Easy Error Handling

The anyhow crate takes a different approach. Instead of defining custom error types, you use anyhow::Error as a catch-all:

[dependencies]
anyhow = "1"
use anyhow::Result;

fn read_config(path: &str) -> Result<String> {
    let content = std::fs::read_to_string(path)?;
    Ok(content)
}

fn parse_port(input: &str) -> Result<u16> {
    let port: u16 = input.parse()?;
    if port < 1024 {
        anyhow::bail!("port {} is reserved", port);
    }
    Ok(port)
}

anyhow::Result<T> is shorthand for Result<T, anyhow::Error>. The anyhow::Error type can hold any error that implements std::error::Error. The ? operator converts any error automatically. No From implementations needed.

bail! — Return an Error Immediately

The bail! macro creates an error and returns it:

use anyhow::{bail, Result};

fn validate_username(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("username cannot be empty");
    }
    if name.len() > 20 {
        bail!("username '{}' is too long (max 20 characters)", name);
    }
    Ok(())
}

bail! is equivalent to return Err(anyhow::anyhow!("...")) but shorter.

ensure! — Assert with Error

The ensure! macro is like assert! but returns an error instead of panicking:

use anyhow::{ensure, Result};

fn divide(a: f64, b: f64) -> Result<f64> {
    ensure!(b != 0.0, "cannot divide by zero");
    ensure!(a.is_finite(), "input must be a finite number");
    Ok(a / b)
}

If the condition is false, ensure! returns an Err with the given message.

context() — Add Context to Errors

This is the killer feature of anyhow. Add context to explain why an operation failed:

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context(format!("failed to read config file '{}'", path))?;

    let config: Config = serde_json::from_str(&content)
        .context("failed to parse config as JSON")?;

    Ok(config)
}

Without context, the error says: “No such file or directory”. With context, it says: “failed to read config file ‘config.toml’: No such file or directory”. The context explains what your code was trying to do. The original error explains what went wrong.

with_context() — Lazy Context

Use with_context when building the context message is expensive:

let data = std::fs::read(path)
    .with_context(|| format!("failed to read file at '{}'", path))?;

The closure only runs if there is an error. Use this when the context string involves formatting or computation.

Printing the Full Error Chain

Anyhow errors keep the full chain of context. Print it with {:?}:

fn main() {
    if let Err(e) = run() {
        // Short message
        eprintln!("Error: {}", e);

        // Full chain with context
        eprintln!("Error: {:?}", e);
    }
}

The debug output ({:?}) shows every layer of context:

Error: failed to load application

Caused by:
    0: failed to read config file 'config.toml'
    1: No such file or directory (os error 2)

This is invaluable for debugging. You can trace exactly what your code was doing when the error happened.

thiserror vs anyhow — When to Use Which

This is the most important decision. The Rust community has a clear convention:

Use CaseCrateWhy
LibrariesthiserrorCallers need to match on specific errors
ApplicationsanyhowYou just need to report errors to the user
Library public APIthiserrorCustom error types are part of your API
Internal functionsanyhowQuick error propagation
CLI toolsanyhowPrint error and exit
Web APIsBoththiserror for API errors, anyhow for internal

Libraries should use thiserror because the code that calls your library needs to handle different error cases:

// Library code — use thiserror
#[derive(Debug, Error)]
pub enum DatabaseError {
    #[error("connection failed: {0}")]
    ConnectionFailed(String),

    #[error("query failed: {0}")]
    QueryFailed(String),

    #[error("record not found")]
    NotFound,
}

Applications should use anyhow because you are the final consumer. You just need to display errors to the user:

// Application code — use anyhow
use anyhow::{Context, Result};

fn main() -> Result<()> {
    let config = load_config("config.toml")
        .context("failed to load configuration")?;
    start_server(config)
        .context("failed to start server")?;
    Ok(())
}

Using Both Together

In practice, many projects use both crates. Use thiserror for your public error types and anyhow inside your application logic:

use anyhow::{Context, Result};
use thiserror::Error;

// Public error type for your API layer
#[derive(Debug, Error)]
pub enum ApiError {
    #[error("not found: {0}")]
    NotFound(String),

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

    #[error("internal server error")]
    Internal(#[from] anyhow::Error),
}

// Internal function uses anyhow
fn fetch_user_data(id: u64) -> Result<UserData> {
    let raw = read_from_cache(id)
        .context("cache lookup failed")?;
    let parsed = parse_user(raw)
        .context("failed to parse user data")?;
    Ok(parsed)
}

// API handler converts anyhow::Error to ApiError
fn get_user(id: u64) -> Result<UserData, ApiError> {
    let data = fetch_user_data(id)
        .map_err(ApiError::Internal)?;
    Ok(data)
}

The internal functions use anyhow::Result for convenience. The API boundary converts to specific ApiError variants that map to HTTP status codes.

Practical Example: Config Loader

Here is a real-world example that loads and validates a configuration file:

use anyhow::{bail, Context, Result};
use serde::Deserialize;
use thiserror::Error;

#[derive(Debug, Deserialize)]
struct Config {
    host: String,
    port: u16,
    database_url: String,
}

#[derive(Debug, Error)]
enum ConfigError {
    #[error("config file not found: {path}")]
    NotFound { path: String },

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

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context(format!("failed to read '{}'", path))?;

    let config: Config = serde_json::from_str(&content)
        .context("config file is not valid JSON")?;

    validate_config(&config)?;
    Ok(config)
}

fn validate_config(config: &Config) -> Result<()> {
    if config.host.is_empty() {
        bail!("host cannot be empty");
    }
    if config.port == 0 {
        bail!("port must be greater than 0");
    }
    if !config.database_url.starts_with("postgres://")
        && !config.database_url.starts_with("sqlite:")
    {
        bail!(
            "unsupported database URL: '{}' (use postgres:// or sqlite:)",
            config.database_url
        );
    }
    Ok(())
}

fn main() {
    match load_config("config.json") {
        Ok(config) => println!("Loaded: {}:{}", config.host, config.port),
        Err(e) => {
            eprintln!("Error: {}", e);
            // Print full chain for debugging
            eprintln!("\nFull error chain:");
            for (i, cause) in e.chain().enumerate() {
                eprintln!("  {}: {}", i, cause);
            }
            std::process::exit(1);
        }
    }
}

Key patterns:

  • context() on every ? to explain what the code was doing
  • bail! for validation failures
  • e.chain() to print the full error chain
  • Exit with a non-zero code on error

Error Handling in main()

You can return Result from main():

use anyhow::Result;

fn main() -> Result<()> {
    let config = load_config("config.json")?;
    println!("Server starting on {}:{}", config.host, config.port);
    Ok(())
}

When main() returns Err, Rust prints the error using Debug format and exits with code 1. This is fine for quick scripts, but for better output, handle the error yourself:

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {:#}", e);
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let config = load_config("config.json")?;
    println!("Server starting on {}:{}", config.host, config.port);
    Ok(())
}

The {:#} format shows the error with context on a single line: “failed to read ‘config.json’: No such file or directory”.

Downcasting Errors

With anyhow, you can check the original error type:

fn handle_error(err: &anyhow::Error) {
    if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
        match io_err.kind() {
            std::io::ErrorKind::NotFound => println!("File not found"),
            std::io::ErrorKind::PermissionDenied => println!("Permission denied"),
            _ => println!("IO error: {}", io_err),
        }
    } else {
        println!("Other error: {}", err);
    }
}

downcast_ref() lets you check if the underlying error is a specific type. This is useful when you need to handle specific errors differently.

Common Mistakes

Mistake 1: Using anyhow in Library Public APIs

// BAD: library function returns anyhow::Error
pub fn connect(url: &str) -> anyhow::Result<Connection> { ... }

// GOOD: library function returns a specific error
pub fn connect(url: &str) -> Result<Connection, DatabaseError> { ... }

Library users need to match on specific error types. anyhow::Error hides the error type.

Mistake 2: Missing Context on File Operations

// BAD: "No such file or directory" — which file?
let data = std::fs::read_to_string(path)?;

// GOOD: includes the file path
let data = std::fs::read_to_string(path)
    .context(format!("failed to read '{}'", path))?;

Always add context that includes the input that caused the error.

Mistake 3: Using String as Error Type

// BAD: stringly-typed errors
fn validate(input: &str) -> Result<(), String> {
    Err(format!("invalid: {}", input))
}

// GOOD: use anyhow or thiserror
fn validate(input: &str) -> anyhow::Result<()> {
    anyhow::bail!("invalid: {}", input);
}

String does not implement std::error::Error, so it does not compose well with ? and error chains.

Summary

ConceptPurpose
thiserror::ErrorDerive custom error types with Display and From
#[error("...")]Generate Display implementation
#[from]Generate From implementation for auto-conversion
#[source]Set error source without From
anyhow::Result<T>Catch-all Result type for applications
anyhow::bail!()Return an error immediately
anyhow::ensure!()Assert a condition or return error
.context()Add context explaining what your code was doing
e.chain()Iterate over the full error chain
e.downcast_ref()Check the underlying error type

Source Code

Find the complete code on GitHub: tutorial-19-error-handling-advanced

What’s Next?

In this tutorial, we learned production-grade error handling with thiserror and anyhow. These two crates eliminate boilerplate and make error messages much more useful.

In the next tutorial, we will learn Serde — the serialization framework that powers JSON, TOML, and data handling in the Rust ecosystem.

Next: Rust Tutorial #20: Serde and JSON