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 ?.