Error Handling in Production: thiserror, anyhow, and When to Use Which

Rust's error handling is genuinely good. The Result<T, E> type forces you to handle errors at the type level, the ? operator propagates them concisely, and the ecosystem has standardized on traits that make errors composable. The tooling — thiserror and anyhow — is stable and excellent.

The confusion comes from having two crates that both deal with errors, used for different purposes. This chapter explains the distinction, which is simpler than most blog posts make it sound.

The Rule

thiserror — for library code and domain errors. Use it when the caller needs to match on the error type and do different things based on which error occurred.

anyhow — for application code. Use it when you just need to propagate errors and eventually log or return a 500.

A backend application uses both. Your database layer defines typed errors with thiserror. Your request handlers return anyhow::Result or your own anyhow-based error type and convert domain errors to HTTP responses.

thiserror: Typed Domain Errors

[dependencies]
thiserror = "1"
#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserError {
    #[error("User not found: {id}")]
    NotFound { id: uuid::Uuid },

    #[error("Email already registered: {email}")]
    EmailConflict { email: String },

    #[error("Invalid password")]
    InvalidPassword,

    #[error("Account locked: too many failed attempts")]
    AccountLocked,

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
}
}

#[error("...")] implements std::fmt::Display. #[from] implements From<sqlx::Error> for UserError, so you can use ? on sqlx calls in functions that return Result<_, UserError>.

The #[from] attribute generates exactly one conversion. If you have two error types you want to convert from, you can only #[from] one of them per variant — the other you convert manually or via a separate From impl.

Matching Domain Errors in Handlers

#![allow(unused)]
fn main() {
async fn create_user_handler(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    match create_user(&pool, payload).await {
        Ok(user) => Ok((StatusCode::CREATED, Json(user))),
        Err(UserError::EmailConflict { email }) => {
            Err(ApiError::Conflict(format!("Email already registered: {email}")))
        }
        Err(UserError::Database(e)) => {
            tracing::error!("Database error creating user: {e}");
            Err(ApiError::Internal)
        }
        Err(e) => {
            tracing::error!("Unexpected error: {e}");
            Err(ApiError::Internal)
        }
    }
}
}

This is the value of typed errors: the handler can distinguish between "email conflict → 409" and "database error → 500" without stringly-typed error messages.

anyhow: Pragmatic Error Propagation

[dependencies]
anyhow = "1"
#![allow(unused)]
fn main() {
use anyhow::{Context, Result};

pub async fn startup_checks(config: &Config) -> Result<()> {
    // Context adds a message to the error chain
    let pool = create_pool(&config.database_url)
        .await
        .context("Failed to connect to database")?;

    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .context("Failed to run database migrations")?;

    ping_cache(&config.redis_url)
        .await
        .context("Failed to connect to Redis")?;

    Ok(())
}
}

anyhow::Error is a boxed dyn std::error::Error + Send + Sync. It erases the specific error type, which means you lose the ability to match on it — but you gain the ability to use ? with any error type, compose errors freely, and add context at each layer.

The Context trait (from anyhow) adds .context() and .with_context() to Result values. The context message is prepended to the error chain, which shows up when you print with {:#}:

Failed to run database migrations: error connecting to database: connection refused (os error 111)

Each context() call adds a layer to the error chain, making it clear where the error originated.

anyhow in Axum Handlers

anyhow::Error doesn't implement IntoResponse, so you can't return it directly from Axum handlers. You need a thin wrapper:

#![allow(unused)]
fn main() {
use anyhow::Error;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};

/// Wraps anyhow::Error for use as an Axum response.
/// Logs the error and returns a 500.
pub struct ServerError(Error);

impl IntoResponse for ServerError {
    fn into_response(self) -> Response {
        tracing::error!("Internal server error: {:#}", self.0);
        StatusCode::INTERNAL_SERVER_ERROR.into_response()
    }
}

// Allow using ? with anyhow::Error in handlers
impl<E: Into<Error>> From<E> for ServerError {
    fn from(err: E) -> Self {
        Self(err.into())
    }
}

// Usage:
async fn my_handler(State(pool): State<PgPool>) -> Result<Json<Vec<User>>, ServerError> {
    let users = fetch_users(&pool)
        .await
        .context("Failed to fetch users")?; // ? converts to ServerError via From

    Ok(Json(users))
}
}

This pattern is sometimes called the "anyhow handler" pattern. You get the convenience of anyhow for error propagation and the IntoResponse integration Axum needs.

A Complete Error Architecture

For a real application, here's a pattern that scales:

#![allow(unused)]
fn main() {
use axum::{http::StatusCode, response::{IntoResponse, Json, Response}};
use thiserror::Error;

/// Domain errors — typed, matchable, returned from your business logic layer
#[derive(Debug, Error)]
pub enum AppError {
    // Auth errors
    #[error("Authentication required")]
    Unauthenticated,
    #[error("Insufficient permissions")]
    Forbidden,

    // Resource errors
    #[error("{resource} with id {id} not found")]
    NotFound { resource: &'static str, id: String },
    #[error("{field} already exists: {value}")]
    Conflict { field: &'static str, value: String },

    // Validation errors
    #[error("Validation failed: {0}")]
    Validation(String),

    // Infrastructure errors — wrap the low-level error
    #[error("Database error")]
    Database(#[from] sqlx::Error),
    #[error("Internal error")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code, message) = match &self {
            AppError::Unauthenticated => (
                StatusCode::UNAUTHORIZED,
                "UNAUTHENTICATED",
                self.to_string(),
            ),
            AppError::Forbidden => (
                StatusCode::FORBIDDEN,
                "FORBIDDEN",
                self.to_string(),
            ),
            AppError::NotFound { .. } => (
                StatusCode::NOT_FOUND,
                "NOT_FOUND",
                self.to_string(),
            ),
            AppError::Conflict { .. } => (
                StatusCode::CONFLICT,
                "CONFLICT",
                self.to_string(),
            ),
            AppError::Validation(msg) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                "VALIDATION_ERROR",
                msg.clone(),
            ),
            AppError::Database(e) => {
                tracing::error!("Database error: {e}");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "INTERNAL_ERROR",
                    "An internal error occurred".to_string(),
                )
            }
            AppError::Internal(e) => {
                tracing::error!("Internal error: {e:#}");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "INTERNAL_ERROR",
                    "An internal error occurred".to_string(),
                )
            }
        };

        (status, Json(serde_json::json!({
            "error": {
                "code": code,
                "message": message
            }
        }))).into_response()
    }
}

// Convenient type alias for handlers
pub type ApiResult<T> = Result<T, AppError>;
}

Now handlers look like:

#![allow(unused)]
fn main() {
async fn get_user(
    State(pool): State<PgPool>,
    Path(id): Path<uuid::Uuid>,
) -> ApiResult<Json<User>> {
    sqlx::query_as!(
        User,
        "SELECT id, email, name, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&pool)
    .await?  // sqlx::Error converts to AppError::Database via #[from]
    .map(Json)
    .ok_or_else(|| AppError::NotFound {
        resource: "User",
        id: id.to_string(),
    })
}
}

Error Context in Logs

The {:#} format specifier on anyhow::Error prints the full error chain. This is what you want in logs:

#![allow(unused)]
fn main() {
match result {
    Err(e) => tracing::error!("Operation failed: {e:#}"),
    // Prints something like:
    // Operation failed: failed to send email: connection refused (os error 111)
}
}

Without #, you get only the top-level message. With #, you get the full causal chain. Use # in your logs.

The ? Operator and Implicit Conversions

The ? operator calls From::from on the error. This is why #[from] in thiserror and the From impl for ServerError above work — they hook into the ? operator's type coercion.

When ? doesn't compile, it's because there's no From impl from the error you have to the error type you need. The fix is either:

  1. Add a From impl (or #[from] attribute)
  2. Map the error explicitly: .map_err(|e| AppError::Database(e))?
  3. Use .context("message")? with anyhow

The third option is the escape hatch when you want to stop carrying exact error types and just get the thing to compile.

Panics in Production

Panics in async tasks don't propagate. They're caught by the Tokio runtime and surfaced as JoinError when you .await the JoinHandle. If you're not awaiting the handle, the panic is silently swallowed.

Set a panic hook to log panics before they're caught:

#![allow(unused)]
fn main() {
std::panic::set_hook(Box::new(|info| {
    let backtrace = std::backtrace::Backtrace::capture();
    tracing::error!("Panic: {info}\n{backtrace}");
}));
}

Or use the tokio-panic-hook crate to automatically convert task panics into errors. Either way, you want panics to appear in your logs — the default behavior of silently terminating a task while leaving the rest of the server running is a debugging nightmare.

The rule: use panic! for programming errors (invariant violations that shouldn't happen if the code is correct). Use Result for expected failure modes (network errors, invalid input, missing records). If you find yourself using unwrap() on things that could reasonably fail in production, that's a Result opportunity.