Contents

Defensive Coding in Rust: Error Handling Patterns That Scale

Rust’s error handling ecosystem in 2026 centers on four patterns: Result<T, E> with custom enums for libraries, thiserror for ergonomic enum derivation, anyhow for application-level error propagation, and miette or color-eyre for human-friendly diagnostic reports. The right choice depends on whether you are writing a library (where callers need to match on specific error variants) or an application (where you need to propagate errors with context and print them readably). Most non-trivial Rust projects use both thiserror in their library crates and anyhow in their binary crates.

This split is not accidental. Library authors must give downstream consumers structured types to match on. Application developers want to collect errors from every dependency, attach context as failures propagate up, and print a readable report at the top level. The crates in Rust’s error handling ecosystem are designed to complement each other rather than compete, and knowing where each one fits saves hours of rework when a project grows from a weekend prototype into something that runs in production.

Result, Option, and the ? Operator

Before reaching for any crate, you need to understand the built-in primitives that every Rust error handling approach builds on.

Result<T, E> is an enum with two variants: Ok(T) and Err(E). Unlike exceptions in Python or Java, errors in Rust are values that must be explicitly handled. The compiler refuses to let you ignore a Result - if you call a function that returns one and do nothing with it, you get a warning. Errors are not hidden control flow in Rust - they are data, and the compiler holds you to that.

Option<T> represents the absence of a value (None) rather than an error condition. Converting between them is straightforward: .ok_or(err) turns an Option into a Result, and .ok() goes the other direction. The distinction matters because None communicates “nothing here” while Err communicates “something went wrong.”

The ? operator handles most error propagation in practice. When you write:

let data = fs::read_to_string("config.toml")?;

the ? operator checks the Result. If it is Ok, it unwraps the value and assigns it to data. If it is Err, it returns that error from the enclosing function immediately. The operator also performs automatic From trait conversion, so if the function’s return type expects a different error type, Rust will convert it as long as a From implementation exists.

unwrap() and expect("msg") exist as escape hatches. They extract the value from Ok or Some, but they panic on Err or None. Reserve these for cases where the invariant is truly unreachable - a regex pattern that is known at compile time to be valid, for example. In production code, ? is almost always the right tool.

map_err transforms the error type inline without unwrapping:

file.read_to_string(&mut buf)
    .map_err(|e| ConfigError::IoError(e))?;

This converts a std::io::Error into your domain-specific error type before propagation.

Pattern matching with match gives you exhaustive handling. The compiler ensures you address both Ok and Err, and if you later add a new error variant to an enum, every unhandled match site breaks at compile time. This is one of Rust’s strongest guarantees : the type system forces you to confront new failure modes rather than silently ignoring them.

Custom Error Enums: The Library Pattern

Libraries must expose structured, matchable error types so callers can decide how to handle each failure case. The standard approach is defining an error enum with one variant per failure mode:

#[non_exhaustive]
pub enum DatabaseError {
    ConnectionFailed(String),
    QueryTimeout {
        query: String,
        timeout_ms: u64,
    },
    InvalidSchema(SchemaError),
    AuthenticationDenied,
}

Each variant represents a distinct category of failure. Callers can pattern match on these variants to take specific recovery actions - retry on ConnectionFailed, show a login prompt on AuthenticationDenied, abort on InvalidSchema.

To integrate with Rust’s error trait ecosystem, you need two trait implementations. std::fmt::Display provides human-readable messages for each variant. std::error::Error connects your type to the standard error trait, enabling .source() chaining so logging frameworks and error reporters can traverse the full chain of causes.

The #[non_exhaustive] attribute on the enum is a forward-compatibility measure. Without it, adding a new variant in a future release is a breaking change because downstream match statements become incomplete. With #[non_exhaustive], callers are forced to include a wildcard _ => arm, making new variants additive rather than breaking.

For automatic error conversion with the ? operator, implement From<SourceError> for your error type:

impl From<std::io::Error> for DatabaseError {
    fn from(e: std::io::Error) -> Self {
        DatabaseError::ConnectionFailed(e.to_string())
    }
}

Preserving the source error chain is important for debugging compiled programs . Store the original error inside your variant (either as a field or through Error::source()) so that when something goes wrong three layers deep, the full chain is available for inspection.

One pattern to avoid in library APIs: using Box<dyn Error> as your public error type. It erases all type information and prevents callers from matching on specific variants. Box<dyn Error> has its place in internal plumbing or application code where you just need to propagate errors, but it should not appear in a library’s public interface.

thiserror: Derive Macros That Eliminate Boilerplate

Writing Display and Error implementations by hand is tedious, especially as your error enum grows. thiserror (currently at v2.0.18) eliminates this boilerplate with derive macros while keeping your error types fully structured and matchable.

The #[derive(thiserror::Error)] macro generates Display, Error, and From implementations from attributes on your enum variants:

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("database error")]
    Db(#[from] sqlx::Error),

    #[error("config file {path} not found")]
    ConfigMissing {
        path: String,
        #[source]
        cause: std::io::Error,
    },

    #[error("invalid input: {0}")]
    Validation(String),
}

The #[error("...")] attribute on each variant generates the Display implementation. You can interpolate named fields directly into the format string. The #[error(transparent)] attribute delegates display entirely to the wrapped error.

#[from] on a field generates a From implementation automatically. In the example above, Db(#[from] sqlx::Error) means that ? will convert any sqlx::Error into AppError::Db without you writing any conversion code.

#[source] is different from #[from]. It marks a field as the error’s source for .source() chain traversal but does not generate a From impl. Use #[source] when you want to preserve the error chain but need manual control over how errors are converted - for instance, when you want to add extra fields alongside the source error.

thiserror has zero runtime overhead. It is a proc-macro crate with no dependencies beyond proc-macro2, syn, and quote. It is the standard choice for library error types in the Rust ecosystem, used by tokio, reqwest, serde, and hundreds of other major crates.

thiserror vs snafu

snafu is the main alternative to thiserror for structured error types. Where thiserror keeps things minimal (typically 2 lines per variant), snafu is more opinionated and feature-rich - about 5 lines per variant when using source errors and backtraces. snafu generates context selector types that provide a builder-like pattern for attaching context when errors are created. For most library crates, thiserror’s lighter footprint is the better fit. snafu becomes more attractive in large, complex applications where you want the error type itself to carry richer context about what went wrong.

anyhow: Ergonomic Error Handling for Applications

Application code has different priorities. You care about reporting errors to users and logs, not about downstream callers matching on variants. anyhow (currently at v1.0.100) provides a single error type with rich context chaining that fits this use case perfectly.

anyhow::Result<T> is an alias for Result<T, anyhow::Error>. anyhow::Error wraps any type that implements std::error::Error, eliminating the need to define a top-level error enum for your binary. Any library error that passes through your application code just works with ?.

The most useful part is context chaining. The .context() and .with_context() methods (from the Context trait) attach human-readable descriptions to errors as they propagate:

let config = fs::read_to_string(&config_path)
    .with_context(|| format!("loading config from {}", config_path.display()))?;

let parsed: Config = toml::from_str(&config)
    .context("parsing config file")?;

When this error reaches main(), printing it with {:?} (Debug format) shows the full chain:

Error: parsing config file

Caused by:
    0: expected `=`, found newline at line 3 column 1

Each .context() call adds a layer to the chain, so you get a complete trace of what was happening at each level when the failure occurred.

anyhow::bail! creates and returns an error in one expression: bail!("port {} already in use", port). anyhow::ensure! is the error-handling equivalent of assert!: ensure!(count > 0, "count must be positive, got {}", count). Both are convenience macros that reduce boilerplate in validation logic.

For the rare cases where you need to recover a specific error type, anyhow::Error supports downcasting:

if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
    // handle I/O error specifically
}

This should be uncommon in application code. If you find yourself downcasting frequently, you probably want structured error types with thiserror instead.

The recommended pattern for non-trivial Rust projects : use thiserror in your library crates (where callers need structured, matchable types) and anyhow in your main.rs or binary crate (where you just need to propagate and display errors). The two crates are designed to work together.

Beyond the Basics: color-eyre, miette, and Error Reporting

For CLI tools, developer-facing utilities, and applications where error presentation matters, specialized reporting crates turn error chains into readable, actionable output.

color-eyre

color-eyre (v0.6.5) replaces anyhow in CLI applications where you want colorized error reports. Install it once at the top of main():

fn main() -> eyre::Result<()> {
    color_eyre::install()?;
    // ...
}

After that, eyre::Result<T> works as a drop-in replacement for anyhow::Result<T>. The difference is in the output: error reports get color-coded with ANSI escape sequences, and if you use the tracing crate, SpanTrace integration shows which tracing spans were active when the error occurred. Panic reports also get colorized with source location and backtrace information.

color-eyre error report showing colorized backtrace with source code context and span traces
A color-eyre error report with full backtrace and source context enabled

miette

miette (v7.6.0) goes further. It generates rich diagnostic reports with source code snippets, labeled spans pointing to the exact character range that caused the error, help text, and URL links. It is purpose-built for compilers, linters, and developer tools - any situation where you want to show the user exactly what went wrong and where.

#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error("invalid config")]
#[diagnostic(
    code(config::invalid),
    help("check the documentation at https://example.com/config")
)]
struct ConfigError {
    #[source_code]
    src: String,
    #[label("this value is not valid")]
    span: SourceSpan,
}

When this error is reported, miette renders the source code with an underline pointing to the problematic span, plus the help text and error code. If you have used the Rust compiler’s own error messages, you have seen this style of output.

miette diagnostic output showing source code with highlighted error span and contextual help text
miette rendering a JSON parsing error with source highlighting and labeled spans

Error Types in Web Servers

For HTTP servers built with axum or actix-web , the pattern shifts slightly. You implement IntoResponse for your error type to map internal errors to appropriate HTTP status codes:

Error VariantHTTP Status
DatabaseError::ConnectionFailed503 Service Unavailable
ValidationError400 Bad Request
AuthenticationDenied401 Unauthorized
NotFound404 Not Found

This keeps error handling consistent throughout the application while translating internal structure into the format HTTP clients expect.

Structured Logging Integration

Combining structured error types with the tracing crate lets you log errors with full context - span data, error chain, backtrace - to structured logging backends. A typical setup sends JSON-formatted logs to systems like Loki or Elasticsearch, where each error carries enough metadata to debug issues without reproducing them locally.

When to Panic vs When to Return Result

The boundary between panic! and Result is not arbitrary. Rust’s standard library documentation gives clear guidance: return Result when failure is an expected possibility that calling code should handle. Panic when continuing would violate a fundamental invariant that makes further execution meaningless or dangerous.

Concrete examples of when panic! is acceptable:

  • In tests, where unwrap() and expect() are idiomatic because a failed assertion should abort the test
  • In prototype code where error handling would obscure the logic you are exploring
  • When an internal invariant has been violated (an index that should always be in bounds, a state machine transition that should be impossible)
  • When external code returns a state so invalid that recovery is not meaningful

For everything else - file I/O, network requests, parsing user input, database queries - use Result. In backend services, favoring recovery over panic keeps the service running under adverse conditions. A web server that panics on a malformed request takes down the entire process; one that returns a 400 status code keeps serving other clients.

Async Error Handling and Tokio

Error handling in async Rust follows the same patterns, with one wrinkle: tokio::spawn wraps your task’s return value in a JoinHandle, and awaiting that handle gives you a Result<T, JoinError>. If the task itself returns a Result, you end up with Result<Result<T, E>, JoinError> - a nested result where the outer layer represents task-level failures (panics, cancellation) and the inner layer represents your application errors.

The common pattern is to flatten this:

let result = tokio::spawn(async {
    do_work().await
}).await;

match result {
    Ok(Ok(value)) => { /* task succeeded, work succeeded */ }
    Ok(Err(app_err)) => { /* task succeeded, work failed */ }
    Err(join_err) => { /* task itself failed (panic or cancel) */ }
}

With anyhow, you can use .context() on the JoinError to provide a description of which task failed, keeping the error chain informative even across task boundaries.

Choosing the Right Tool

ScenarioRecommended Approach
Library crate, callers need to match errorsCustom enum + thiserror
Binary/CLI, just propagate and displayanyhow
CLI with colored output and tracingcolor-eyre
Compiler, linter, or developer toolmiette
Large complex system with rich contextsnafu
Web server error responsesthiserror + IntoResponse impl

These crates complement each other rather than compete. Pick thiserror or snafu for your library boundaries, anyhow or color-eyre for your binary, and miette if your tool needs to point at source code. The library vs. application divide is the decision that matters most - get that right and the rest follows.