Contents

Defensive Coding in Rust: Error Handling Patterns That Scale

Rust error handling in 2026 rests on four patterns. You use Result<T, E> with custom enums for libraries. You reach for thiserror to derive those enums with less boilerplate. You pick anyhow to pass errors up through application code. And you add miette or color-eyre for friendly diagnostic reports. The right choice depends on whether you write a library or an application. Most real Rust projects use both: thiserror in their library crates and anyhow in their binary crates.

This split is not an accident. Library authors must hand callers structured types to match on. Application developers want something else. They need to collect errors from every dependency, attach context as those errors travel up, and print a readable report at the top. The crates do not compete. They are built to fit together. Knowing where each one belongs saves hours of rework when a project grows from a weekend prototype into something that runs in production.

Result, Option, and the ? Operator

Before you reach for any crate, you need to grasp the built-in pieces that every Rust error pattern 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. You must handle them out in the open. The compiler refuses to let you ignore a Result. Call a function that returns one and do nothing with it, and you get a warning. Errors are not hidden control flow in Rust. They are data, and the compiler holds you to that.

Option<T> stands for a missing value (None) rather than an error. Switching between them is easy. .ok_or(err) turns an Option into a Result, and .ok() goes the other way. The split is useful because None says “nothing here” while Err says “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 binds it to data. If it is Err, it returns that error from the current function right away. The operator also runs an automatic From conversion. So if the function’s return type wants a different error type, Rust converts it for you, as long as a From impl exists.

unwrap() and expect("msg") are escape hatches. They pull the value out of Ok or Some, but they panic on Err or None. Save them for cases where the invariant can never break, such as a regex pattern that is known to be valid at compile time. 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 turns a std::io::Error into your own error type before it travels up.

Pattern matching with match gives you exhaustive handling. The compiler makes sure you cover both Ok and Err. Add a new error variant to an enum later, and every unhandled match site breaks at compile time. This is one of Rust’s strongest guarantees. The type system forces you to face new failure modes rather than skip past them.

Custom Error Enums: The Library Pattern

Libraries must expose structured error types that callers can match on. That way each caller decides how to handle each failure. The standard approach defines 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 stands for a distinct kind of failure. Callers can match on these variants to pick a recovery action: retry on ConnectionFailed, show a login prompt on AuthenticationDenied, abort on InvalidSchema.

To fit into Rust’s error traits, you need two trait impls. std::fmt::Display gives a readable message for each variant. std::error::Error ties your type to the standard error trait. That turns on .source() chaining, so logging tools and error reporters can walk the full chain of causes.

The #[non_exhaustive] attribute on the enum keeps the type future-proof. Without it, adding a new variant in a later release is a breaking change, because downstream match statements go incomplete. With #[non_exhaustive], callers must add a wildcard _ => arm. New variants then add to the type rather than break it.

To get 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())
    }
}

Keeping the source error chain is key when you debug compiled programs. Store the original error inside your variant, either as a field or through Error::source(). Then when something breaks three layers deep, the full chain is there to inspect.

Avoid one pattern in library APIs: using Box<dyn Error> as your public error type. It erases all type info and stops callers from matching on specific variants. Box<dyn Error> is fine for internal plumbing or application code where you just need to pass errors up. It should not show up in a library’s public interface.

thiserror: Derive Macros That Eliminate Boilerplate

Writing Display and Error impls by hand is tedious, and it gets worse as your error enum grows. thiserror (at v2.0.18) cuts this boilerplate with derive macros. Your error types stay fully structured and matchable.

The #[derive(thiserror::Error)] macro builds Display, Error, and From impls 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 builds the Display impl. You can drop named fields straight into the format string. The #[error(transparent)] attribute hands display fully to the wrapped error.

#[from] on a field builds a From impl for you. In the example above, Db(#[from] sqlx::Error) means ? will turn any sqlx::Error into AppError::Db. You write no conversion code at all.

#[source] is not the same as #[from]. It marks a field as the error’s source for .source() chain walking, but it does not build a From impl. Use #[source] when you want to keep the error chain but need manual control over how errors convert. For instance, you may want to add extra fields next to the source error.

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

thiserror vs snafu

snafu is the main rival to thiserror for structured error types. Where thiserror stays minimal, often 2 lines per variant, snafu is more opinionated and does more. It runs about 5 lines per variant once you add source errors and backtraces. snafu builds context selector types, which give a builder-style way to attach context when errors are created. For most library crates, thiserror’s lighter weight wins. snafu pulls ahead in large, busy 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 other priorities. You care about reporting errors to users and logs, not about downstream callers matching on variants. anyhow (at v1.0.100) gives you a single error type with rich context chaining, and it fits this job well.

anyhow::Result<T> is an alias for Result<T, anyhow::Error>. anyhow::Error wraps any type that implements std::error::Error. So you don’t need a top-level error enum for your binary. Any library error that flows through your application code just works with ?.

The best part is context chaining. The .context() and .with_context() methods, from the Context trait, attach readable descriptions to errors as they travel up:

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 full trace of what was going on at each level when the failure hit.

anyhow::bail! creates and returns an error in one expression: bail!("port {} already in use", port). anyhow::ensure! is the error-handling twin of assert!: ensure!(count > 0, "count must be positive, got {}", count). Both are handy macros that cut 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 rare in application code. If you find yourself downcasting a lot, you probably want structured error types with thiserror instead.

Here is the pattern most real Rust projects follow. Use thiserror in your library crates, where callers need structured types to match on. Use anyhow in your main.rs or binary crate, where you just need to pass errors up and display them. The two crates are built to work together.

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

For CLI tools, developer-facing utilities, and apps where error presentation counts, dedicated reporting crates turn error chains into readable, actionable output.

color-eyre

color-eyre (v0.6.5) replaces anyhow in CLI apps 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 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 shows which tracing spans were active when the error hit. Panic reports also get color, plus source location and backtrace info.

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 builds rich diagnostic reports with source code snippets, labeled spans, help text, and URL links. The spans point at the exact character range that caused the error. miette is built for compilers, linters, and developer tools, any case where you want to show the user just 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 under the bad span, plus the help text and error code. If you have read 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 a bit. You implement IntoResponse for your error type. That maps internal errors to the right HTTP status codes:

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

This keeps error handling consistent across the application. It also translates your internal structure into the format HTTP clients expect.

Structured Logging Integration

Pair structured error types with the tracing crate, and you can log errors with full context: span data, error chain, and backtrace. A typical setup sends JSON-formatted logs to systems like Loki or Elasticsearch. Each error then carries enough metadata to debug issues without reproducing them locally.

When to Panic vs When to Return Result

The line between panic! and Result is not arbitrary. Rust’s standard library docs give clear guidance. Return Result when failure is an expected outcome that calling code should handle. Panic when going on would break a core invariant and make further work pointless or unsafe.

Here are concrete cases where panic! is fine:

  • In tests, where unwrap() and expect() are idiomatic, because a failed assertion should abort the test
  • In prototype code, where error handling would hide the logic you are still exploring
  • When an internal invariant has been broken: an index that should always be in bounds, or a state machine move that should be impossible
  • When external code returns a state so invalid that recovery has no meaning

For everything else, use Result: file I/O, network requests, parsing user input, database queries. Parsing routines gain the most from probing parsers with random inputs , which surfaces the bad values your error handling has to survive. In backend services, favoring recovery over panic keeps the service up under stress. A web server that panics on a bad request takes down the whole 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. Await that handle and you get a Result<T, JoinError>. If the task itself returns a Result, you end up with Result<Result<T, E>, JoinError>. That is a nested result. The outer layer holds task-level failures, such as panics or cancellation. The inner layer holds 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 call .context() on the JoinError to note which task failed. The error chain then stays informative even across task boundaries.

Choosing the Right Tool

Decision flowchart for choosing a Rust error handling crate based on whether you are writing a library or application
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 fit together rather than compete. Pick thiserror or snafu for your library boundaries. Pick anyhow or color-eyre for your binary. Pick miette if your tool needs to point at source code. The library versus application split is the call that counts most. Get that right and the rest falls into place.