The error boundary problem
You are building a command-line tool that fetches a configuration file from a URL, parses the JSON, and writes a report. Halfway through, the network request fails. Then the JSON is malformed. Then the disk is full. You could define a FetchError, a ParseError, and a DiskError, implement Display and Debug for each, and write match statements to glue them together. Or you could let the errors bubble up as opaque blobs and handle them at the very top. Rust gives you both paths, but it forces you to pick the right one for the job.
The ecosystem split into two crates to solve this. anyhow handles errors in binaries where the goal is to report a message and exit. thiserror defines errors in libraries where the goal is to expose a contract that callers can inspect. Mixing them up leads to brittle APIs or boilerplate nightmares.
Type erasure versus type definition
anyhow treats errors as opaque values. It erases the specific type and wraps everything in a single anyhow::Error type. This makes it easy to propagate errors without caring about their shape. You write Result<T, anyhow::Error> and the ? operator handles the rest. The caller never sees the original error type. They only see the anyhow wrapper.
thiserror does the opposite. It generates boilerplate for custom error types. You define a struct or enum, add a derive macro, and get a full std::error::Error implementation. thiserror is about defining the contract. The caller knows exactly what went wrong and can pattern match on the variants.
Think of anyhow as a universal remote control. It sends a signal, and the TV turns on. You don't care about the infrared frequency or the protocol. Think of thiserror as the wiring diagram for the TV. It specifies every pin, voltage, and signal type. Other engineers need the diagram to build compatible accessories.
How anyhow erases types
Under the hood, anyhow::Error is a wrapper around Box<dyn std::error::Error + Send + Sync>. This is a trait object. The compiler stores a pointer to the data and a pointer to a vtable of methods. When you return an error, it gets boxed on the heap. The specific type disappears behind the dynamic dispatch.
use anyhow::{Context, Result};
/// Reads a file and returns its contents, attaching context on failure.
fn read_config(path: &str) -> Result<String> {
// std::io::Error gets converted to anyhow::Error via From trait.
// The original type is erased and boxed.
let content = std::fs::read_to_string(path)
.context("Failed to read configuration file")?;
// Context builds an error chain. The inner error is preserved
// but wrapped in a message layer.
Ok(content)
}
fn main() -> Result<()> {
let data = read_config("config.json")?;
println!("{data}");
Ok(())
}
The Context trait adds a message to the error chain without changing the type. This is the killer feature for binaries. You can annotate failures as they bubble up, and anyhow prints the chain with a backtrace when the program exits. The convention is to use anyhow::Result<T> as a type alias for Result<T, anyhow::Error>. This keeps signatures clean.
Convention aside: The community prefers anyhow::Result over std::result::Result<T, anyhow::Error> in binaries. It signals intent. Readers know immediately that this function is part of the application logic, not a library interface.
How thiserror generates types
thiserror uses a derive macro to generate the std::error::Error implementation. You write the enum or struct, and the macro fills in the Display, Debug, and Error traits. The macro supports formatting strings, field access, and transparent delegation.
use thiserror::Error;
/// Errors that can occur during configuration parsing.
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("file not found: {path}")]
NotFound { path: String },
#[error("invalid version: expected {expected}, found {found}")]
VersionMismatch { expected: u32, found: u32 },
#[error("parsing failed: {source}")]
Parse {
#[source]
source: serde_json::Error,
},
}
The #[error("...")] attribute defines the Display output. The macro expands this into a fmt::Display implementation. The #[source] attribute marks a field as the underlying cause. This enables error chaining in the standard library sense. Callers can use std::error::Error::source() to walk the chain.
The generated code is static. The compiler knows the size and layout of ConfigError. Errors are passed by value or reference. There is no boxing. There is no dynamic dispatch. The cost is writing the definition and handling the variants.
Convention aside: Use #[error(transparent)] when you want to wrap an existing error type without adding a new variant layer. This is useful for re-exporting errors from dependencies. The macro generates a Display and source implementation that delegates directly to the inner error. It keeps the error chain flat.
The bridge: From and the question mark
The two crates work together because of the From trait. anyhow implements From<E> for every type E that implements std::error::Error. This means anyhow::Error::from(err) works for any standard error. The ? operator calls From::from implicitly.
When a library function returns a thiserror enum, and a binary function returns anyhow::Result, the ? operator converts the library error to anyhow::Error automatically. The conversion happens at the call site. The library error gets boxed and wrapped.
use anyhow::Result;
use my_lib;
/// Main entry point that consumes the library and handles errors.
fn main() -> Result<()> {
// my_lib::parse returns Result<(), my_lib::ConfigError>.
// The ? operator converts ConfigError to anyhow::Error via From.
// The conversion boxes the error and erases the type.
my_lib::parse("config.json")?;
println!("Configuration loaded successfully.");
Ok(())
}
This bridge is what makes the ecosystem cohesive. Libraries define precise errors. Binaries consume them as opaque blobs. The ? operator handles the translation. You don't need map_err chains. You don't need manual boxing. The compiler inserts the conversion.
Realistic example: Library and binary
A realistic project splits error handling across the boundary. The library defines errors with thiserror. The binary uses anyhow for top-level handling. The library never mentions anyhow. The binary never defines custom error types.
// src/lib.rs
use thiserror::Error;
/// Errors related to data processing.
#[derive(Error, Debug)]
pub enum ProcessError {
#[error("input is empty")]
EmptyInput,
#[error("processing failed: {0}")]
Internal(#[from] std::io::Error),
}
/// Processes input data and returns a result.
pub fn process(input: &str) -> Result<(), ProcessError> {
if input.is_empty() {
return Err(ProcessError::EmptyInput);
}
// Simulate an IO error that gets converted via #[from].
std::fs::read_to_string("dummy.txt")?;
Ok(())
}
// src/main.rs
use anyhow::{Context, Result};
use my_lib;
/// Runs the application logic.
fn run() -> Result<()> {
let input = std::fs::read_to_string("input.txt")
.context("Failed to read input file")?;
// Library error converts to anyhow::Error automatically.
my_lib::process(&input)
.context("Processing pipeline failed")?;
Ok(())
}
fn main() -> Result<()> {
run()
}
The library function process returns Result<(), ProcessError>. The caller can match on ProcessError::EmptyInput or ProcessError::Internal. The binary function run returns Result<()>. It uses context to add messages. The ? operator bridges the gap. The binary sees a chain of context messages and the original error at the bottom.
Pitfalls and compiler errors
Using anyhow in a library breaks the contract. If a library function returns anyhow::Error, the caller cannot pattern match on the error. They only see a string. This hides the structure of failures. Callers cannot recover from specific errors. They cannot implement custom retry logic. The API becomes opaque.
If you try to return a thiserror enum from a function that expects anyhow::Error, the compiler rejects it with E0308 (mismatched types) unless you map it. The ? operator handles the mapping, but explicit returns need care. You can use anyhow::anyhow! to create an error from a string, or Err(err).into() to convert.
Using thiserror everywhere in a binary creates boilerplate. You define enums for every possible failure. You write match statements to handle them. You chain errors manually. The code grows verbose. The benefit is minimal because the binary usually just prints the error and exits. The structure doesn't matter to the user.
Convention aside: The community treats anyhow as a binary-only dependency. Adding anyhow to a library's Cargo.toml is a design smell. It signals that the library author didn't think about the error contract. Reviewers will ask you to switch to thiserror or a custom error type.
Decision matrix
Use anyhow for top-level error handling in binaries where the goal is to report a user-friendly message and exit. Use anyhow when prototyping and you want to skip error type definitions until the API stabilizes. Use anyhow when you need to attach context messages to errors as they bubble up through the call stack.
Use thiserror when defining public error types in a library so callers can match on specific failure modes. Use thiserror when you need to attach structured data to errors, like a line number or a request ID, that callers might inspect. Use thiserror when you want to avoid heap allocations for errors in performance-critical paths. Use thiserror when you need to wrap existing error types with #[from] or #[error(transparent)] to build a cohesive error hierarchy.
Keep anyhow in the binary. Keep thiserror in the library. The ? operator is the translator; make sure it's translating to the right language.