Rust Error Handling
When to Use
Use this skill when:
- The user asks how to choose between
thiserrorandanyhowfor a Rust project - The user is designing a custom error type for a library crate and needs to understand
std::error::Errortrait implementation - The user has a function returning
Result<T, E>and wants to propagate or transform errors using?,.map_err(),.and_then(), or combinators - The user needs to add context to errors (e.g., "failed to parse config at line 42") without losing the original cause
- The user is hitting compiler errors like "the
?operator can only be used in a function that returnsResultorOption" or "the traitFrom<SomeError>is not implemented" - The user wants to define an error hierarchy for a multi-module crate with variants for different failure categories
- The user is writing a CLI tool or binary application and wants ergonomic top-level error reporting
- The user needs to convert between error types at FFI, async, or service boundaries
- The user is working with
Box<dyn Error>and wants to migrate to a more structured approach
Do NOT use this skill when:
- The user asks about Rust ownership, lifetimes, or borrow checker issues -- use
rust-ownership-patterns - The user is setting up a new Rust project structure, workspace layout, or Cargo.toml dependencies -- use
rust-project-setup - The user asks about writing unit tests, integration tests, or property-based tests in Rust -- use
rust-testing-patterns - The user asks about async Rust runtime selection or
tokiovsasync-stdarchitecture -- userust-async-patterns - The user is asking about logging frameworks (
tracing,log) in isolation without an error handling question -- userust-observability - The user asks about panics as a design mechanism for invariant violations -- that is a separate topic from recoverable error handling
Process
1. Identify the Crate Type and Error Consumer
The single most important question in Rust error handling is: who reads this error?
- Library crate (
lib.rsroot): Your users are other Rust programmers. They need to match on specific variants. Usethiserrorto define strongly-typed, matchable error enums. Never useanyhow::Errorin a library's public API -- it erases type information that downstream code cannot recover. - Binary / application crate (
main.rs): Your users are humans at a terminal or log aggregator. Useanyhow::Errorat the top level for ergonomic?chaining and rich context strings. The exact type rarely matters once you reachmain. - Dual-use crate (library with a binary target): Keep the library's public API using typed errors (
thiserror). In thesrc/bin/ormain.rslayer, convert toanyhow::Errorat the outermost boundary using?or.map_err(|e| anyhow::anyhow!(e)). - Internal modules within a large crate: You do NOT need a distinct error type per module. Group errors at the public API surface, not at every internal helper. Over-granular error types cause churn.
- Proc-macro crate: Use
syn::Errorandproc_macro2::Span-- these integrate with the compiler's diagnostic infrastructure and are not replaceable withthiserrororanyhow.
2. Design the Error Enum Structure
Once you know the crate type, design the error enum with these concrete rules:
- One error enum per public API surface, not per file. A
DatabaseErrorcovers all DB operations; you do not needQueryError,ConnectionError,TransactionErroras separate top-level types unless they appear in separate public modules. - Use
thiserror::Errorderive macro. It implementsstd::error::Error,Display, and optionallyFrom<SourceError>for you. Avoid writing these by hand. - Each variant maps to a distinct failure category that callers might handle differently. If two variants would always trigger the same recovery logic, merge them.
- Embed structured context in variant fields, not in format strings. Prefer
IoError { path: PathBuf, source: std::io::Error }overIoError(String). This lets callers inspect the path without parsing strings. - Mark the source error with
#[source](or the shorthand#[from]when you also want automaticFromimpl).#[source]populates theError::source()chain;#[from]additionally generatesimpl From<SourceError> for YourError. - Do NOT use
#[from]indiscriminately. If two variants wrap the same source type (e.g., two variants both wrappingstd::io::Error),#[from]cannot distinguish them -- you must use.map_err()explicitly at call sites. - Avoid
Box<dyn Error + Send + Sync>in library enums unless you genuinely cannot know the source type at compile time (e.g., plugin systems). It loses type information and forces heap allocation. - Add
#[non_exhaustive]to library error enums when you anticipate adding variants in future minor versions. Without it, adding a variant is a breaking change.
3. Apply the ? Operator and From Trait Correctly
The ? operator desugars to: evaluate the Result, on Err(e) call return Err(From::from(e)). Understanding this prevents 80% of error propagation confusion.
?works automatically whenimpl From<SourceError> for TargetErrorexists. This is what#[from]generates. If you get a "the trait bound is not satisfied" error, you are missing aFromimpl or the function's return type is wrong.- Use
.map_err(|e| TargetError::Variant { field: ctx, source: e })?when you need to attach context that#[from]cannot supply (e.g., the file path, the key name, the line number). - Never use
.unwrap()or.expect()in library code paths that can receive user input. Reserve.expect()for cases where failure indicates a programming error (invariant violation), not an environmental failure. Document the invariant in the.expect()message. - Chain
?through async functions freely --?works identically inasync fnreturningResult. There is no special async syntax needed. - In
Optioncontexts, use.ok_or_else(|| MyError::MissingField { name: "host" })?to convertOption<T>toResult<T, MyError>before applying?. - Avoid deeply nested
matchonResult. More than two levels of nesting signals you should extract a helper function or use combinators.
4. Add Error Context Without Losing the Cause
Naked errors without context ("No such file or directory") are useless in production. Add context at every layer crossing.
- In application code with
anyhow: use.context("loading database config")or.with_context(|| format!("reading file {}", path.display())). This wraps the error with a message while preserving the original via thesource()chain. - In library code with
thiserror: add context as variant fields (path: PathBuf,key: String,line: usize). Context belongs in the type, not in a string attached later. - Never double-wrap context. If a function already returns
MyError::Io { path, source }, do not also add.context("io error")at the call site -- the path is already in the error. - Correlation IDs and request IDs belong in the logging layer (via
tracingspans), not inside error types. Error types carry structural cause; tracing carries execution context. - When wrapping a third-party error, capture only what you need. If
reqwest::Errorcontains the URL, do not duplicate it in your wrapper field -- call.url()on the source in yourDisplayimpl instead.
5. Select and Apply Result Combinators Appropriately
Result combinators compose transformations without explicit match. Use them for clarity, not for cleverness.
.map(|v| transform(v))-- transform theOkvalue, leaveErruntouched. Use when the transformation is infallible..map_err(|e| convert(e))-- transform theErrvalue, leaveOkuntouched. The most common combinator at type boundaries..and_then(|v| fallible_transform(v))-- apply a function that itself returnsResult. Equivalent to?inside a closure. Use for chaining dependent fallible operations..or_else(|e| fallback(e))-- attempt a recovery; if the recovery also fails, return the new error. Use for retry logic or fallback sources..unwrap_or_else(|e| default_value(e))-- consume the error and produce a default. Only appropriate when an error is genuinely expected and non-fatal..flatten()-- convertsResult<Result<T, E>, E>toResult<T, E>. Useful after.map()that returns anotherResult.- Avoid chaining more than 3-4 combinators in a single expression. Beyond that, extract a named function. The cognitive overhead exceeds the terseness benefit.
Iteratorof Results: use.collect::<Result<Vec<_>, _>>()to fail fast on the first error, or.filter_map(|r| r.ok())to skip errors silently (document when you do the latter).
6. Handle Errors at Boundaries
Errors must be translated, logged, or surfaced at each architectural layer crossing.
- HTTP API boundary: Map error variants to HTTP status codes in a centralized handler, not scattered through route handlers. A
matchonAppErrorvariants producesStatusCode::BAD_REQUEST,StatusCode::NOT_FOUND,StatusCode::INTERNAL_SERVER_ERROR. Never expose internal error messages in 5xx responses -- log them with a request ID, return the ID to the client. - CLI boundary: Implement
main() -> Result<(), anyhow::Error>(oreyre::Reportfor richer formatting).anyhowwill print the error chain automatically onErr. For prettier output, usemiettewith#[diagnostic]annotations. - FFI boundary: Rust panics must not cross FFI. Wrap any
Result-returning Rust function called from C instd::panic::catch_unwind. ConvertErrto a C-compatible integer code or out-parameter error string. Never let?propagate acrossextern "C"fn. - Thread/task boundary:
tokio::task::JoinHandlereturnsResult<T, JoinError>. AJoinErrorcan be a panic payload. Check.is_panic()and handle panics explicitly. Do not let them silently disappear. - Deserialization boundary (serde):
serde_json::from_strreturns a genericserde_json::Error. Wrap it immediately with.map_err(|e| MyError::DeserializationFailed { field: "config", source: e })rather than letting the raw serde error bubble up to callers.
7. Write Ergonomic Error Display Implementations
Display for errors is the message a human reads. Write it with care.
- Use lowercase for error messages unless a proper noun requires capitalization. This follows Rust ecosystem convention (matching
stderrors) and allows embedding in sentences:format!("failed to connect: {}", e). - Do not end error messages with a period. They are fragments, not sentences.
- Do not include "Error" in the message text. The caller's context supplies that word. Write "connection refused" not "Connection error: connection refused".
- In
thiserror, use#[error("...")]attribute with{field_name}interpolation. Use{source}to embed the source error's display in the message when useful. - Make messages specific. "failed to read file
/etc/app/config.toml" is useful. "IO error" is not. - For
anyhowcontext strings, write them as lowercase verb phrases:.context("parsing configuration"),.context("connecting to database").
8. Validate and Test Error Paths
Error paths are code paths. Test them like production code.
- Test that the correct error variant is returned for each failure condition using
assert!(matches!(result, Err(MyError::Variant { .. })))or theassert_matches!macro (stabilized in Rust 1.82). - Test the
Displayoutput of your error types to ensure messages are human-readable and do not regress. A simpleassert_eq!(format!("{}", err), "expected message")catches accidental changes. - Test the
source()chain when it matters:assert!(err.source().is_some())confirms the cause is preserved. - Use
should_panictests sparingly -- they test panic messages, not error types, and are fragile. - For libraries, test that error types implement
Send + Syncusing a compile-time assertion:fn assert_send_sync<T: Send + Sync>() {}called with your error type in a test. Many async runtimes requireSenderrors. - Property-based testing with
proptestorquickcheckis valuable for parsing/deserialization code -- generate malformed inputs and verify you always getErr, never a panic.
Output Format
When responding to a Rust error handling question, structure the response as follows:
## Error Handling Strategy for [Context]
### Crate Type Assessment
[Library / Application / Dual-use] -- [one sentence rationale]
### Recommended Dependencies
[thiserror and/or anyhow, with version and Cargo.toml snippet]
### Decision Matrix
| Scenario | Library Crate | Application Crate | Recommendation |
|-----------------------------------|-----------------|-------------------|------------------------------|
| Public API error types | thiserror enum | anyhow::Error | thiserror for libraries |
| Internal helper functions | ? propagation | ? propagation | propagate without conversion |
| Top-level main / request handler | N/A | anyhow / miette | anyhow::Result in main |
| Wrapping third-party errors | #[from] or map_err | .context() | depends on type stability |
| Multiple source error types | enum variants | .context() chain | enum for libraries |
### Error Type Definition
[Complete, compilable Rust code for the error enum]
### Propagation Pattern
[Complete code showing ? usage, map_err, and context attachment]
### Boundary Handling
[Code showing how errors surface at API, CLI, or FFI boundary]
### Common Pitfalls to Avoid
[Bulleted list of 3-5 specific mistakes relevant to the user's situation]
Rules
Never recommend
anyhow::Errorin a library's public API. It erases type information. Downstream callers cannot match on it. If a library author asks what to use, the answer is alwaysthiserroror a hand-rolledstd::error::Errorimplementation.Never use
#[from]for two variants that wrap the same source type. TheFromtrait cannot have two implementations for the same source type in the sameimplblock. If this situation arises, write explicit.map_err()conversions and use#[source](not#[from]) on the variant fields.Never let a Rust panic cross an FFI boundary. Catching a panic via
catch_unwindand converting to an error code is mandatory forextern "C"functions. Failure to do this causes undefined behavior in the calling process.Never use
Box<dyn Error>as a function return type unless the codebase is using it for a plugin/dynamic dispatch system. In almost all other cases it is a sign of premature generalization or a copy from a pre-thiserrortutorial. Replace it with a concrete error enum.Always implement
Send + Syncfor errors used in async contexts.tokioand other runtimes require errors to beSend.thiserror-derived types areSend + Syncif all their fields are. Verify this.Box<dyn Error>is NOTSend + Syncby default -- useBox<dyn Error + Send + Sync>if you must use it.Never add
.context()or.map_err()at every single call site in application code. Context should be added at meaningful layer crossings (loading config, connecting to DB, processing a request) -- not on every individual?. Over-annotated error chains become noise.Never derive
Copyfor error types. Errors often containString,PathBuf, or heap-allocated fields. Error types should beClonewhen needed but are rarelyCopy. Attempting this will cause compiler errors.Always add
#[non_exhaustive]to library error enums before publishing. Adding a new variant to an exhaustive enum in a library is a semver-breaking change.#[non_exhaustive]allows adding variants in minor versions, requiring downstream code to have a_match arm.Never format an error type's
source()in the parent'sDisplayimplementation when usingthiserror. Thethiserror#[error]attribute's{source}interpolation will include the source error's display. If you also manually callsource()somewhere in the chain, you get duplicate messages. Use either#[error("... {source}")]OR let callers traverse the chain -- not both.Never silently discard errors with
let _ = fallible_call()unless you have explicitly documented why the error is intentionally ignored. Useif let Err(e) = fallible_call() { tracing::warn!("...") }as a minimum. Silently dropped errors are the hardest bugs to diagnose in production.
Edge Cases
The #[from] Collision Problem
When two variants in the same enum both need to wrap std::io::Error, #[from] cannot be used because Rust does not allow two impl From<io::Error> for MyError blocks. The solution: use #[source] on both variants (which preserves the error chain) and write explicit .map_err() at each call site to specify which variant to produce.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file `{path}`")]
ReadFailed { path: PathBuf, #[source] source: std::io::Error },
#[error("failed to write config file `{path}`")]
WriteFailed { path: PathBuf, #[source] source: std::io::Error },
}
// At call site:
fs::read_to_string(&path).map_err(|e| ConfigError::ReadFailed { path: path.clone(), source: e })?;
Async Tasks and JoinError
tokio::task::spawn returns JoinHandle<Result<T, E>>. Awaiting it gives Result<Result<T, E>, JoinError>. Callers must handle both layers. Panics inside tasks become JoinError with is_panic() == true. The standard pattern is:
let result = task_handle.await
.map_err(|e| if e.is_panic() {
AppError::TaskPanicked
} else {
AppError::TaskCancelled
})??; // The second ? unwraps the inner Result<T, E>
Never flatten this with .unwrap() -- a panicking task will propagate the panic to the caller's thread.
Error Types in Trait Objects (dyn Trait)
When defining a trait that returns errors, the error type must be part of the trait signature or use Box<dyn Error + Send + Sync>. Using an associated type (type Error: std::error::Error) is the ergonomic approach for libraries. Using Box<dyn Error + Send + Sync> trades type safety for object safety. If a trait needs to be object-safe AND return meaningful errors, the associated type approach breaks object safety -- use Box<dyn Error + Send + Sync> and document the trade-off explicitly.
Error Conversion in Large Dependency Trees
When a crate depends on many sub-crates, each with their own error types, the top-level error enum can explode in variant count. Signs of this problem: your AppError enum has 40+ variants, each wrapping a single foreign error type. The solution is error aggregation layers: create DatabaseError, NetworkError, StorageError mid-level enums that each consolidate a domain's errors, then have AppError wrap these mid-level enums. This limits AppError to 6-10 variants representing business domains, not implementation details.
The anyhow / thiserror Mix in a Workspace
In a Cargo workspace with multiple crates, the pattern is: all lib crates use thiserror; the application binary crate uses anyhow. The binary's main.rs imports library errors as use my_lib::LibError and they convert to anyhow::Error transparently via ? because anyhow implements From<E: std::error::Error>. This is seamless -- you do not need to write any conversion glue. The only mistake is accidentally publishing an internal anyhow dependency in a library's public API.
serde Deserialization Errors with Custom Context
serde_json::Error exposes line/column information via .line() and .column() methods. When wrapping serde errors in your own type, preserve this structured data as fields rather than stringifying it:
#[derive(Debug, thiserror::Error)]
#[error("invalid JSON in {file} at line {line}, column {column}")]
pub struct ParseError {
pub file: PathBuf,
pub line: usize,
pub column: usize,
#[source]
pub source: serde_json::Error,
}
This allows callers to programmatically access position data, which is essential for editors and language servers.
miette for User-Facing CLI Errors
When the end user is a developer (e.g., a CLI tool, build system, or compiler plugin), miette provides diagnostics with source code snippets, labels, and help text -- similar to Rust compiler errors. Use miette::Diagnostic derive alongside thiserror::Error. This is most valuable when your tool processes source files or configuration formats where pointing to the exact problematic line/column is critical. miette is NOT needed for server applications where errors go to log aggregators.
Recoverable vs. Fatal Error Distinction
Some errors indicate unrecoverable state corruption (e.g., a corrupted database page, an impossible enum variant from deserialization). These should NOT be modeled as error variants to be matched and recovered. Use panic! with a clear message, or define a separate Fatal type that your top-level handler converts to process exit with a diagnostic. Mixing recoverable and fatal errors in the same enum forces callers to handle cases that can never succeed.
Example
Input: "I'm building a Rust library crate that parses TOML configuration files and loads them from disk. It needs to work in an async context with tokio. What error handling approach should I use?"
Output:
Error Handling Strategy for a TOML Configuration Library
Crate Type Assessment
Library crate -- error types must be strongly typed and matchable by downstream application code. anyhow is excluded from the public API.
Recommended Dependencies
# Cargo.toml
[dependencies]
thiserror = "1"
tokio = { version = "1", features = ["fs"] }
toml = "0.8"
serde = { version = "1", features = ["derive"] }
Decision Matrix
| Scenario | Approach | Rationale |
|---|---|---|
Public ConfigError type |
thiserror enum |
Callers must match NotFound vs ParseError vs IoError |
Wrapping std::io::Error |
#[source] + PathBuf field |
Two IO variants prevent #[from] use |
Wrapping toml::de::Error |
#[from] if single variant |
Only one TOML parse error variant -- #[from] is safe |
| Async tokio FS operations | Same ? propagation |
No special async error handling needed |
| Application using this library | .context() with anyhow |
App layer adds runtime context, library type is preserved |
Error Type Definition
use std::path::PathBuf;
use thiserror::Error;
/// All errors that can occur when loading or parsing a configuration file.
/// This enum is `#[non_exhaustive]` to allow adding variants in future minor versions
/// without breaking downstream `match` expressions.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
/// The configuration file was not found at the specified path.
/// Callers may want to fall back to a default config in this case.
#[error("config file not found at `{path}`")]
NotFound { path: PathBuf },
/// The file exists but could not be read (e.g., permission denied).
/// Distinct from `NotFound` because the recovery strategy differs.
#[error("failed to read config file `{path}`")]
ReadFailed {
path: PathBuf,
#[source]
source: std::io::Error,
},
/// The file was read successfully but contains invalid TOML syntax.
#[error("failed to parse config file `{path}`")]
ParseFailed {
path: PathBuf,
#[source]
source: toml::de::Error,
},
/// The TOML was valid but did not match the expected schema (missing fields,
/// wrong types, failed validation rules).
#[error("config file `{path}` has invalid structure: {message}")]
InvalidStructure { path: PathBuf, message: String },
}
// Verify Send + Sync at compile time -- required for use in tokio tasks.
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ConfigError>();
};
Propagation Pattern
use serde::Deserialize;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Deserialize)]
pub struct AppConfig {
pub database_url: String,
pub port: u16,
pub max_connections: Option<u32>,
}
pub async fn load_config(path: &Path) -> Result<AppConfig, ConfigError> {
// Use map_err to attach path context -- #[from] cannot be used here because
// we have two variants wrapping io::Error (NotFound and ReadFailed).
let contents = match fs::read_to_string(path).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ConfigError::NotFound { path: path.to_owned() });
}
Err(e) => {
return Err(ConfigError::ReadFailed {
path: path.to_owned(),
source: e,
});
}
};
// toml::from_str returns toml::de::Error -- use map_err to attach path.
let config: AppConfig = toml::from_str(&contents).map_err(|e| ConfigError::ParseFailed {
path: path.to_owned(),
source: e,
})?;
// Business logic validation after successful parse.
if config.port == 0 {
return Err(ConfigError::InvalidStructure {
path: path.to_owned(),
message: "port must be non-zero".to_string(),
});
}
Ok(config)
}
Boundary Handling in Application Code
The application binary uses anyhow for ergonomics. The library's typed errors convert automatically:
// In the application's main.rs (binary crate, not the library)
use anyhow::{Context, Result};
use std::path::PathBuf;
use my_config_lib::{load_config, ConfigError};
#[tokio::main]
async fn main() -> Result<()> {
let config_path = PathBuf::from("/etc/myapp/config.toml");
// Option 1: Let the error chain speak for itself via anyhow
let config = load_config(&config_path).await
.context("startup failed: could not load application config")?;
// Option 2: Handle specific variants for recovery
let config = match load_config(&config_path).await {
Ok(c) => c,
Err(ConfigError::NotFound { .. }) => {
eprintln!("No config file found, using defaults");
AppConfig::default()
}
Err(e) => return Err(anyhow::anyhow!(e).context("failed to load config")),
};
println!("Server starting on port {}", config.port);
Ok(())
}
Testing Error Paths
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tokio::fs;
#[tokio::test]
async fn test_missing_file_returns_not_found() {
let result = load_config(Path::new("/nonexistent/path/config.toml")).await;
assert!(
matches!(result, Err(ConfigError::NotFound { .. })),
"expected NotFound, got: {:?}",
result
);
}
#[tokio::test]
async fn test_invalid_toml_returns_parse_failed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, b"not valid toml ][[[").await.unwrap();
let result = load_config(&path).await;
assert!(matches!(result, Err(ConfigError::ParseFailed { .. })));
}
#[tokio::test]
async fn test_zero_port_returns_invalid_structure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, b"database_url = 'postgres://localhost'\nport = 0").await.unwrap();
let result = load_config(&path).await;
assert!(matches!(result, Err(ConfigError::InvalidStructure { .. })));
}
#[test]
fn test_error_display_messages() {
let err = ConfigError::NotFound {
path: PathBuf::from("/etc/app/config.toml"),
};
assert_eq!(
format!("{}", err),
"config file not found at `/etc/app/config.toml`"
);
}
}
Common Pitfalls to Avoid
- Do not add
anyhowas a dependency of this library. It belongs in the binary. Adding it to a library's[dependencies]is a code smell that will propagateanyhow::Errorinto your public API. - Do not use
#[from] source: std::io::Errorwhen you have two variants both wrappingio::Error(NotFoundandReadFailed). The compiler will reject duplicateFromimpls. Use explicitmap_errat call sites as shown above. - Do not forget
#[non_exhaustive]before the first0.1.0publish. Adding it after first release is a breaking change in the opposite direction -- it would break existing exhaustive matches in downstream code. - Do not embed
source.to_string()in variant fields. Keep the source error as a typed#[source]field. String conversion throws away the entire error chain below it and makes the source untraversable viaError::source(). - Do not panic on
toml::from_strerrors even during development. The habit of usingunwrap()in library parsing code is how panics slip into production libraries used by others.