Rust Error Handling: anyhow vs thiserror and the ? Operator Explained
Rust error handling with anyhow vs thiserror is the decision every Rust developer hits the moment their project grows past a single file. You have a function that calls a database client, which returns one error type, and a config parser, which returns a completely different error type, and your function needs to return both from the same Result. The Rust Book covers Result<T, E> and stops there — it does not tell you how production codebases actually unify error types across crate boundaries. This page covers the mechanics of the ? operator, how thiserror generates trait implementations from derive macros, when anyhow is the right choice and when it is not, and the error context patterns that make debugging production failures actually possible.
Covers Rust 2021 edition and later, with anyhow 1.0 and thiserror 1.0/2.0 syntax. Every section includes compilable code with the exact compiler behavior explained.
TL;DR
- The
?operator callsFrom::from()on the error automatically — this is what lets you return different error types from one function if a conversion exists thiserrorgeneratesstd::error::ErrorandDisplayimplementations from a derive macro — zero runtime cost, all code generation happens at compile timeanyhow::Errorwraps any error in a single type with a fixed-size representation — at the cost of losing the original type at the call site unless you downcast- Rule of thumb:
thiserrorfor libraries (callers need to match on error variants),anyhowfor applications (you just need to propagate and log) .context()on aResultadds a human-readable message to the error chain without losing the original error — critical for debugging multi-layer failures- Mixing both is normal: libraries return
thiserrorenums, application code wraps them inanyhow::Resultat the boundary
Rust Error Handling: Result, the Error Trait, and the ? Operator
Rust error handling is built on Result<T, E> and the std::error::Error trait — everything else, including anyhow and thiserror, is built on top of these two primitives. A function that can fail returns Result<T, E> where E is any type that implements std::error::Error. The trait requires Display (for human-readable messages) and provides a default source() method that returns the underlying cause, if any — this is what lets error chains be traversed programmatically.
The ? operator is syntactic sugar for early-return on Err. When you write let value = some_call()?;, the compiler expands this to a match: if Ok(v), bind v to value; if Err(e), return Err(From::from(e)) from the current function immediately. That From::from(e) call is the entire mechanism that makes error type unification possible — and it is also the source of confusion when it does not compile.
// Rust — what the ? operator actually expands to
fn read_config(path: &str) -> Result<String, std::io::Error> {
let content = std::fs::read_to_string(path)?; // expands to the match below
Ok(content)
}
// Equivalent expansion:
fn read_config_expanded(path: &str) -> Result<String, std::io::Error> {
let content = match std::fs::read_to_string(path) {
Ok(v) => v,
Err(e) => return Err(From::from(e)), // From for io::Error is identity
};
Ok(content)
}
Without the From::from(e) step in this expansion, the ? operator could only be used when the error type returned by the called function exactly matches the error type of the current function. The From conversion is what allows ? to return a std::io::Error from a function that returns your custom error type — as long as a From<std::io::Error> implementation exists for your type.
What is the Rust Error Trait and Why Does It Matter
std::error::Error is a trait with two requirements: the type must implement Display (for {} formatting) and Debug (for {:?} formatting). It provides one optional method, source(), which returns Option<&(dyn Error + 'static)> — the underlying error that caused this one, if any. This source() chain is what lets tools print full error chains like "failed to start server: failed to bind port: address already in use" — each layer’s error wraps the layer below and exposes it via source(). Implementing this trait manually for every custom error type is the boilerplate that thiserror eliminates.
Why Rust Functions Return Different Error Types From the Same Function
In a real application, a single function often calls multiple operations that fail differently: a file read returns std::io::Error, a JSON parse returns serde_json::Error, a database query returns sqlx::Error. Without a unifying error type, you cannot write one function signature that covers all three with ?. The three solutions are: define a custom enum with From implementations for each source error (manual or via thiserror), use Box<dyn std::error::Error> as a type-erased catch-all, or use anyhow::Error which does the same type erasure with additional ergonomics. Which one you choose determines the rest of your error handling architecture.
Rust Performance Profiling: Why Your Fast Code Is Lying to You Rust gives you control over memory, zero-cost abstractions, and a compiler that feels like it's on your side. So why does your service still...
rust ? operator error conversion: How From Trait Makes It Work
The ? operator’s error conversion relies entirely on impl From for TargetError existing for your function’s error type. If you write a function returning Result<T, MyError> and call something returning Result<T, std::io::Error> with ?, the code only compiles if From for MyError exists. If it does not exist, the compiler error is the trait bound `MyError: From` is not satisfied — this is one of the most common compile errors Rust developers hit when refactoring error types.
The snippet below shows a manual From implementation that makes ? conversion work between std::io::Error and a custom enum. This is exactly the boilerplate that thiserror generates automatically — seeing it written by hand makes the derive macro’s output concrete.
// Rust — manual From implementation enabling ? operator conversion
enum AppError {
Io(std::io::Error),
Parse(String),
}
// This impl is what makes `?` work when an io::Error is returned
// from a function whose Result error type is AppError
impl From for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err) // wrap the io error in the Io variant
}
}
fn load(path: &str) -> Result<String, AppError> {
let content = std::fs::read_to_string(path)?; // io::Error converted via From
Ok(content)
}
Without this From implementation, std::fs::read_to_string(path)? does not compile inside a function returning Result<String, AppError> — the compiler has no path from io::Error to AppError and rejects the conversion. This single missing trait implementation is responsible for a large fraction of “type mismatch” errors junior Rust developers hit when introducing custom error types.
rust Box<dyn Error>: The Type-Erasure Alternative
Box<dyn std::error::Error> is the standard library’s built-in type erasure for errors — any type implementing std::error::Error can be boxed into this single type, and From<E> for Box<dyn Error> exists for every E: Error, so ? works without writing any conversion code. The tradeoff: callers receive Box<dyn Error> and cannot match on the original error variant without downcasting with .downcast_ref::<SpecificError>(). This is the simplest possible error handling for small projects and prototypes — and it is exactly what anyhow::Error improves upon for application code.
How to Convert Errors Between Types in Rust Without thiserror
For a small number of error sources, manual From implementations like the one above are completely reasonable — there is no requirement to use thiserror for every project. The decision point is volume: with 2–3 error sources, manual impl From blocks are a few lines each and add no dependencies. With 5+ error sources, or when you need each variant to carry different contextual data, the boilerplate becomes repetitive enough that thiserror‘s derive macro saves meaningful time and reduces copy-paste errors.
thiserror derive macro: Custom Error Types Without Boilerplate
The thiserror derive macro generates Display, std::error::Error, and From implementations for an enum based on attributes you write on each variant. #[error("message")] generates the Display implementation with the given format string — you can interpolate fields directly. #[from] on a field generates a From implementation for that variant, enabling ? conversion automatically. The entire mechanism is compile-time code generation — there is zero runtime cost compared to hand-written implementations.
// Rust — thiserror derive macro generating Display, Error, and From impls
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("failed to read file: {0}")]
Io(#[from] std::io::Error), // #[from] generates From for AppError
#[error("invalid config value '{field}': {reason}")]
Config { field: String, reason: String }, // named fields interpolated in message
#[error("database query failed")]
Database(#[source] sqlx::Error), // #[source] sets source() without enabling From
}
Without #[from] on the Io variant, you would need to write impl From for AppError by hand — exactly the code shown in the previous section. With it, ? works on any function returning std::io::Error inside a function returning Result<T, AppError>, and the generated Display implementation produces messages like "failed to read file: No such file or directory (os error 2)" automatically using the wrapped error’s own Display.
thiserror vs Box dyn Error: When the Derive Macro Is Worth It
thiserror is worth adding as a dependency when callers of your function need to match on specific error variants — a database layer that wants to retry on AppError::Database but not on AppError::Config, for example. Box<dyn Error> erases this information; thiserror preserves it in a typed enum while eliminating the manual trait implementation boilerplate. If nothing downstream ever inspects the error variant — it is only logged or displayed to a user — Box<dyn Error> or anyhow::Error are simpler choices with one less dependency.
rust custom error enum thiserror example: Nested Error Sources
When one of your error variants wraps another custom error type that also implements std::error::Error (rather than a standard library type), #[from] still works — thiserror generates the From implementation regardless of whether the source type is from the standard library, an external crate, or your own code. The generated source() method returns the wrapped error, which is how error-printing libraries display the full chain: "top-level error caused by: middle error caused by: root cause". This chaining is automatic as long as each layer’s error type wraps the layer below with #[source] or #[from].
Clone, Arc, and Lifetime Annotations: Why Your Rust Architecture Is Quietly Bleeding Performance Most mid-level Rust devs hit the same wall: the compiler shuts up, the tests pass, and production quietly burns CPU cycles on...
rust anyhow vs thiserror: When to Use Each
The decision between anyhow and thiserror comes down to one question: does any caller of this function need to match on the specific error variant? If yes, use thiserror — callers can pattern-match on your enum. If no — the error will only be logged, displayed, or propagated to a top-level handler — use anyhow::Error, which erases the type but adds ergonomic context-building methods that Box<dyn Error> does not have.
The community convention, which is also Rust’s own guidance in practice, is: libraries use thiserror, applications use anyhow. A library’s callers are other code that may need to handle specific failure modes differently. An application’s top-level code typically just needs to propagate the error to a log line or an HTTP error response — the specific variant rarely matters at that level.
// Rust — typical layering: thiserror in library code, anyhow in application code
// library crate — callers may want to match on variants
#[derive(thiserror::Error, Debug)]
pub enum DbError {
#[error("connection failed")]
Connection(#[from] std::io::Error),
#[error("query timeout after {0}s")]
Timeout(u64),
}
// application code — just needs to propagate and add context
fn handler() -> anyhow::Result {
let conn = connect_db() // returns Result<_, DbError>
.context("failed to connect to database")?; // anyhow wraps DbError here
Ok(conn.query("SELECT 1")?)
}
Without anyhow::Result in the application layer, the handler function would need to return Result<String, DbError> — but if handler also calls a different library with a different error type, you are back to the type unification problem. anyhow::Result<T> (an alias for Result<T, anyhow::Error>) accepts any error type via ? because anyhow::Error implements From<E> for any E: std::error::Error + Send + Sync + 'static.
Should Libraries Use anyhow?
No — libraries should not return anyhow::Result in their public API. When a library returns anyhow::Error, callers lose the ability to match on specific error variants without downcasting, and the library forces anyhow as a dependency on every consumer even if they would prefer a different error handling approach. The Rust ecosystem convention is firm on this: public library APIs should return concrete, typed errors — typically thiserror-derived enums — so callers retain full information and dependency choice. anyhow belongs in application binaries and internal modules that are not part of a public API.
rust error trait object overhead: Is anyhow Slower?
anyhow::Error is a single pointer-sized type (8 bytes on 64-bit systems) that boxes the underlying error on the heap — this is the same memory layout as Box<dyn Error>. The overhead compared to a typed enum is one heap allocation per error created, which only happens on the error path, not the success path. For error handling — which by definition occurs on exceptional, non-hot-path code — this allocation cost is irrelevant in virtually all applications. The performance argument against anyhow is not about runtime cost; it is about losing type information for callers that need to match on variants.
anyhow context Method: Adding Debug Information to Error Chains
The .context() method, available on any Result via the anyhow::Context trait, wraps an error with an additional message while preserving the original error as the source(). This is the single most useful feature anyhow adds over Box<dyn Error> — it turns a bare "No such file or directory (os error 2)" into "failed to load user config: No such file or directory (os error 2)", with both messages preserved in the error chain for printing.
// Rust — anyhow context() building a readable error chain
use anyhow::{Context, Result};
fn load_user_config(path: &str) -> Result {
let raw = std::fs::read_to_string(path)
.context("failed to read config file")?; // adds context to io::Error
let config: Config = toml::from_str(&raw)
.context("failed to parse config as TOML")?; // adds context to parse error
Ok(config)
}
// printed error: "failed to parse config as TOML: missing field `port` at line 4"
Without .context(), the error returned from a TOML parse failure deep in a dependency chain would print only the parser’s own message — "missing field `port` at line 4" — with no indication of which file or which operation triggered it. In a function that loads five different config files, that message alone does not tell you which file failed. The context message added at each layer turns an opaque error into a readable trace of what the program was doing when it failed.
anyhow context vs with_context: When to Use Each
.context("static message") takes a value that implements Display — use it for fixed strings known at compile time. .with_context(|| format!("failed to process {}", filename)) takes a closure — use it when the context message requires runtime data like a variable filename or ID, because the closure is only evaluated if an error actually occurs, avoiding the cost of formatting a string on the success path. For loops processing many items, always use with_context with a closure — using context with a pre-formatted string means formatting happens on every iteration regardless of whether an error occurs.
rust downcast error anyhow: Recovering the Original Type
When you need to inspect the original error type behind an anyhow::Error — for example, to retry only on a specific I/O error kind — use .downcast_ref::<OriginalType>(), which returns Option<&OriginalType>. This walks the error chain (including context wrappers) looking for an error of the requested type. Downcasting is the escape hatch for the type information that anyhow::Error erases — it should be rare in application code, but it is the correct tool when one specific layer needs to react differently to one specific underlying error type while the rest of the codebase treats errors generically.
Rust Panic at Runtime: Why Your "Safe" App Still Crashes You ship a Rust binary. It compiles clean, zero warnings. Then production logs hit you with thread 'main' panicked at 'called `Option::unwrap()` on a `None`...
FAQ: Rust Error Handling — anyhow vs thiserror
When should I use anyhow vs thiserror?
Use thiserror when callers of your function need to match on specific error variants — this is the standard for library public APIs. Use anyhow when the error will only be logged, displayed, or propagated to a top-level handler without callers needing to distinguish between failure types — this is standard for application binaries. The common pattern: libraries define thiserror enums, application code wraps them in anyhow::Result at the boundary using .context() for additional debug information.
How does the Rust ? operator work with errors?
The ? operator unwraps Ok(value) to value, or returns early with Err(From::from(error)) if the result is Err. The From::from() call is automatic and is what enables type conversion — if your function’s error type implements From<SourceErrorType>, you can use ? on any expression that returns Result<_, SourceErrorType>. If no such From implementation exists, the code does not compile with a “trait bound not satisfied” error.
What does the thiserror derive macro generate?
#[derive(thiserror::Error)] generates an implementation of std::error::Error and Display for your enum or struct. The #[error("...")] attribute on each variant defines the Display output, with field interpolation supported. The #[from] attribute on a field additionally generates a From implementation, enabling ? operator conversion from that source type. All of this is compile-time code generation with zero runtime overhead compared to hand-written implementations.
Should Rust libraries use anyhow in their public API?
No. Returning anyhow::Error or anyhow::Result from a library’s public functions erases the specific error type, forcing callers to downcast if they need to match on variants, and adds anyhow as a transitive dependency for all consumers. Library public APIs should return concrete typed errors — typically a thiserror-derived enum — so callers retain full type information and can choose their own error handling strategy in application code.
What is the difference between thiserror and Box<dyn Error>?
Box<dyn Error> is the standard library’s type-erased error container — any error type can be boxed into it with zero extra dependencies, but callers cannot match on the original variant without downcasting. thiserror generates a typed enum with full std::error::Error and Display implementations, preserving variant information for callers while eliminating the manual boilerplate of writing those implementations by hand. Use Box<dyn Error> for quick prototypes; use thiserror when variant matching matters.
What does anyhow’s context method do?
.context("message") wraps a Result‘s error in a new error that includes your message while preserving the original error as the source(). This builds a readable chain of what the program was doing at each layer when a failure occurred — printing the error shows your context message followed by the original error’s message. Use .with_context(|| ...) instead when the message requires runtime-computed data, since the closure form avoids formatting cost on the success path.
How do I convert between different error types in Rust?
Implement From<SourceError> for YourErrorType manually, or use thiserror‘s #[from] attribute to generate it automatically. Once the From implementation exists, the ? operator performs the conversion automatically when propagating errors. For applications where you do not want to define conversions for every error source, use anyhow::Result — anyhow::Error implements From<E> for any E: std::error::Error + Send + Sync + 'static, so ? works without any manual conversion code.
Is anyhow slower than thiserror at runtime?
The only overhead anyhow::Error adds is one heap allocation when an error is created, since it boxes the underlying error into a pointer-sized type. This happens exclusively on the error path — the success path has zero overhead either way. For virtually all applications, this allocation cost is irrelevant because errors are exceptional events, not hot-path operations. The real tradeoff between anyhow and thiserror is type information for callers, not runtime performance.
Written by: