The ‘Result’ Headache in Production
When I first moved to Rust, the Result<T, E> enum felt like a superpower. It forced me to confront failure cases immediately, a refreshing change from the ‘try-catch and pray’ logic I used in Python. But as my projects grew from simple scripts to microservices with 50+ modules, I hit a wall. My function signatures became a cluttered mess of nested generics. I was spending more time writing 30-line From<OtherError> implementations than actually shipping features.
In a production environment, messy error handling is more than just an eyesore. If you don’t structure your errors properly, you’ll eventually fall into the trap of using .unwrap() just to make the compiler be quiet. This turns your ‘safe’ Rust binary into a crash-prone liability. To avoid this, I rely on two industry-standard crates: thiserror and anyhow. They help keep code clean, descriptive, and—most importantly—easy to debug at 2 AM when a production pod fails.
Choosing Your Tool: thiserror vs. anyhow
New Rust developers often ask which crate is better. The truth is they serve different masters. Both are maintained by David Tolnay, but they solve different parts of the error puzzle.
thiserror: Defining the Contract
I use thiserror when building libraries or internal domain modules. It’s a procedural macro that generates the Error trait for you. Use this when you need to define exactly what went wrong so that the caller can react to specific cases. It’s about creating a clear, typed API contract.
anyhow: Handling the Flow
I reserve anyhow for the application level—think main.rs, CLI entry points, or high-level request handlers. It’s built for cases where you don’t care about the specific error type; you just need to report it and add context. It’s the ultimate tool for handling the aftermath of a failure.
Hands-on: Building a Resilient Data Pipeline
Let’s look at a common scenario. We need a service that reads a JSON config, connects to a database, and processes user records. Each step—I/O, parsing, and networking—can fail in its own unique way.
Step 1: Configuration
First, update your Cargo.toml with these dependencies:
[dependencies]
thiserror = "1.0"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Step 2: Defining Domain Errors
Instead of using generic strings, we’ll define a structured enum for our data module. This tells other developers exactly what failure modes to expect.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataError {
#[error("Configuration file missing at: {0}")]
NotFound(String),
#[error("Malformed JSON in config file")]
InvalidFormat(#[from] serde_json::Error),
#[error("Database connection timed out after 30s: {0}")]
ConnectionError(String),
#[error("Internal data corruption detected")]
Unknown,
}
The #[from] attribute is a massive time-saver. It automatically generates the From<serde_json::Error> implementation. This allows the ? operator to convert a JSON error into your custom DataError automatically. You just saved 15 lines of boilerplate.
Step 3: Adding Context with anyhow
Now, let’s implement the logic. We’ll use anyhow here because it allows us to attach “context” to errors. This is the difference between a log that says “File not found” and one that tells you exactly which file was missing and why the app was trying to read it.
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("Critical failure: Unable to load config from {}", path))?;
Ok(content)
}
fn process_data() -> Result<()> {
let config = load_config("settings.json")?;
// Logic follows...
Ok(())
}
If load_config fails, anyhow produces a detailed error chain. You’ll see: “Critical failure: Unable to load config from settings.json: No such file or directory (os error 2)”. This level of detail is a lifesaver for debugging distributed systems.
Managing Nested Errors
A frequent mistake is losing the original error cause when wrapping results. By combining both crates, you can preserve the entire stack trace. In microservices, the most frustrating bugs are “Internal Server Error” messages that hide a simple timeout in a downstream dependency.
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Downstream service failure")]
External(#[source] anyhow::Error),
#[error("User input validation failed: {0}")]
Validation(String),
}
fn call_third_party_api() -> anyhow::Result<()> {
Err(anyhow::anyhow!("Gateway Timeout"))
}
fn handle_request() -> Result<(), ApiError> {
call_third_party_api().map_err(ApiError::External)?;
Ok(())
}
By using #[source], you tell Rust that anyhow::Error is the root cause. You can then use anyhow‘s .chain() method to iterate through every layer of the error to see exactly where the failure started.
Keeping Data Pipelines Resilient
When processing a batch of 1,000 records, you rarely want one bad entry to kill the entire process. I prefer combining Result with iterators to separate the successes from the failures. This keeps the pipeline moving while capturing errors for the logs.
let paths = vec!["prod.json", "staging.json", "broken.json"];
let (successes, failures): (Vec<_>, Vec<_>) = paths
.into_iter()
.map(|p| load_config(p))
.partition(Result::is_ok);
let valid_configs: Vec<String> = successes.into_iter().map(Result::unwrap).collect();
let errors: Vec<anyhow::Error> = failures.into_iter().map(Result::unwrap_err).collect();
This pattern ensures your worker thread doesn’t panic. You process the valid data and move the 5% of failures into a dead-letter queue or a logging service for later inspection.
Final Thoughts
The choice between thiserror and anyhow isn’t about which is better; it’s about context. If you are writing a library that others will import, use thiserror to provide clear, typed errors. If you are writing the ‘glue’ code of an application, use anyhow to keep things concise and your logs informative.
Adopting these tools makes Rust codebases significantly more readable. You stop fighting the borrow checker’s relationship with errors and start using the type system to build more reliable software. Try replacing a few match blocks with anyhow::Context today—your future self will thank you when the next bug appears.

