background-shape
Rust Error Handling, Result, ?, thiserror, anyhow
March 9, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — Errors are values, returned via Result<T, E>. ? propagates them up. Use thiserror to define typed errors in libraries; use anyhow for application top-level error aggregation. No exceptions; no try/catch; explicit at every layer.

After ownership and lifetimes, error handling is the third paradigm shift. Rust has no exceptions. Failures are return values. The ? operator threads them up the call stack cleanly. After a week of using it, exception-based languages start to feel sloppy.

This post is the patterns I use in actual backend Rust code: when to use thiserror, when to use anyhow, and how to organize errors so they’re useful both for the caller and for logs.

The Result type

Every fallible function returns a Result<T, E>:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

That’s the whole machinery. A function that parses a number:

fn parse(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse::<i32>()
}

match parse("42") {
    Ok(n)  => println!("got {}", n),
    Err(e) => println!("oops: {}", e),
}

No exceptions to catch. No hidden control flow. The function signature tells you it can fail.

The ? operator: propagating errors

In Go, you write if err != nil { return err } everywhere. In Rust, you write ?:

fn double(s: &str) -> Result<i32, std::num::ParseIntError> {
    let n = s.parse::<i32>()?;   // if Err, return it; if Ok, unwrap
    Ok(n * 2)
}

The ? is shorthand for “if this is Err, return the error from the enclosing function; otherwise, unwrap to the inner value.” Way less ceremony than the Go equivalent.

? requires that the error type of the call is convertible into the function’s return error type. The From trait handles the conversion. For library code with custom errors, you implement From (or thiserror does it for you). For application code with anyhow, anything implementing std::error::Error converts automatically.

thiserror: typed errors for libraries

If you’re writing a library or a discrete module, you want callers to be able to pattern-match on your errors. thiserror makes defining these ergonomic:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum BillingError {
    #[error("subscription {id} not found")]
    NotFound { id: String },

    #[error("payment declined: {reason}")]
    PaymentDeclined { reason: String },

    #[error("database error")]
    Database(#[from] sqlx::Error),

    #[error("invalid amount: {0}")]
    InvalidAmount(i64),
}

What this gives you:

  • A typed enum the caller can match on
  • Display and Error traits derived for you
  • From<sqlx::Error> derived automatically (the #[from] attribute), so ? works on sqlx calls
  • The {id} syntax in #[error("...")] pulls from fields, like format strings

Caller-side usage:

match service.refund(id).await {
    Ok(credit_note) => Ok(credit_note),
    Err(BillingError::NotFound { id }) => {
        // map to a 404
        Err(api::not_found(format!("subscription {} not found", id)))
    }
    Err(BillingError::PaymentDeclined { reason }) => {
        // map to a 402 with reason
        Err(api::payment_required(reason))
    }
    Err(e) => {
        // unknown — log + 500
        tracing::error!(error = ?e, "refund failed");
        Err(api::internal_error())
    }
}

Typed errors at module boundaries; arbitrary handling at the edges.

anyhow: when you don’t care about the type

For top-level application code — main, request handlers, batch jobs — you often want to aggregate errors of any type with context and bail. anyhow is the tool:

use anyhow::{Context, Result};

fn load_config() -> Result<Config> {
    let raw = std::fs::read_to_string("config.toml")
        .context("reading config.toml")?;
    let cfg: Config = toml::from_str(&raw)
        .context("parsing config.toml")?;
    Ok(cfg)
}

fn main() -> Result<()> {
    let cfg = load_config().context("loading config")?;
    run(cfg)?;
    Ok(())
}

anyhow::Result<T> is shorthand for Result<T, anyhow::Error> — a wrapper that holds any error implementing std::error::Error. .context("...") adds a layer of explanation; the full chain prints with {:#}:

Error: loading config

Caused by:
    0: parsing config.toml
    1: invalid TOML at line 4: expected `=`, found `{`

anyhow is the right tool when the caller can’t usefully distinguish the error types — they just need to know “this failed, here’s why, give up.”

When to use which

The simple rule:

  • Libraries / shared modules: thiserror enums. Typed boundaries.
  • Application top-level / handlers: anyhow::Result. Aggregated, contextual.

Within a single service:

  • Your internal/billing module uses thiserror-defined errors so the handler can pattern-match
  • Your cmd/billing/main.rs uses anyhow so config loading, runtime setup, and similar boot-time failures can bail cleanly with context

Don’t use anyhow in library code — you’d be hiding the error types from your callers. Don’t use thiserror in your main — you’d be inventing enum variants for things that just need to fail loudly.

Mapping errors at boundaries

When you call a library that uses thiserror from code that uses anyhow, conversion is automatic:

// Library function returning typed error
fn library_call() -> Result<i32, BillingError> { ... }

// Application function using anyhow
fn app_function() -> anyhow::Result<i32> {
    let n = library_call().context("calling billing")?;
    Ok(n)
}

The ? works because BillingError: Error, and anyhow::Error implements From<E> for any E: Error. Add .context() for human-readable layering.

HTTP errors in handlers

For HTTP handlers (which you’ll meet in the Axum post), the pattern is:

  1. Handler signature returns Result<Json<T>, AppError>
  2. AppError is a wrapper that maps domain errors to HTTP status codes
  3. Implement IntoResponse for AppError so Axum knows how to serialize it
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error(transparent)]
    Billing(#[from] BillingError),

    #[error("unauthorized")]
    Unauthorized,

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            AppError::Billing(BillingError::NotFound { .. }) => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Billing(BillingError::PaymentDeclined { .. }) => (StatusCode::PAYMENT_REQUIRED, self.to_string()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
            _ => {
                tracing::error!(error = ?self, "internal error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
            }
        };
        (status, body).into_response()
    }
}

Now any ? in a handler that surfaces a BillingError automatically returns the right HTTP code. Clean separation: domain errors stay typed; HTTP mapping happens at the boundary.

Common Pitfalls

.unwrap() in production code. Crashes the process on Err. Fine for prototypes; never in checked-in code. Use ? or match.

Using Box<dyn Error> instead of a real enum. Loses information. Forces the caller to downcast. Use thiserror for libraries.

Forgetting #[from] in thiserror. Without it, ? won’t auto-convert. You’ll write boilerplate .map_err() calls.

anyhow in library code. The library’s callers want to know what can go wrong. anyhow hides that. Reserve anyhow for the app’s top-level.

Catch-all error variants with no info. OtherError(String) loses context. Either add structured fields, or wrap the original via #[from].

Missing .context() on ?. When something fails six layers deep, Error: connection refused is useless. Error: loading config / Caused by: connection refused while reading config from etcd is actionable.

Returning Result<(), String>. Strings aren’t errors. Use Box<dyn Error>, anyhow::Error, or a real thiserror enum.

Wrapping Up

Result + ? + thiserror + anyhow cover essentially all error-handling needs in backend Rust. The discipline is more explicit than try/catch, the error paths are typed, and the resulting code makes failure modes obvious in the signature. Friday: cargo workspaces — how to lay out a real Rust backend project with multiple binaries and shared code.