thiserror or anyhow: the line is the library boundary
Every Rust error-handling discussion eventually produces the sentence “use thiserror for libraries
and anyhow for applications”. It is correct and it is unhelpful, because most code is not
obviously either.
The version I use now is sharper: can the caller do anything different depending on which error this is?
If yes, they need distinct types they can match on — thiserror. If no, they are going to log it
and give up, and all they need is a good message — anyhow.
What each one gives you
thiserror is a derive macro for building your own error enum. It writes the Display and Error
implementations and the From conversions, and produces a plain type with no runtime cost:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("config file not found at {path}")]
NotFound { path: PathBuf },
#[error("invalid TOML at line {line}")]
Malformed { line: usize, #[source] source: toml::de::Error },
#[error(transparent)]
Io(#[from] std::io::Error),
}
The caller can match:
match load_config(&path) {
Err(ConfigError::NotFound { .. }) => Config::default(), // recoverable
Err(e) => return Err(e),
Ok(config) => config,
}
anyhow goes the other way. anyhow::Error is a single boxed type holding any error, plus a chain
of context strings:
use anyhow::{Context, Result};
fn start() -> Result<()> {
let config = load_config(&path)
.context("loading configuration")?;
let db = connect(&config.database_url)
.with_context(|| format!("connecting to {}", config.database_url))?;
Ok(())
}
When that fails, the message is a story rather than a symptom:
Error: connecting to postgres://localhost/app
Caused by:
0: connection refused
1: os error 61
The ? operator converts anything implementing Error into anyhow::Error automatically, which is
why anyhow code has so little error handling in it.
The .context() habit is the real win
I want to argue that the biggest practical benefit of anyhow is not the type at all. It is that
.context() is so cheap to write that you actually write it.
An error that says No such file or directory (os error 2) is nearly useless. The same error with
three context frames tells you which file, which operation, and which request it belonged to. I add
context at every ? that crosses a meaningful boundary, and debugging production issues went from
guesswork to reading.
Tip
Use with_context(|| …) rather than context(…) when the message needs formatting. context()
evaluates its argument eagerly, so building a String there costs an allocation on every call,
including the ones that succeed. The closure version only runs on the error path.
Where the line actually falls
The library-versus-application framing breaks down because an application has internal modules that behave like libraries. The question that keeps working:
thiserror when the caller branches on the error. A parser returning “malformed at line 12”
versus “unsupported version” — the caller shows different UI. A network layer distinguishing
“offline” from “unauthorised” — one retries, one logs you out.
anyhow when the caller propagates. main, request handlers, CLI commands, anything whose error
path is “log it and return non-zero”.
In practice a service ends up with both: thiserror enums in the modules that model real failure
modes, and anyhow in the layer that composes them.
// domain module: callers care
#[derive(Error, Debug)]
pub enum AuthError {
#[error("invalid credentials")]
InvalidCredentials,
#[error("account locked until {0}")]
Locked(DateTime<Utc>),
}
// handler: callers do not
async fn login(form: Form<Login>) -> anyhow::Result<Response> {
let session = auth::login(&form.email, &form.password)
.await
.context("login")?;
Ok(session.into_response())
}
Do not put anyhow in a public API
This is the one hard rule. anyhow::Error in a library’s public signature takes away the caller’s
ability to handle anything specifically — they get a message and nothing else. It also drags
anyhow into their dependency tree whether they wanted it or not.
The compiler will not stop you and it will make your library annoying to use. If a public function can fail in ways the caller might handle differently, give them a type.
Two things worth knowing
anyhow requires Send + Sync + 'static. Most errors satisfy this. Ones holding a non-Send
type do not, and the error message when it fails is about trait bounds rather than about your error,
which is confusing the first time.
Backtraces are free but need enabling. anyhow captures one automatically if the standard
library’s backtrace support is available; set RUST_BACKTRACE=1 to see it. On a server, this is the
difference between “something failed in the auth module” and an exact line.
What I settled on
For my own services: thiserror in each domain module, anyhow from the handler layer up,
.context() on every ? that crosses a module boundary, and anyhow::bail! for the one-off
validation failures that do not deserve a variant.
That combination took about a day to apply across a codebase and paid for itself the first time something broke at three in the morning and the log said exactly what had happened.