What are the trade-offs between anyhow::Context and thiserror for adding contextual error information?

anyhow::Context provides runtime error context through implicit error chaining with ergonomic .context() methods on Result, while thiserror generates static error types with compile-time fields that embed context directly into the error type—choosing between them involves deciding between ergonomic runtime flexibility with dynamic context (anyhow) versus type-safe static errors with structured context (thiserror). Both libraries solve the problem of adding meaningful context to errors, but they take fundamentally different approaches: anyhow focuses on convenience and dynamic error types for application code, while thiserror creates structured, typed errors ideal for libraries.

Understanding the Two Approaches

use anyhow::{Context, Result};
use thiserror::Error;
 
// anyhow: Runtime context added via .context()
fn read_config_anyhow() -> Result<Config> {
    let content = std::fs::read_to_string("config.toml")
        .context("Failed to read config file")?;
    
    let config: Config = toml::from_str(&content)
        .context("Failed to parse config")?;
    
    Ok(config)
}
 
// thiserror: Compile-time context built into error type
#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Failed to read config file: {0}")]
    ReadError(#[from] std::io::Error),
    
    #[error("Failed to parse config: {0}")]
    ParseError(#[from] toml::de::Error),
}
 
fn read_config_thiserror() -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string("config.toml")?;
    let config: Config = toml::from_str(&content)?;
    Ok(config)
}

anyhow adds context at call sites; thiserror defines context in error types.

anyhow::Context: Runtime Context Chain

use anyhow::{Context, Result};
 
fn process_file_anyhow() -> Result<()> {
    std::fs::read_to_string("data.txt")
        .context("Failed to read data.txt")?
        .lines()
        .enumerate()
        .try_for_each(|(i, line)| {
            process_line(line)
                .with_context(|| format!("Failed to process line {}", i + 1))
        })?;
    
    Ok(())
}
 
fn process_line(line: &str) -> Result<()> {
    // Context chain is built at runtime
    if line.is_empty() {
        anyhow::bail!("Empty line not allowed");
    }
    Ok(())
}
 
// Error output includes full context chain:
// Error: Failed to process line 3
// Caused by:
//     Empty line not allowed

Context is added dynamically at each call site using .context() and .with_context().

thiserror: Static Error Types

use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum ProcessingError {
    #[error("Failed to read file {path}: {source}")]
    ReadError {
        path: String,
        #[source]
        source: std::io::Error,
    },
    
    #[error("Failed to process line {line}: {message}")]
    LineError {
        line: usize,
        message: String,
    },
    
    #[error("Invalid data: {0}")]
    InvalidData(String),
}
 
fn process_file_thiserror() -> Result<(), ProcessingError> {
    let path = "data.txt";
    let content = std::fs::read_to_string(path)
        .map_err(|e| ProcessingError::ReadError {
            path: path.to_string(),
            source: e,
        })?;
    
    for (i, line) in content.lines().enumerate() {
        if line.is_empty() {
            return Err(ProcessingError::LineError {
                line: i + 1,
                message: "Empty line not allowed".to_string(),
            });
        }
    }
    
    Ok(())
}

Context is defined in the error type; values are passed as fields.

Ergonomics Comparison

use anyhow::{Context, Result};
 
// anyhow: Quick, concise, at call site
fn quick_context() -> Result<()> {
    std::fs::read_to_string("config.toml")
        .context("Failed to read config")?;
    
    // Context is added right where the operation happens
    // No need to define error types
    
    Ok(())
}
 
// thiserror: More boilerplate, but structured
use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Failed to read config: {0}")]
    Io(#[from] std::io::Error),
}
 
fn structured_context() -> Result<(), ConfigError> {
    std::fs::read_to_string("config.toml")?;
    Ok(())
}
 
// When you need to add context not just wrap:
fn with_extra_context() -> Result<()> {
    // anyhow: context added inline
    let config_path = "config.toml";
    std::fs::read_to_string(config_path)
        .with_context(|| format!("Failed to read {}", config_path))?;
    
    Ok(())
}
 
// thiserror: context is a field
#[derive(Error, Debug)]
pub enum ReadError {
    #[error("Failed to read {path}: {source}")]
    Io { path: String, #[source] source: std::io::Error },
}
 
fn with_field_context() -> Result<(), ReadError> {
    let path = "config.toml";
    std::fs::read_to_string(path)
        .map_err(|e| ReadError::Io { path: path.to_string(), source: e })?;
    Ok(())
}

anyhow is more concise; thiserror requires defining error variants and fields.

Error Message Quality

use anyhow::{Context, Result};
 
fn anyhow_error_chain() -> Result<()> {
    std::fs::read_to_string("missing.txt")
        .context("Loading configuration")?;
    Ok(())
}
 
// anyhow output (with RUST_BACKTRACE=1):
// Error: Loading configuration
// Caused by:
//     No such file or directory (os error 2)
 
// Context chain is preserved and displayed
 
fn anyhow_detailed_context() -> Result<()> {
    let file = "config.toml";
    std::fs::read_to_string(file)
        .with_context(|| format!("Failed to load config from {}", file))?;
    Ok(())
}
 
// Error: Failed to load config from config.toml
// Caused by:
//     No such file or directory (os error 2)
use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum AppError {
    #[error("Loading configuration from {path}")]
    ConfigLoad { 
        path: String,
        #[source]
        source: std::io::Error 
    },
}
 
fn thiserror_detailed() -> Result<(), AppError> {
    let path = "config.toml".to_string();
    std::fs::read_to_string(&path)
        .map_err(|source| AppError::ConfigLoad { path, source })?;
    Ok(())
}
 
// thiserror output:
// AppError::ConfigLoad { path: "config.toml", source: IoError }
// Display: "Loading configuration from config.toml"
// Debug: Full struct with source chain

Both produce informative errors; anyhow chains are runtime, thiserror are structured.

Library vs Application Trade-offs

// Library: Use thiserror for type-safe, documented errors
// thiserror: Error types are part of your API
 
use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("Connection failed to {host}:{port}")]
    ConnectionFailed { host: String, port: u16 },
    
    #[error("Query execution failed: {0}")]
    QueryError(String),
    
    #[error("Transaction rollback: {0}")]
    TransactionError(#[from] TransactionError),
}
 
// Users can match on specific error variants
pub fn handle_error(err: DatabaseError) {
    match err {
        DatabaseError::ConnectionFailed { host, port } => {
            println!("Could not connect to {}:{}", host, port);
        }
        DatabaseError::QueryError(msg) => {
            println!("Query failed: {}", msg);
        }
        DatabaseError::TransactionError(e) => {
            println!("Transaction error: {}", e);
        }
    }
}
 
// Application: Use anyhow for convenience
// anyhow: No need to define error types
 
use anyhow::{Context, Result};
 
fn application_main() -> Result<()> {
    let config = load_config()
        .context("Failed to load configuration")?;
    
    let db = connect_db(&config)
        .context("Failed to connect to database")?;
    
    let results = query_db(&db)
        .context("Failed to execute query")?;
    
    process_results(results)
        .context("Failed to process results")?;
    
    Ok(())
}
 
// Application code focuses on context at each step
// No need to define error types for every failure mode

Libraries benefit from thiserror's type-safe errors; applications benefit from anyhow's ergonomics.

Pattern Matching on Errors

// thiserror: Can match on specific error variants
use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum NetworkError {
    #[error("Connection timeout")]
    Timeout,
    #[error("DNS resolution failed for {host}")]
    DnsError { host: String },
    #[error("TLS handshake failed")]
    TlsError,
}
 
fn handle_network_error(err: NetworkError) {
    match err {
        NetworkError::Timeout => {
            println!("Retrying...");
        }
        NetworkError::DnsError { host } => {
            println!("Could not resolve {}", host);
        }
        NetworkError::TlsError => {
            println!("Certificate error");
        }
    }
}
 
// anyhow: Can't easily match on error types
use anyhow::{anyhow, Result};
 
fn handle_anyhow_error(err: anyhow::Error) {
    // Downcast is possible but not ergonomic
    if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
        match io_err.kind() {
            std::io::ErrorKind::TimedOut => println!("Timeout"),
            _ => println!("Other IO error"),
        }
    } else {
        println!("Unknown error: {}", err);
    }
    
    // Can check error chain
    for cause in err.chain() {
        println!("Caused by: {}", cause);
    }
}

thiserror enables pattern matching; anyhow requires downcasting for type-specific handling.

Dynamic vs Static Context

use anyhow::{Context, Result};
 
// anyhow: Context can be computed at runtime
fn dynamic_context(user_id: u64, action: &str) -> Result<()> {
    perform_action(user_id, action)
        .with_context(|| format!(
            "User {} failed to perform action: {}",
            user_id, action
        ))?;
    Ok(())
}
 
// Context message varies based on runtime values
// No need to define error variants for each case
 
// thiserror: Context structure is defined at compile time
use thiserror::Error;
 
#[derive(Error, Debug)]
pub enum ActionError {
    #[error("User {user_id} failed to {action}: {source}")]
    ActionFailed {
        user_id: u64,
        action: String,
        #[source]
        source: std::io::Error,
    },
}
 
fn static_context(user_id: u64, action: &str) -> Result<(), ActionError> {
    perform_action(user_id, action)
        .map_err(|source| ActionError::ActionFailed {
            user_id,
            action: action.to_string(),
            source,
        })?;
    Ok(())
}
 
fn perform_action(_user_id: u64, _action: &str) -> std::io::Result<()> {
    Ok(())
}

anyhow excels at dynamic context; thiserror at structured, typed context.

Error Conversion and Propagation

use anyhow::{Context, Result};
use thiserror::Error;
 
// anyhow: #[source] via From is automatic with #[error(transparent)]
// Or use .context() to wrap
 
fn anyhow_propagation() -> Result<()> {
    // Wrap io::Error with context
    let content = std::fs::read_to_string("file.txt")
        .context("Reading file.txt")?;
    
    // Wrap parse error with context
    let data: Data = serde_json::from_str(&content)
        .context("Parsing JSON")?;
    
    Ok(())
}
 
// thiserror: Use #[from] for automatic conversion
// Use #[source] for explicit source fields
 
#[derive(Error, Debug)]
pub enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("JSON parse error: {0}")]
    Json(#[from] serde_json::Error),
    
    #[error("Custom error: {message}")]
    Custom {
        message: String,
        #[source]
        source: std::io::Error,
    },
}
 
fn thiserror_propagation() -> Result<(), AppError> {
    // Automatic conversion via #[from]
    let content = std::fs::read_to_string("file.txt")?;
    let data: Data = serde_json::from_str(&content)?;
    Ok(())
}

Both support error propagation; anyhow uses implicit wrapping, thiserror uses #[from].

Source Error Chaining

use anyhow::{Context, Result};
 
// anyhow: Automatic source chain via Error trait
fn chain_example() -> Result<()> {
    let config = std::fs::read_to_string("config.toml")
        .context("Reading config")?
        .parse::<Config>()
        .context("Parsing config")?;
    
    Ok(())
}
 
// Error chain:
// Error: Parsing config
// Caused by:
//     0: Reading config
//     1: No such file or directory (os error 2)
 
// The chain() method iterates sources
fn print_chain(err: &anyhow::Error) {
    for (i, cause) in err.chain().enumerate() {
        println!("{}: {}", i, cause);
    }
}
 
// thiserror: #[source] field tracks the cause
#[derive(Error, Debug)]
pub enum ChainError {
    #[error("Reading config")]
    ReadFailed {
        #[source]
        source: std::io::Error,
    },
    
    #[error("Parsing config")]
    ParseFailed {
        #[source]
        source: toml::de::Error,
    },
}
 
// The #[source] attribute marks which field is the cause
// impl std::error::Error uses the source field

Both preserve error sources; anyhow chains are implicit, thiserror sources are explicit fields.

Performance Characteristics

use anyhow::Result;
use thiserror::Error;
 
// anyhow: Dynamic dispatch for error types
// - Errors are boxed (type-erased)
// - Context is added at runtime
// - Small overhead for allocation
 
fn anyhow_performance() -> Result<()> {
    // Each .context() creates a new Error with allocated message
    std::fs::read_to_string("file.txt")
        .context("Reading file")?;  // Allocates context string
    
    Ok(())
}
 
// thiserror: Static error types
// - No dynamic dispatch
// - Errors are concrete types
// - Context is stack-allocated in struct fields
 
#[derive(Error, Debug)]
pub enum StaticError {
    #[error("Reading {path}")]
    ReadFailed { path: String, #[source] source: std::io::Error },
}
 
fn thiserror_performance() -> Result<(), StaticError> {
    std::fs::read_to_string("file.txt")
        .map_err(|e| StaticError::ReadFailed { 
            path: "file.txt".to_string(),  // String allocation
            source: e,
        })?;
    Ok(())
}
 
// For hot paths:
// - thiserror: Better for performance-critical code
// - anyhow: Overhead is usually negligible for most applications

thiserror has less runtime overhead; anyhow uses dynamic allocation for error wrapping.

Mixing Both Libraries

use anyhow::{Context, Result};
use thiserror::Error;
 
// Common pattern: Library uses thiserror, application uses anyhow
 
// Library error type (thiserror)
#[derive(Error, Debug)]
pub enum LibraryError {
    #[error("Invalid input: {0}")]
    InvalidInput(String),
    
    #[error("Processing failed: {0}")]
    ProcessingFailed(String),
}
 
// Library returns thiserror errors
pub fn library_function(input: &str) -> Result<(), LibraryError> {
    if input.is_empty() {
        return Err(LibraryError::InvalidInput("input cannot be empty".into()));
    }
    Ok(())
}
 
// Application uses anyhow, wrapping library errors
fn application_function() -> Result<()> {
    library_function("")
        .context("Calling library function")?;
    
    Ok(())
}
 
// The LibraryError is preserved in the anyhow::Error chain
// Application code uses anyhow::Context for convenience
// Library code uses thiserror for API clarity

Use thiserror for library errors, anyhow for application error handling.

Context for Different Error Types

use anyhow::{anyhow, bail, Context, Result};
 
// anyhow: Consistent context interface for all errors
fn unified_context() -> Result<()> {
    // std::io::Error
    std::fs::read_to_string("file.txt")
        .context("Reading file")?;
    
    // serde_json::Error
    let _: Data = serde_json::from_str("{}")
        .context("Parsing JSON")?;
    
    // Custom errors with bail!
    if invalid_condition() {
        bail!("Invalid condition encountered");
    }
    
    // Context works with any Result<T, E> where E: std::error::Error
    // The .context() method is provided by anyhow's Context trait
    
    Ok(())
}
 
// thiserror: Each error type needs explicit definition
#[derive(Error, Debug)]
pub enum UnifiedError {
    #[error("Reading file: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("Parsing JSON: {0}")]
    Json(#[from] serde_json::Error),
    
    #[error("Invalid condition: {0}")]
    InvalidCondition(String),
}
 
fn thiserror_unified() -> Result<(), UnifiedError> {
    std::fs::read_to_string("file.txt")?;
    let _: Data = serde_json::from_str("{}")?;
    
    if invalid_condition() {
        return Err(UnifiedError::InvalidCondition(
            "Invalid condition encountered".into()
        ));
    }
    
    Ok(())
}
 
fn invalid_condition() -> bool {
    false
}

anyhow provides consistent .context() for all error types; thiserror requires defining variants.

Structured Logging Integration

use anyhow::{Context, Result};
use thiserror::Error;
 
// anyhow: Good for logging error chains
fn log_anyhow_errors() -> Result<()> {
    let result = process_data();
    
    if let Err(err) = result {
        // Log the full chain
        log::error!("Error: {:?}", err);
        
        for cause in err.chain() {
            log::error!("  Caused by: {}", cause);
        }
        
        return Err(err);
    }
    
    Ok(())
}
 
// thiserror: Good for structured logging with fields
#[derive(Error, Debug)]
pub enum ProcessError {
    #[error("Processing failed at step {step}")]
    StepFailed { step: usize, source: std::io::Error },
    
    #[error("Validation failed: {field} = {value}")]
    Validation { field: String, value: String },
}
 
fn log_thiserror_errors(err: ProcessError) {
    match err {
        ProcessError::StepFailed { step, source } => {
            log::error!(
                "Step {} failed: {}",
                step, source
            );
        }
        ProcessError::Validation { field, value } => {
            log::error!(
                "Validation failed: field={}, value={}",
                field, value
            );
        }
    }
}
 
// thiserror provides structured fields for logging
// anyhow provides error chain iteration

thiserror's structured fields work well with structured logging; anyhow's chain iteration supports generic error reporting.

Testing Error Conditions

use anyhow::{anyhow, Result};
use thiserror::Error;
 
// anyhow: Testing error messages requires string matching
#[test]
fn test_anyhow_error() {
    let result = anyhow_function();
    
    let err = result.unwrap_err();
    assert!(err.to_string().contains("expected error message"));
    
    // Downcast to check underlying error type
    let source = err.downcast_ref::<std::io::Error>();
    assert!(source.is_some());
}
 
fn anyhow_function() -> Result<()> {
    Err(anyhow!("expected error message"))
}
 
// thiserror: Can match on error variants
#[derive(Error, Debug, PartialEq)]
pub enum TestError {
    #[error("Invalid input: {0}")]
    InvalidInput(String),
    
    #[error("Processing failed")]
    ProcessingFailed,
}
 
#[test]
fn test_thiserror_error() {
    let result = thiserror_function("");
    
    match result {
        Err(TestError::InvalidInput(msg)) => {
            assert!(msg.contains("empty"));
        }
        Err(TestError::ProcessingFailed) => {
            panic!("Wrong error type");
        }
        Ok(_) => panic!("Expected error"),
    }
}
 
fn thiserror_function(input: &str) -> Result<(), TestError> {
    if input.is_empty() {
        return Err(TestError::InvalidInput("input is empty".into()));
    }
    Ok(())
}

thiserror enables precise error matching in tests; anyhow requires message comparison.

Synthesis

Design philosophy:

// anyhow::Context:
// - Runtime context addition
// - Dynamic error types (type-erased)
// - Ergonomic .context() method
// - Focus on developer convenience
// - Best for applications
 
// thiserror:
// - Compile-time error definitions
// - Static error types (concrete)
// - Structured error fields
// - Focus on API design
// - Best for libraries

Trade-off summary:

// anyhow::Context:
// + Ergonomic .context() syntax
// + Works with any Result<T, E: Error>
// + Automatic error chaining
// + Dynamic context messages
// + Easy to add context incrementally
// - No pattern matching on errors
// - Runtime overhead (allocation)
// - Less structured for API consumers
// - Can't express error variants in type system
 
// thiserror:
// + Type-safe error variants
// + Pattern matching on errors
// + Structured error fields
// + Part of library's public API
// + Zero runtime overhead (static types)
// - More boilerplate to define errors
// - Context must be defined at compile time
// - Need explicit error conversion
// - Error types must be defined per crate

Choosing between them:

// Use anyhow::Context when:
// - Writing application code
// - You want quick, easy error context
// - Error types don't need to be public API
// - Dynamic context based on runtime values
// - Rapid development with minimal boilerplate
 
// Use thiserror when:
// - Writing library code
// - Errors are part of your public API
// - Users need to match on error types
// - Structured error fields are valuable
// - Performance matters (hot paths)
// - You want compile-time error guarantees

Key insight: anyhow::Context and thiserror solve the same problem—adding meaningful context to errors—but from opposite directions. anyhow prioritizes ergonomics and runtime flexibility, letting you add context at any call site with .context() without defining error types, making it ideal for application code where developer productivity matters most. thiserror prioritizes type safety and API design, requiring you to define error variants and fields upfront, producing structured error types that library consumers can match on and handle programmatically. The choice isn't mutually exclusive: a common pattern is using thiserror for library error types (where the errors are part of the API) and anyhow for application error handling (where the context-rich error chains help with debugging). The .context() method works with any Result<T, E: Error>, making it easy to add context to library errors before propagating them up the application stack.