Choosing the error strategy per crate: who consumes the error decides its shape. Code reacts to errors (match, retry, map to status codes); humans read errors (logs, terminal, bug reports). Libraries serve code, applications serve humans, and services usually need both at once.
Contents
- 1. The decision
- 2. thiserror in one page
- 3. anyhow in one page
- 4. Using both in one workspace
- 5. Alternatives
- 6. Hand-written errors without any crate
- 7. Migration paths
- 8. Checklist
1. The decision
| Question | If yes | If no |
|---|---|---|
Will another crate (or another module you do not own) match on this error? | typed enum (thiserror) | anyhow is fine |
Is this a main.rs, CLI, test, build script, migration tool, one-off job? | anyhow (or eyre) | typed |
| Is the error turned into an HTTP/gRPC status? | typed variants for every non-500 case, plus an Internal(anyhow::Error) catch-all | |
| Does the error need source-code snippets with labels? | miette | |
Do you want colored reports + tracing span traces in a CLI? | eyre + color-eyre | anyhow |
| Must the public API hide which dependencies you use? | opaque struct with kind() | enum is fine |
Rules of thumb:
- A library never returns
anyhow::Errorfrom a public function. The caller cannot distinguish "not found" from "disk full" without downcasting to types you never promised. - An application gains little from a giant crate-wide enum that only ever gets printed. Use anyhow and spend the effort on context messages.
- A service sits in between: the HTTP layer needs to know "this is a 404", but database and I/O failures are all "500, log it". Typed for what callers react to, anyhow for the rest.
2. thiserror in one page
[dependencies]
thiserror = "2"#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FetchError {
#[error("invalid url {url:?}")]
InvalidUrl { url: String, #[source] source: url::ParseError },
#[error("server returned {status} for {url}")]
Status { url: String, status: u16 },
#[error("request to {url} timed out after {timeout_ms} ms")]
Timeout { url: String, timeout_ms: u64 },
#[error("transport error")]
Transport(#[from] std::io::Error),
}What the derive generates:
impl std::fmt::Displayfrom the#[error(...)]attributesimpl std::error::Errorwithsource()returning the#[source]/#[from]field (or a field namedsource)impl From<std::io::Error> for FetchErrorfor each#[from]
Nothing else. No runtime, no trait objects, no allocation. The generated code only references std/core
traits, so thiserror is not part of your public API: callers never need to depend on it, and you can remove
it later by writing the impls by hand without a breaking change.
3. anyhow in one page
[dependencies]
anyhow = "1"use anyhow::{Context, Result};
fn main() -> Result<()> {
let url = std::env::var("FEED_URL").context("FEED_URL is not set")?;
let body = fetch(&url).with_context(|| format!("fetching feed {url}"))?;
let items = parse_feed(&body).context("parsing feed XML")?;
println!("{} items", items.len());
Ok(())
}anyhow::Errorholds anyE: std::error::Error + Send + Sync + 'static, plus a context chain and an optional backtrace. It is a single pointer wide, soResult<T, anyhow::Error>stays small.anyhow::Result<T>isResult<T, anyhow::Error>; the second parameter defaults, soanyhow::Result<T, MyError>also works.Contextadds a message layer without losing the original:err.downcast_ref::<std::io::Error>()still finds it.anyhow::Erroritself does not implementstd::error::Error(that would conflict with its blanketFrom<E: Error>impl). It derefs todyn Error + Send + Sync + 'static, implementsAsRef<dyn Error + Send + Sync>, and converts intoBox<dyn Error + Send + Sync + 'static>.
4. Using both in one workspace
workspace/
crates/store/ library: thiserror StoreError, pub type Result<T, E = StoreError>
crates/indexer/ library: thiserror IndexError { Store(#[from] StoreError), ... }
crates/api/ service: thiserror ApiError (typed 4xx) + Internal(#[from] anyhow::Error)
crates/cli/ binary: anyhow everywhere, main() -> anyhow::Result<()>The binary keeps the typed information available:
use anyhow::Context;
use store::StoreError;
fn show(id: u64) -> anyhow::Result<()> {
match store::get(id) {
Ok(item) => println!("{item}"),
Err(StoreError::NotFound { .. }) => println!("no item {id}"), // expected case: handled, not an error
Err(other) => return Err(other).with_context(|| format!("loading item {id}")),
}
Ok(())
}
fn exit_code(err: &anyhow::Error) -> u8 {
// Any layer of the chain can be inspected after context was added.
if let Some(StoreError::Io(_)) = err.downcast_ref::<StoreError>() { 74 } else { 1 } // 74 = EX_IOERR
}Library-to-library: wrap the lower error as a variant of the higher one when callers of the higher crate need to react to it; otherwise wrap it as an opaque source with a description of what the higher crate was doing:
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum IndexError {
#[error("document {doc_id} not found in store")]
MissingDocument { doc_id: u64 }, // callers react: skip or re-fetch
#[error("failed to load segment {segment}")]
Load { segment: u32, #[source] source: store::StoreError }, // callers just report it
}5. Alternatives
snafu (context selectors)
use snafu::{ResultExt, Snafu};
use std::path::PathBuf;
#[derive(Debug, Snafu)]
pub enum ConfigError {
#[snafu(display("could not read config {}", path.display()))]
Read { path: PathBuf, source: std::io::Error },
#[snafu(display("invalid TOML in {}", path.display()))]
Parse { path: PathBuf, source: toml::de::Error },
}
pub fn load(path: PathBuf) -> Result<String, ConfigError> {
let text = std::fs::read_to_string(&path).context(ReadSnafu { path: path.clone() })?;
Ok(text)
}- Each variant gets a generated "context selector" (
ReadSnafusince snafu 0.7) that you pass to.context(..), so the same source type (io::Error) can map to several variants with different context, which is exactly where thiserror's#[from]stops working. - Also offers
ensure!,whatever!and aWhatevertype for application-style stringly errors. - Choose it when nearly every conversion needs context fields; choose thiserror when most are 1:1.
eyre and color-eyre
use color_eyre::eyre::{Result, WrapErr};
fn main() -> Result<()> {
color_eyre::install()?; // installs panic + error report hooks
let cfg = std::fs::read_to_string("app.toml").wrap_err("reading app.toml")?;
run(&cfg)
}eyre::Reportis an anyhow fork with a pluggable report handler.wrap_err/wrap_err_withare the equivalents ofcontext/with_context;eyre::ContextCompatprovides.context()onOptionfor anyhow-style code.- color-eyre renders colored reports, optional span traces via
tracing-error, and custom sections (.suggestion(..),.note(..)fromcolor_eyre::Section). - Choose it for developer-facing CLIs. Do not mix anyhow and eyre in the same binary without reason; pick one.
miette (diagnostics)
use miette::{Diagnostic, NamedSource, SourceSpan};
#[derive(Debug, thiserror::Error, Diagnostic)]
#[error("unknown key `{key}`")]
#[diagnostic(code(config::unknown_key), help("valid keys are: listen, workers, log_level"))]
pub struct UnknownKey {
key: String,
#[source_code]
src: NamedSource<String>,
#[label("not a recognised key")]
span: SourceSpan,
}
fn main() -> miette::Result<()> {
check("app.toml")?; // fancy report needs miette's "fancy" feature in the binary
Ok(())
}- miette builds on thiserror: the error is still a std error;
Diagnosticadds code, help, URL, labels and source snippets. miette::IntoDiagnostic::into_diagnostic()converts ordinary errors intomiette::Report.- Choose it for compilers, linters, config validators: anything where the user must see where in an input the problem is.
error-stack
Report<C>carries a current context typeCplus a stack of attachments; at layer boundaries you call.change_context(MyLayerError)and add data with attachment methods. Good for large codebases that want typed contexts and rich attachments; heavier API than thiserror + anyhow. Check the docs of the version you use: attachment method names have changed between releases.
Comparison
| Crate | Error type | Typed matching | Context | Reports | Best for |
|---|---|---|---|---|---|
| thiserror 2 | your enum/struct | yes | fields in variants, map_err | your Display | libraries, service error enums |
| anyhow 1 | anyhow::Error | via downcast_ref | .context() | {:#}, {:?} with chain + backtrace | binaries, tests |
| snafu | your enum | yes | selectors with fields | your Display | libraries with many contextual wraps |
| eyre + color-eyre | eyre::Report | via downcast | .wrap_err() | colored, sections, span traces | CLIs |
| miette | your type + Diagnostic | yes | labels, help | snippets with labels | compilers, linters, config tools |
| error-stack | Report<C> | context type | change_context + attachments | tree of frames | large systems |
6. Hand-written errors without any crate
Zero dependencies is a fine choice for small libraries, no_std crates or when compile time matters:
use std::{error::Error, fmt};
#[derive(Debug)]
#[non_exhaustive]
pub enum ParseError {
Empty,
BadNumber { line: usize, source: std::num::ParseIntError },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("input is empty"),
Self::BadNumber { line, .. } => write!(f, "invalid number on line {line}"),
}
}
}
impl Error for ParseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Empty => None,
Self::BadNumber { source, .. } => Some(source),
}
}
}That is exactly what thiserror would generate for:
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ParseError {
#[error("input is empty")]
Empty,
#[error("invalid number on line {line}")]
BadNumber { line: usize, #[source] source: std::num::ParseIntError },
}7. Migration paths
Box<dyn Error> everywhere -> structured
- Make it at least
Box<dyn Error + Send + Sync + 'static>(type aliasBoxError). Nothing else changes, and async/threads start working. - Binaries: replace the alias with
anyhow::Result;?keeps working for every std error. Add context. - Libraries: introduce
pub enum Errorper module, starting with the variants callers already distinguish (search fordowncast_refandto_string().contains(at call sites).
Result<T, String> -> typed
// before
pub fn parse_port(s: &str) -> Result<u16, String> {
s.parse().map_err(|e| format!("bad port {s}: {e}"))
}
// after
#[derive(Debug, thiserror::Error)]
#[error("invalid port {input:?}")]
pub struct PortError {
input: String,
#[source]
source: std::num::ParseIntError,
}
pub fn parse_port(s: &str) -> Result<u16, PortError> {
s.parse().map_err(|source| PortError { input: s.to_owned(), source })
}Library returning anyhow -> typed
Keep anyhow internally while you migrate: add a temporary catch-all variant and shrink it over time.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("record {0} not found")]
NotFound(u64),
/// Temporary: everything not yet classified. Remove before 1.0.
#[error(transparent)]
Other(#[from] anyhow::Error),
}#[non_exhaustive] lets you split Other into real variants later without a breaking release (callers
already have a _ => arm).
anyhow -> eyre (or back)
Mostly mechanical: anyhow::Result -> eyre::Result, Context::context -> WrapErr::wrap_err,
anyhow! -> eyre!, bail!/ensure! exist in both. Install the report hook first thing in main.
8. Checklist
- Every library crate exposes typed errors; no
anyhowinpub fnsignatures - Every binary returns
anyhow::Result<()>(orExitCode) frommain - Error enums are
#[non_exhaustive]andSend + Sync + 'static - No dependency error type you intend to swap is a public field or
#[from] - Services: one
ApiErrorwith typed 4xx variants and anInternalcatch-all - Context added on every
?that crosses an I/O or parse boundary in application code
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.