Loading page…
Rust walkthroughs
Loading page…
thiserror::Error and anyhow::Error in terms of when you should use each one?Error handling in Rust presents a fundamental tension between library design and application development. The thiserror and anyhow crates represent two different philosophies for solving this problem, each optimized for distinct use cases. Choosing between them—or understanding how to use them together—requires examining what each crate optimizes for and how errors flow through your codebase.
The distinction begins with who consumes the error. Libraries produce errors that callers must handle and potentially recover from. Applications produce errors that ultimately terminate execution or get logged for human consumption. This difference shapes everything about how errors should be designed.
thiserror is a derive macro for creating structured error types with rich information. It excels when you need to define specific error variants that callers can match against and handle differently. The errors it generates are typed, implement std::error::Error, and integrate seamlessly with Rust's ? operator.
anyhow provides a generic error type that can hold any error, along with context attachment mechanisms. It prioritizes ergonomics and rich diagnostic information over type-safe error handling, making it ideal for applications where errors bubble up to a top-level handler.
The thiserror::Error derive macro generates boilerplate code for implementing the std::error::Error trait and optionally Display and From:
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}")]
QueryFailed(String),
#[error("record not found with id {id}")]
NotFound { id: u64 },
#[error("timeout after {ms}ms")]
Timeout { ms: u64 },
}Each variant carries specific data that callers can inspect. The #[error(...)] attribute defines how the error appears in logs and error messages, with fields interpolated directly into the message.
Callers can match on these variants and take different actions:
fn handle_database_operation() -> Result<(), DatabaseError> {
let result = perform_query()?;
match result {
Ok(data) => process(data),
Err(DatabaseError::NotFound { id }) => {
// Recovery: create the missing record
create_default_record(id)
}
Err(DatabaseError::Timeout { ms }) if ms < 5000 => {
// Recovery: retry with longer timeout
retry_with_extended_timeout()
}
Err(e) => return Err(e),
}
Ok(())
}The ability to match on specific error variants enables intelligent error recovery. A database connection failure might trigger a reconnection attempt, while a not-found error might create a default record.
anyhow::Error wraps any type that implements std::error::Error into a single error type, erasing the specific error variant information:
use anyhow::{Error, Result, Context};
fn load_configuration(path: &str) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.context(format!("failed to read config from {}", path))?;
let config: Config = toml::from_str(&contents)
.context("failed to parse configuration")?;
Ok(config)
}The Result here is anyhow::Result<T>, an alias for Result<T, anyhow::Error>. Any error type can be converted to anyhow::Error automatically via the ? operator. The context method attaches additional information that appears in the error chain.
When this fails, the error message includes the full context chain:
Error: failed to read config from /etc/myapp/config.toml
Caused by:
No such file or directory (os error 2)
The context method transforms cryptic lower-level errors into actionable messages:
use anyhow::{Context, Result};
fn process_user_file(user_id: u64) -> Result<ProcessedData> {
let path = format!("/data/users/{}.json", user_id);
let data = std::fs::read_to_string(&path)
.context(format!("could not load data for user {}", user_id))?;
let parsed: UserData = serde_json::from_str(&data)
.context(format!("invalid JSON in user {} data file", user_id))?;
parsed.validate()
.context(format!("validation failed for user {}", user_id))?;
Ok(ProcessedData::from(parsed))
}Each layer adds context about what operation was being attempted. When the error reaches the top level, the message tells a complete story about what went wrong and where.
The fundamental trade-off between these crates is type information:
// thiserror: Type information preserved
fn library_function() -> Result<Data, DatabaseError>;
// anyhow: Type information erased
fn application_function() -> anyhow::Result<Data>;With thiserror, the error type appears in the function signature. Callers know exactly what can go wrong. With anyhow, the error type is opaque—callers know something can fail, but not what specifically.
This has downstream effects:
Version compatibility: Changing error variants in a thiserror enum is a breaking change for library users. Adding context to anyhow errors is entirely internal.
Pattern matching: thiserror errors can be matched exhaustively. anyhow errors can only be downcast at runtime:
use anyhow::Error;
fn handle_error(error: Error) {
if let Some(db_error) = error.downcast_ref::<DatabaseError>() {
// Handle specific database error
match db_error {
DatabaseError::NotFound { id } => println!("Missing: {}", id),
_ => {}
}
} else if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
// Handle IO error
}
// Cannot exhaustively handle all cases
}Libraries should almost always use thiserror (or manual error implementations). The error type is part of the library's public API:
// my_http_client/src/lib.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum HttpClientError {
#[error("DNS resolution failed for {host}")]
DnsResolution { host: String },
#[error("connection refused to {host}:{port}")]
ConnectionRefused { host: String, port: u16 },
#[error("HTTP {status}: {message}")]
HttpError { status: u16, message: String },
#[error("request timed out after {seconds}s")]
Timeout { seconds: u64 },
#[error("TLS handshake failed: {0}")]
TlsError(#[from] TlsError),
}
pub fn get(url: &str) -> Result<Response, HttpClientError> {
// Implementation
}Users of this library can write code that handles specific failure modes:
use my_http_client::{get, HttpClientError};
fn fetch_with_retry(url: &str) -> Option<Response> {
for attempt in 0..3 {
match get(url) {
Ok(response) => return Some(response),
Err(HttpClientError::Timeout { .. }) if attempt < 2 => {
std::thread::sleep(Duration::from_secs(1));
continue;
}
Err(HttpClientError::ConnectionRefused { .. }) if attempt < 2 => {
std::thread::sleep(Duration::from_secs(2));
continue;
}
Err(e) => {
eprintln!("Failed: {}", e);
return None;
}
}
}
None
}Applications benefit from anyhow's ergonomics because errors ultimately flow to a handler that logs or displays them:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = load_config()
.context("failed to load configuration")?;
let db = connect_database(&config.database_url)
.context("failed to connect to database")?;
let server = start_server(&config, db)
.context("failed to start server")?;
server.run()
.context("server encountered fatal error")?;
Ok(())
}The main function returns anyhow::Result<()>, and any error that bubbles up gets printed with its context chain. There's no need to define error types or implement conversions.
A common pattern uses thiserror for library errors and anyhow for application code. The conversion is automatic:
// library/src/error.rs
#[derive(thiserror::Error, Debug)]
pub enum LibraryError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("processing failed")]
ProcessingFailed,
}
// application/src/main.rs
use anyhow::{Context, Result};
use library::LibraryError;
fn use_library() -> Result<()> {
library::process("input")
.context("library operation failed")?;
Ok(())
}The LibraryError converts to anyhow::Error automatically because it implements std::error::Error. Context stacks on top:
Error: library operation failed
Caused by:
invalid input: provided value out of range
thiserror supports error chaining through the #[source] attribute or #[from]:
#[derive(Error, Debug)]
pub enum AppError {
#[error("database error")]
Database(#[from] DatabaseError),
#[error("IO error while reading {path}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}The #[from] attribute generates a From implementation, allowing automatic conversion with ?. The #[source] attribute marks a field as the underlying cause without generating From.
fn read_config(path: &str) -> Result<Config, AppError> {
let contents = std::fs::read_to_string(path)
.map_err(|e| AppError::Io {
path: path.to_string(),
source: e,
})?;
Ok(parse(contents))
}Consider a payment processing system:
// With anyhow - no compile-time guarantees
fn process_payment(amount: u64) -> anyhow::Result<Transaction> {
// What errors can occur? Must read implementation or docs.
}
// With thiserror - errors are documented in the type
fn process_payment(amount: u64) -> Result<Transaction, PaymentError> {
// PaymentError variants tell the caller what to handle
}
#[derive(Error, Debug)]
pub enum PaymentError {
#[error("insufficient funds: required {required}, available {available}")]
InsufficientFunds { required: u64, available: u64 },
#[error("card declined by issuer")]
CardDeclined,
#[error("network error during processing")]
NetworkError(#[from] reqwest::Error),
}The caller of the thiserror version sees exactly what can fail and can make informed decisions about retry logic, user messaging, and fallback behavior.
anyhow::Error uses Box<dyn std::error::Error + Send + Sync + 'static> internally, which means heap allocation for every error. thiserror errors are sized at compile time and can be stack-allocated. For hot paths where errors are common but handled quickly, thiserror may perform better.
However, error handling is rarely on the critical path, and the ergonomic benefits of anyhow usually outweigh the performance difference in application code.
Choose thiserror when you're writing a library and the error type is part of your public API. Callers need to know what can fail and have the ability to handle specific failure modes differently. The structured error types enable exhaustive matching and make error handling a deliberate part of the interface contract.
Choose anyhow when you're writing application code where errors flow to a top-level handler. The ergonomics of context(), automatic error conversion, and unified error type reduce boilerplate and improve error messages without requiring type design effort.
The crates complement each other: use thiserror in your libraries to define meaningful error types, use anyhow in your application to collect and contextualize those errors. The boundary between them is the library/application boundary, not a technical limitation.