Error handling in Rust with thiserror and anyhow
1. Overview
Rust forces you to handle errors explicitly via Result<T, E>. This approach is safe, but writing error types by hand is verbose. You must manually implement Display, Error, and From for each case.
This article introduces the two most popular crates to solve this problem. thiserror helps you define structured error types. anyhow helps you consolidate all errors into one type and attach context. After reading, you’ll know which crate to use where, how to combine them, and how to test errors.
2. The problem with writing errors manually
Suppose you’re reading the quantity of an order from a file. Two errors can occur: I/O errors and parse errors. Without a library, you’d write something like this:
use std::fmt;
#[derive(Debug)]
pub enum OrderError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderError::Io(_) => write!(f, "could not read order file"),
OrderError::Parse(_) => write!(f, "order data has invalid format"),
}
}
}
impl std::error::Error for OrderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OrderError::Io(e) => Some(e),
OrderError::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for OrderError {
fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}
impl From<std::num::ParseIntError> for OrderError {
fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}
You need nearly 30 lines of boilerplate for just two variants. Every time you add a variant, you have to edit four places. This is exactly what thiserror does for you.
Another shortcut is to use Box<dyn std::error::Error>. This is concise, but it loses type information and doesn’t let you attach context. anyhow is a better version of this shortcut.
3. Installation
Add two dependencies to Cargo.toml:
[dependencies]
thiserror = "2"
anyhow = "1"
These two crates are independent. A library typically only needs thiserror. A binary usually uses anyhow, sometimes both.
4. thiserror: structured error types for libraries
4.1. Defining an error enum
With thiserror, the code from section 2 shrinks to:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("order #{0} not found")]
NotFound(u64),
#[error("invalid quantity: {quantity} (must be > 0)")]
InvalidQuantity { quantity: i32 },
#[error("could not read order file")]
Io(#[from] std::io::Error),
#[error("order data has invalid format")]
Parse(#[from] std::num::ParseIntError),
}
Each attribute has its own role:
#[derive(Error)]generates an implementation ofstd::error::Error.#[error("...")]generatesDisplay.{0}refers to the first field of a tuple variant.{quantity}refers to a named field.#[from]generatesimpl From<std::io::Error> for OrderError. This lets the?operator automatically convert errors. A field with#[from]also automatically becomes thesource().
Now let’s write a function using this error type:
use std::fs;
pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
let quantity: i32 = raw.trim().parse()?;
if quantity <= 0 {
return Err(OrderError::InvalidQuantity { quantity });
}
Ok(quantity)
}
pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
let content = fs::read_to_string(path)?;
parse_quantity(&content)
}
The fs::read_to_string function returns io::Error. The ? operator calls From to convert it to OrderError::Io. Similarly, ParseIntError gets converted to OrderError::Parse. Callers can match each variant to handle them separately.
4.2. Traversing the error chain with #[source]
Sometimes you want to keep the original error but don’t want automatic conversion via From. In that case, use #[source] instead of #[from]:
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("could not update inventory for product {sku}")]
UpdateFailed {
sku: String,
#[source]
cause: std::io::Error,
},
}
This variant has an extra field sku, so From can’t be generated automatically. Here, #[source] just declares cause as the error source. You can traverse the error chain like this:
use std::error::Error as _;
fn print_chain(err: &dyn std::error::Error) {
eprintln!("Error: {err}");
let mut current = err.source();
while let Some(cause) = current {
eprintln!(" caused by: {cause}");
current = cause.source();
}
}
When you call print_chain with an UpdateFailed wrapping a “permission denied” error, you’ll see:
Error: could not update inventory for product SKU-42
caused by: Permission denied (os error 13)
4.3. #[error(transparent)]
transparent passes through the Display and source() to the inner error. You use it when a variant is just a wrapper and adds no information:
#[derive(Debug, Error)]
pub enum PaymentError {
#[error("card was declined")]
CardDeclined,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
The Other variant serves as a catch-all for other errors. Specific errors that callers need to handle have their own variants. Everything else gets collected into Other.
5. anyhow: one error type for the whole application
At the application layer, you rarely match each error type. Usually you just need to log the error or print it to the user, with enough information to understand what went wrong. anyhow::Error serves exactly this need.
5.1. anyhow::Result and context
use anyhow::{Context, Result};
fn read_port(path: &str) -> Result<u16> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("could not read config file {path}"))?;
let port: u16 = raw
.trim()
.parse()
.context("port must be an integer between 0 and 65535")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port("app.conf")?;
println!("Server running on port {port}");
Ok(())
}
Here’s how the code works:
anyhow::Result<T>is an alias forResult<T, anyhow::Error>.- Any type implementing
std::error::Error + Send + Sync + 'staticcan be converted toanyhow::Errorvia?. .context(...)wraps the error in a new message, keeping the original error as the cause..with_context(|| ...)takes a closure. The closure only runs if there’s an error, so you avoid unnecessaryformat!calls on the happy path.
When main returns an Err because the file doesn’t exist, the program prints:
Error: could not read config file app.conf
Caused by:
No such file or directory (os error 2)
You have three ways to format an anyhow::Error:
{}prints only the outermost message.{:#}prints the entire chain on one line, separated by:.{:?}prints a multi-line format like above.
Using {:#} when logging a single line is most convenient.
Context also works with Option. It’s a concise way to turn None into a meaningful error:
use anyhow::{Context, Result};
use std::collections::HashMap;
fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
customers.get(&id).with_context(|| format!("no customer with ID #{id}"))
}
5.2. bail!, ensure!, and anyhow!
These three macros help create ad-hoc errors without defining a type:
use anyhow::{anyhow, bail, ensure, Result};
fn apply_discount(total: u64, percent: u8) -> Result<u64> {
ensure!(percent <= 100, "invalid discount percentage: {percent}");
if total == 0 {
bail!("cannot apply discount to a zero-dollar order");
}
total
.checked_sub(total * percent as u64 / 100)
.ok_or_else(|| anyhow!("overflow when calculating discount"))
}
ensure!(cond, ...)returns an error if the condition is false.bail!(...)is equivalent toreturn Err(anyhow!(...)).anyhow!(...)creates ananyhow::Errorvalue without returning.
For example, apply_discount(500_000, 120) returns an error invalid discount percentage: 120.
5.3. Downcasting when you still need to classify errors
anyhow::Error still preserves the original type. You can downcast to handle a special case:
fn handle(err: &anyhow::Error) {
match err.downcast_ref::<OrderError>() {
Some(OrderError::NotFound(id)) => println!("Show 404 page for order #{id}"),
Some(other) => println!("Order error: {other}"),
None => println!("System error: {err:#}"),
}
}
downcast_ref still works when the error has been wrapped with .context(...). However, if you downcast frequently, that’s a sign you should use a thiserror enum instead of anyhow.
6. Combining: thiserror at the bottom, anyhow at the top
A simple and effective rule:
- Libraries or domain modules use
thiserror. Callers need to know exactly what error occurred to decide how to handle it. - Binaries, CLIs, and handlers at the outermost layer use
anyhow. Here you mainly report the error.
use anyhow::{Context, Result};
fn process_order_file(path: &str) -> Result<()> {
let quantity = load_quantity(path)
.with_context(|| format!("failed to process order file {path}"))?;
println!("Order has {quantity} items");
Ok(())
}
The load_quantity function returns a structured OrderError. The upper layer adds context about which file is being processed. When run with a file containing -3, you’ll see:
Error: failed to process order file orders/1001.txt
Caused by:
invalid quantity: -3 (must be > 0)
7. Testing error behavior
Structured error types make testing precise. You can check the exact variant instead of doing fuzzy string comparisons:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_quantity_returns_invalid_quantity() {
let err = parse_quantity("-3").unwrap_err();
assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
assert_eq!(err.to_string(), "invalid quantity: -3 (must be > 0)");
}
#[test]
fn non_numeric_input_returns_parse_error() {
let err = parse_quantity("abc").unwrap_err();
assert!(matches!(err, OrderError::Parse(_)));
}
#[test]
fn missing_config_keeps_io_error_as_root_cause() {
let err = read_port("/nonexistent/path.conf").unwrap_err();
assert_eq!(
err.to_string(),
"could not read config file /nonexistent/path.conf"
);
assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
}
}
Run tests with:
cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok
test result: ok. 3 passed; 0 failed
The third test shows that root_cause() goes to the deepest error in the chain. This lets you confirm that context doesn’t lose the original error.
8. Common pitfalls and when not to use
Note: Don’t repeat the message of the source error in
#[error]. If you write#[error("io error: {0}")] Io(#[from] io::Error), the source message will appear twice when you print the whole chain with{:?}or{:#}. Choose one: either embed{0}and don’t declare source, or declare source and keep the message concise.
Note: Don’t use
anyhow::Errorin the public API of a library. Callers won’t be able tomatchthe error and will be forced to downcast. Expose athiserrorenum instead.
Some other errors you might encounter:
- Two variants both have
#[from]for the same source type. For example, two variants wrappingio::Errorwould generate two conflictingimpl Fromblocks, causing a compile error. Use#[source]combined withmap_errfor at least one variant. - Overusing
#[from]. If allio::Errors becomeOrderError::Io, you lose information about what operation failed. A variant with a context field, likeUpdateFailed { sku, cause }, is often more useful. - Forgetting that
anyhow::Errordoesn’t implementstd::error::Error. It derefs todyn Errorbut doesn’t implement the trait itself. You can’t pass it to a place that requiresE: std::error::Error. If you need to, wrap it in a variant with#[error(transparent)]. - Calling
.context(format!(...)). The string gets formatted even if there’s no error. Use.with_context(|| ...)instead. - Extremely performance-sensitive code or
no_stdenvironments.anyhow::Errorallocates on the heap. In hot paths or embedded settings, a small compact enum is a better choice.
If you want a backtrace, set the RUST_BACKTRACE=1 environment variable when running. anyhow will print a backtrace along with the error.
9. Conclusion
thiserror and anyhow solve two different needs. thiserror eliminates boilerplate when defining error types, suitable for libraries and domain layers where callers need to classify errors. anyhow consolidates all errors into one type and lets you attach context at each step, suitable for binaries and the outermost layer.
You should combine both: define structured errors at the bottom, wrap context at the top. Finally, keep messages concise, don’t repeat the source error, and write tests that check for the right variant instead of just comparing strings.