Home

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.