Result-Chained vs Panic-Based Rust: Error Handling Patterns That Actually Work in Production

Every Rust developer goes through the same arc. First, you’re amazed the compiler catches so much at compile time. Then you discover .unwrap() and suddenly everything compiles and runs. Then you ship something to production and it crashes with thread 'main' panicked at 'called \Option::unwrap()` on a `None` value’` and no useful context about why or where from the user’s perspective.

This is the tutorial I wish existed when I was bridging that gap.

Rust’s error handling is not just about correctness — it forces you to reason about failure modes at the type level. That’s genuinely powerful. But the two dominant approaches — letting panics fly versus threading Result through everything — have very different trade-offs, and most guides don’t tell you which one to actually reach for in a given situation.

The Real Divide

Panic-based code says: "this shouldn’t happen, and if it does, we crash." Result-based code says: "this might fail, and the caller decides what to do about it." Neither is universally right.

The mistake most developers make is using panics as a lazy escape hatch when they haven’t modeled their failure modes properly. The opposite mistake — which affects people who’ve read too many articles like this one — is wrapping everything in Result until the code becomes unreadable error-forwarding boilerplate.

Let’s break down when each approach is legitimate, and how to write Result chains that don’t make reviewers cry.

When Panics Are Actually Fine

Panics have a place. Here are the cases where reaching for them is not only acceptable but correct:

Programmer errors. If an index is out of bounds because of a bug in your logic, panicking is the right call. You don’t want to silently continue with corrupted state. slice[idx] panics on invalid index, and that’s by design.

Initialization that must succeed. If your app can’t connect to its database at startup, crashing loudly beats limping along. expect("DB connection failed at startup") is more informative than bubbling a Result through main and printing a generic error. Use expect over unwrap — the message is free, the confusion it saves is priceless.

Tests. unwrap() in tests is perfectly fine. If a test helper fails, you want a panic, not a Result<(), TestError> that silently eats the failure.

Invariants that are genuinely impossible to violate. If you’ve already validated a value and you’re 100% sure the subsequent parse can’t fail, parse().unwrap() inside that narrowed scope is defensible. Add a comment explaining why it can’t fail.

// Port was already validated to be in 1024..=65535 range by clap
let port: u16 = config.port_str.parse().unwrap();

The rule: panics communicate programmer errors, not runtime errors. If it can fail due to user input, network conditions, or any external factor — that’s a Result.

The ? Operator: What It Actually Does

The ? operator is syntactic sugar that most explanations under-explain. It does three things:

  1. If the value is Ok(v), unwrap to v and continue.
  2. If the value is Err(e), return early with Err(e.into()).
  3. The .into() part means it will automatically call From to convert the error type — which is where a lot of the magic (and confusion) lives.
fn read_config(path: &str) -> Result<Config, ConfigError> {
    let contents = std::fs::read_to_string(path)?; // io::Error -> ConfigError via From
    let config: Config = toml::from_str(&contents)?; // toml::de::Error -> ConfigError via From
    Ok(config)
}

That ? is not just "return if error." It’s converting io::Error and toml::de::Error into your ConfigError automatically — but only if you’ve implemented From for those conversions. If you haven’t, the compiler tells you immediately.

Gotcha: ? only works in functions that return Result or Option. Forgetting this, then wondering why ? won’t compile in a closure or an iterator chain, costs time. In closures you often need to collect errors manually or restructure.

The Naive Approach and Why It Breaks

Before reaching for libraries, let’s see what raw enum-based error handling looks like — and where it falls apart.

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(toml::de::Error),
    Validation(String),
}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<toml::de::Error> for AppError {
    fn from(e: toml::de::Error) -> Self {
        AppError::Parse(e)
    }
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {e}"),
            AppError::Parse(e) => write!(f, "Parse error: {e}"),
            AppError::Validation(msg) => write!(f, "Validation error: {msg}"),
        }
    }
}

impl std::error::Error for AppError {}

This works. It’s explicit, fully typed, and zero-dependency. The problem: you’re writing 30–50 lines of boilerplate per error type. Add five more error sources and you’ve got a 200-line error.rs file that nobody wants to maintain. The From impls especially get tedious fast.

This is exactly the gap that thiserror fills.

thiserror: Boilerplate Elimination for Library Code

thiserror is a derive macro that generates all that boilerplate from annotations. Same result, 80% less code.

[dependencies]
thiserror = "2"
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("failed to read config file: {0}")]
    Io(#[from] std::io::Error),

    #[error("failed to parse config: {0}")]
    Parse(#[from] toml::de::Error),

    #[error("invalid port {port}: must be in range 1024-65535")]
    InvalidPort { port: u16 },

    #[error("missing required field: {field}")]
    MissingField { field: String },
}

That #[from] attribute generates the From impl automatically. The #[error("...")] attribute generates Display. std::error::Error is derived. Everything the compiler needs is there in a third of the code.

thiserror is the right tool when:

  • You’re writing a library that other code will ? into their own error types
  • You want callers to be able to match on specific error variants
  • Error types need to be part of your public API

Gotcha: thiserror only derives for your own types. If you try to add #[from] for two different error types that don’t conflict, great. If you add #[from] for two error types where Rust can’t disambiguate, it won’t compile. You’ll need a manual conversion for one of them.

anyhow: Pragmatic Error Handling for Applications

anyhow takes a completely different philosophy. Instead of typed errors with variants, it gives you a single anyhow::Error that can wrap anything implementing std::error::Error, with full error chain support.

[dependencies]
anyhow = "1"
use anyhow::{Context, Result};

fn load_user(id: u64) -> Result<User> {
    let path = format!("users/{id}.json");
    let data = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read user file for id={id}"))?;
    
    let user: User = serde_json::from_str(&data)
        .context("user file contains invalid JSON")?;
    
    Ok(user)
}

Note Result<User> — that’s anyhow::Result<User>, which is shorthand for Result<User, anyhow::Error>. No custom error type needed.

The .context() and .with_context() methods are the killer feature. They wrap the original error with additional context, building an error chain you can print with {:#} to see the full story:

failed to read user file for id=42: No such file or directory (os error 2)

anyhow is the right tool when:

  • You’re writing application (binary) code, not a library
  • The caller doesn’t need to match on specific error variants — they just need to know something failed
  • You want rich error context without maintaining a variant enum

Gotcha: Don’t use anyhow in library code. Downstream consumers of your library can’t match on anyhow::Error variants. They lose the ability to handle specific failure modes differently. This is the single most common misuse of the crate.

Mixing Both: The Production Pattern

Real production systems use both. The division is clean:

  • Libraries and domain logic: thiserror with typed errors
  • Application entrypoints, CLI glue, background jobs: anyhow

Here’s what that looks like in practice:

// In your library crate: db.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DbError {
    #[error("connection pool exhausted")]
    PoolExhausted,

    #[error("query failed: {0}")]
    QueryFailed(#[from] sqlx::Error),

    #[error("record not found: id={id}")]
    NotFound { id: i64 },
}

pub async fn get_user(pool: &PgPool, id: i64) -> Result<User, DbError> {
    sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
        .fetch_optional(pool)
        .await?
        .ok_or(DbError::NotFound { id })
}
// In your binary: main.rs / handlers
use anyhow::{Context, Result};

async fn handle_profile_request(pool: &PgPool, user_id: i64) -> Result<ProfileResponse> {
    let user = get_user(pool, user_id)
        .await
        .with_context(|| format!("profile request failed for user_id={user_id}"))?;

    // If we need to handle NotFound specifically, we can still match on the typed error
    // before it gets wrapped — just do it before the `?`

    let profile = build_profile(user).context("failed to build profile response")?;
    Ok(profile)
}

When you need to match on specific variants before context-wrapping:

async fn handle_profile_request(pool: &PgPool, user_id: i64) -> Result<ProfileResponse> {
    let user = match get_user(pool, user_id).await {
        Ok(u) => u,
        Err(DbError::NotFound { .. }) => return Ok(ProfileResponse::not_found()),
        Err(e) => return Err(e).context("profile request failed"),
    };
    // ...
}

Error Propagation in Iterators: The Annoying Part

This trips everyone up. You can’t use ? inside a map closure and have it propagate out of the enclosing function.

Wrong:

fn parse_ids(raw: &[&str]) -> Result<Vec<u64>> {
    let ids = raw.iter()
        .map(|s| s.parse::<u64>()?) // ERROR: can't use ? here
        .collect();
    Ok(ids)
}

Right — collect into Result:

fn parse_ids(raw: &[&str]) -> Result<Vec<u64>> {
    raw.iter()
        .map(|s| s.parse::<u64>().with_context(|| format!("invalid id: {s}")))
        .collect() // collect::<Result<Vec<u64>>>() — Rust infers this
}

collect() on an iterator of Result<T, E> can produce Result<Vec<T>, E> — it stops at the first error and returns it. This is one of those Rust features that feels like magic until you know it’s just a FromIterator impl.

If you want all errors, not just the first:

fn parse_ids(raw: &[&str]) -> (Vec<u64>, Vec<String>) {
    let (oks, errs): (Vec<_>, Vec<_>) = raw.iter()
        .map(|s| s.parse::<u64>().map_err(|e| format!("{s}: {e}")))
        .partition(Result::is_ok);
    
    (oks.into_iter().flatten().collect(), errs.into_iter().map(|e| e.unwrap_err()).collect())
}

Gotcha: The partition trick gets verbose. For collecting all errors with good ergonomics, look at the itertools crate’s partition_map or write a small helper.

Structuring Error Types for Real Services

For a non-trivial service, a flat error enum doesn’t scale. You end up with 40 variants that mix infrastructure errors, domain errors, and validation errors. The pattern that works better:

// Domain-specific errors stay narrow
#[derive(Debug, Error)]
pub enum AuthError {
    #[error("invalid credentials")]
    InvalidCredentials,
    #[error("account locked: too many failed attempts")]
    AccountLocked,
    #[error("token expired")]
    TokenExpired,
}

// Infrastructure errors are separate
#[derive(Debug, Error)]
pub enum InfraError {
    #[error("database error: {0}")]
    Db(#[from] DbError),
    #[error("cache error: {0}")]
    Cache(#[from] redis::RedisError),
}

// Application layer composes them
#[derive(Debug, Error)]
pub enum AppError {
    #[error(transparent)]
    Auth(#[from] AuthError),
    #[error(transparent)]
    Infra(#[from] InfraError),
    #[error("request validation failed: {0}")]
    Validation(String),
}

#[error(transparent)] is a thiserror feature that delegates both Display and source() to the inner error. This preserves error chains through layers.

Production Gotchas Roundup

Gotcha: swallowing error context with map_err. map_err(|_| MyError::Failed) loses the original error entirely. If you’re doing this, you’re throwing away information that would have explained the failure. Use map_err(|e| MyError::Failed { source: e }) and store the source.

Gotcha: error type explosions in async traits. Before async-trait stabilization or with older compilers, Box<dyn std::error::Error + Send + Sync> showed up everywhere. It works but you lose variant matching. anyhow::Error is basically this with context support. Prefer anyhow over naked Box<dyn Error> in async code.

Gotcha: panics in async don’t propagate like you’d expect. A panic in a Tokio task doesn’t crash the process by default — it crashes the task and returns a JoinError from join(). If you’re not checking task results, you’re silently eating crashes. Always join your tasks and handle JoinError.

let handle = tokio::spawn(some_async_fn());
match handle.await {
    Ok(Ok(result)) => { /* use result */ }
    Ok(Err(e)) => tracing::error!("task error: {e:#}"),
    Err(join_err) => tracing::error!("task panicked: {join_err}"),
}

Gotcha: unwrap() in production during "temporary" prototyping. It never gets replaced. Add a // TODO: proper error handling and it lives there forever. Use expect("meaningful message") at minimum, or immediately wrap in a Result return type even if you’re prototyping. The friction of doing it right up front is lower than fixing a production incident later.

Displaying Errors to Users vs Logs

The error you log internally and the message you show a user are different things. anyhow::Error formatted with {:#} (the pretty-printer) gives you the full chain with context — perfect for logs. Don’t show that to users.

match perform_action().await {
    Ok(result) => respond_ok(result),
    Err(e) => {
        // Log the full chain internally
        tracing::error!(error = format!("{e:#}"), "action failed");
        
        // Return a sanitized message to the user
        respond_err(StatusCode::INTERNAL_SERVER_ERROR, "something went wrong")
    }
}

If you need to expose specific error categories to callers (rate limit, not found, validation), do it via typed errors at the domain level, translate them explicitly to HTTP status codes in your handler layer, and never let internal error details leak to the wire.

The Short Decision Tree

Use panic/unwrap/expect when:

  • It’s a programmer bug, not a runtime failure
  • Startup/initialization that must succeed
  • Inside tests
  • Behind a comment proving the case can’t happen

Use thiserror when:

  • Writing a library or shared crate
  • Error variants need to be matchable by callers
  • You’re defining a domain’s error vocabulary

Use anyhow when:

  • Writing application binary code
  • Callers just need to know "did it work" not "which way did it fail"
  • You want rich context chains without the boilerplate

Both thiserror + anyhow together when:

  • You have a service with both library-grade domain logic and application-grade glue code (which is most real services)

Getting this right is genuinely what separates Rust code that reads like a well-engineered system from Rust code that looks like someone gave up and scattered .unwrap() like confetti. The type system is on your side — let it do the work.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646