Rust’s error handling is one of the language’s defining features — and one of its biggest stumbling blocks for newcomers. The patterns settled by 2026; this post is the working set.

The two libraries

thiserror — for libraries

use thiserror::Error;

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

    #[error("conflict: {0}")]
    Conflict(String),

    #[error(transparent)]
    Db(#[from] sqlx::Error),

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

Generates Display, Debug, From — typed error enum your library exposes. Consumers can match on variants.

anyhow — for binaries

use anyhow::{Result, Context};

async fn run() -> Result<()> {
    let cfg = load_config().context("failed to load config")?;
    let db = connect(&cfg.db_url).await.context("db connect")?;
    Ok(())
}

anyhow::Error is a single dynamic error type. No need to define an enum. .context("...") adds breadcrumbs.

When to combine them

Most production Rust apps use both:

  • Library crates (core, db, api): typed errors with thiserror.
  • Binary crates (cli, server): anyhow for top-level handling.
  • Bridge: anyhow auto-converts from any thiserror via the From chain.
// in binary
use anyhow::Result;

async fn handle_request(req: Request) -> Result<Response> {
    let user = svc::get_user(req.id).await?;     // svc returns thiserror, ? converts
    Ok(Response::ok(user))
}

The ? operator

fn process() -> Result<Output, Error> {
    let a = step1()?;
    let b = step2(a)?;
    let c = step3(b)?;
    Ok(c)
}

Replaces verbose match chains. ? returns the error if Err; continues if Ok. Combined with From, errors auto-convert.

Adding context

use anyhow::Context;

let user = db.get_user(id).await
    .context(format!("failed to get user {id}"))?;

When the error bubbles up, the chain looks like:

Error: failed to get user 42

Caused by:
    0: db query failed
    1: connection refused

Without context, you’d see just “connection refused” and have no idea which call.

Convert between error types

When you need to translate one library’s error to your app’s error:

async fn create_user(payload: CreateUser) -> Result<User, AppError> {
    let user = sqlx::query_as!(
        User,
        "INSERT INTO users (...) VALUES (...) RETURNING *",
        ...
    )
    .fetch_one(&pool)
    .await
    .map_err(|e| match e {
        sqlx::Error::Database(db) if db.is_unique_violation() =>
            AppError::Conflict("email taken".into()),
        e => AppError::Db(e),
    })?;
    Ok(user)
}

map_err bridges between error types when From isn’t enough.

Axum integration

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code) = match self {
            Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
            Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
            _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
        };
        (status, Json(json!({"error": code, "message": self.to_string()}))).into_response()
    }
}

Now any handler returning Result<T, AppError> works. The error becomes a proper HTTP response. See Production HTTP Service in Rust .

Panic policy

When to panic! vs return Err:

  • Panic: unrecoverable bug. Invariant broken. unreachable!. Out-of-bounds you proved couldn’t happen.
  • Result: expected failures. User errors. Network. Disk full. Anything that’s not “the program is wrong.”

Set panic = "abort" in Cargo.toml for release:

[profile.release]
panic = "abort"

Smaller binary; faster; no unwinding (which has surprising costs). For libraries, unwind is fine.

Error logging

match do_thing().await {
    Ok(v) => v,
    Err(e) => {
        tracing::error!(error = ?e, "failed to do thing");
        return Err(e.into());
    }
}

?e formats with Debug (gets the cause chain). For final-level handling, {e:?} prints the chain too.

Common mistakes

1. Swallowing errors

let _ = risky_call();           // ⛔ silently ignores

If you really want to discard, comment why. Otherwise propagate.

2. Stringly-typed errors everywhere

fn do_thing() -> Result<(), String> { ... }

Loses type info. Use thiserror or anyhow.

3. Adding Box<dyn Error> manually

That’s reinventing anyhow. Just use anyhow.

4. No context

A bare error 6 calls deep is useless. Add .context(...) at boundaries.

5. unwrap() in non-test code

unwrap() panics if the value is None / Err. Sometimes correct (test, prototype). In production, use ? or explicit handling.

Read this next

If you want my Rust error-handling cheat sheet, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .