Error Handling: thiserror and anyhow
Use case
You need custom error types for libraries (so callers can match on variants) and simple error handling for applications (context and propagation). thiserror simplifies defining error enums; anyhow gives an easy Result<T, anyhow::Error> with .context() for adding context.
In C#: Custom exceptions vs wrapping with inner exception and message. In Rust, errors are values; thiserror and anyhow are helpers, not exceptions.
thiserror: custom error types
Derive Error and implement Display (and optionally From for other error types) with thiserror. Callers can match on your enum variants.
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(String),
}
fn might_fail() -> Result<(), AppError> {
let _ = std::fs::read_to_string("missing.txt")?; // ? converts Io to AppError
Err(AppError::Parse("bad format".into()))
}In C#: Like defining exception types; in Rust the caller uses match on the enum instead of catch blocks.
anyhow: application errors
Use anyhow::Result<T> (alias for Result<T, anyhow::Error>) when you don't need callers to match on variants. Use .context("message") to add context when propagating.
use anyhow::{Context, Result};
fn read_config() -> Result<String> {
let path = "config.toml";
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path))?;
Ok(content)
}In C#: Like wrapping in a higher-level exception with a message; anyhow chains causes and displays a chain of context.
Display vs Debug for errors
This is why the solutions and the example print errors with {:?}, not {}. The two format specifiers show very different things for an anyhow::Error:
{}(theDisplayimpl) prints only the top-level message — the outermost context you attached.{:?}(theDebugimpl) prints the message plus the full cause chain under aCaused by:heading.
use anyhow::{Context, Result};
fn load_settings() -> Result<String> {
std::fs::read_to_string("nonexistent.toml").context("loading settings")
}
fn main() {
if let Err(e) = load_settings() {
eprintln!("Display: {e}"); // Display: loading settings
eprintln!("Debug: {e:?}"); // Debug: loading settings
//
// Caused by:
// No such file or directory (os error 2)
}
}So {:?} is the one you almost always want when reporting an anyhow::Error to a human or a log, because it preserves why it failed all the way down. Use {} only when you deliberately want a one-line summary.
One caveat: this rich {:?} cause-chain output is anyhow::Error's custom Debug impl. A plain thiserror enum is just #[derive(Debug)], so its {:?} prints the Rust value (e.g. Invalid("bad input")), not a Caused by: chain. To walk a thiserror chain manually, call std::error::Error::source() repeatedly (or use anyhow's e.chain() once you've converted into an anyhow::Error).
In C#: {} is like ex.Message (just the immediate message); {:?} is like ex.ToString(), which includes the message and the chain of InnerExceptions. anyhow's cause chain is the equivalent of walking ex.InnerException.
Backtraces: RUST_BACKTRACE vs C# stack traces
In C#, every thrown exception always carries a stack trace — it is captured at throw time and printed by ex.ToString() whether you want it or not. Rust does not do this by default: capturing a backtrace has a cost, so it is opt-in via an environment variable.
RUST_BACKTRACE=1— enable backtrace capture. On apanic!, the panic message is followed by a backtrace. Foranyhow, a backtrace is captured when the error is created and is appended to the{:?}output.RUST_BACKTRACE=full— same, but with more frames and detail.RUST_LIB_BACKTRACE=1— enable backtraces for captured errors (anyhow) while leaving panic backtraces off.
$ RUST_BACKTRACE=1 cargo run
Display: loading settings
Debug: loading settings
Caused by:
No such file or directory (os error 2)
Stack backtrace:
0: ...
5: scratch_verify::load_settings
6: scratch_verify::mainWithout the variable set, the same {:?} output stops after the Caused by: chain — no backtrace, no overhead. (Backtraces rely on std::backtrace::Backtrace, stable since Rust 1.65, so this works on stable anyhow 1.x.)
In C#: Stack traces are always on and you pay for them on every throw. In Rust you opt in per run with RUST_BACKTRACE, which keeps the happy path and error propagation cheap. In both worlds, prefer the cause chain ({:?} / InnerException) for understanding a failure; reach for the backtrace mainly when you need the exact call path.
anyhow ergonomics: anyhow!, bail!, ensure!
anyhow ships three macros that cut boilerplate when you don't have a dedicated error variant:
anyhow!("msg", ...)— builds ananyhow::Errorfrom a format string. It produces a value; it does not return.bail!("msg", ...)— early return with that error. Expands toreturn Err(anyhow!(...)).ensure!(cond, "msg", ...)— guard clause: ifcondis false, return the error. Expands toif !cond { bail!(...) }.
use anyhow::{anyhow, bail, ensure, Result};
fn validate_age(age: i32) -> Result<()> {
ensure!(age >= 0, "age must be non-negative, got {age}");
if age > 150 {
bail!("age {age} is implausible");
}
Ok(())
}
fn make_error() -> anyhow::Error {
anyhow!("standalone error value, code {}", 42)
}In C#: bail! is like throw new Exception($"..."); ensure! is like a guard if (!cond) throw new ... (or an argument check such as ArgumentOutOfRangeException.ThrowIfNegative). anyhow! is new Exception(...) without throwing it — you hold the error as a value. The difference is that these return an Err for the caller to handle, rather than unwinding the stack.
thiserror attributes: #[source] and #[error(transparent)]
Two thiserror attributes control how an error participates in the cause chain:
#[source]marks a field as the underlying cause, soError::source()returns it and tools can walk the chain. (#[from]implies#[source]and generates aFromimpl for?; use a bare#[source]when you want the cause recorded but no automatic conversion — for example when the variant carries extra fields.)#[error(transparent)]forwards bothDisplayandsource()to the wrapped error, adding no message of its own. Use it for a pass-through variant that should look exactly like the inner error.
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
// Adds its own message; #[source] records the cause for the chain.
#[error("could not parse port number")]
Port {
#[source]
source: std::num::ParseIntError,
},
// No new message: Display and source() delegate to the inner io::Error.
#[error(transparent)]
Io(#[from] std::io::Error),
}Here ConfigError::Port displays as could not parse port number, and its source() returns the ParseIntError so the cause survives. ConfigError::Io displays as whatever the io::Error says (e.g. No such file or directory (os error 2)), because transparent hands off entirely.
In C#: #[source] is the innerException argument you pass to a wrapping exception's constructor — it preserves the chain. #[error(transparent)] is closer to not wrapping at all and just rethrowing the original exception, so the caller sees the inner error directly.
When to use which
- Libraries: Prefer thiserror (or manual
Errorimpl) so users can match on your error type. - Applications: Use anyhow for
mainand internal functions; use.context()to add "where" context. You can mix: return thiserror from a library and convert to anyhow with?in the app.
Key takeaway
Use thiserror to define error enums with #[error(...)], #[from], #[source], and #[error(transparent)]. Use anyhow for app-level Result<T> with .with_context(...), plus anyhow!/bail!/ensure! for ad-hoc errors. Print errors with {:?} to get the full cause chain ({} shows only the top message), and set RUST_BACKTRACE=1 when you also need a backtrace. Both crates work with ?.