Stop Using unwrap(): The Engineering Way to Handle Rust Errors in Production
Rust production error handling is where the gap between “compiles clean” and “survives real traffic” becomes visible. The compiler gives you a false sense of security — no null pointers, no undefined behavior, memory-safe by construction. So you write unwrap() because you’re “sure” the value is there, or panic!() because “this should never happen.” Then your JSON parser hits malformed input at 3am, a thread in your pool panics, the Tokio runtime catches it, and your service starts dropping requests while your monitoring shows CPU at 12% and everything “green.” The panic didn’t crash the binary. It just killed that task silently. This page is about building Rust network services that fail loudly, fail correctly, and keep running.
TL;DR
panic!in an async task or thread pool doesn’t crash your process — it kills that task silently, drops the request, and leaves your service in a degraded state with no visible errorthiserrorfor libraries and domain error types,anyhowfor application binary layers — combining both is the 2026 standard, not a sign of confusion- The
?operator with aFromimplementation is idiomatic error propagation — manualmatchon everyResultis not Result<T, E>has zero runtime cost on the success path — the myth thatResultis slow comes from people who haven’t read the generated assemblyunwrap()is acceptable in tests and inmain()during prototype phase — it is never acceptable in request handlers or library code- Map your domain errors to HTTP status codes at the middleware layer, not scattered across individual handlers
The Panic Trap: Why panic! in a Network Service Is a Death Sentence
A panic! in a synchronous single-threaded program crashes the process. Loud, obvious, easy to diagnose. A panic! in a Tokio async task unwinds that task, drops the future, and Tokio moves on. Your HTTP handler returns nothing. The client gets a connection reset or a timeout. Your logs show nothing unless you’ve explicitly set up panic hooks. Your metrics show a request drop, not an error. This is the failure mode that kills production availability while every dashboard stays green.
// Rust — the silent killer: panic in an async handler
// This compiles fine, passes tests, destroys production availability
async fn get_user(id: u64) -> Json {
let user = db.find_user(id).await.unwrap(); // panics if None or Err
Json(user)
// When this panics: task aborts, client gets TCP reset,
// no error log, no metric increment, no alert fires
}
// The correct version: return a Result, let the framework handle it
async fn get_user_correct(id: u64) -> Result<Json, AppError> {
let user = db.find_user(id).await?; // propagates, doesn't panic
Ok(Json(user))
// When this errors: framework converts AppError to HTTP response,
// logs fire, metrics increment, client gets proper 404 or 500
}
The ? operator on the correct version does three things: propagates the error upward, converts the error type via From if needed, and returns early — all in one character. The unwrap() version does one thing: panics when wrong. In production, the difference is the difference between a handled error and a silent request drop.
Rule of thumb: If unwrap() or panic! appears in a request handler, it’s a bug. Review it, wrap it, or remove it.
Rust Result vs Panic Performance: Debunking the Myth
Result<T, E> is a zero-cost abstraction on the success path. The compiler generates the same code for Result::Ok(value) as it would for returning the value directly — on x86-64, it’s typically a register or stack slot with a discriminant bit that gets optimized away in the hot path. The error path has overhead proportional to the error type’s size and construction cost, but the error path is by definition not your hot path. Benchmarks comparing Result to raw returns show no measurable difference in tight loops. The “Result is slow” myth comes from profiling code that’s allocating large error objects on every call — that’s an error type design problem, not a Result problem.
Rust anyhow vs thiserror: The 2026 Practical Reality
The debate between anyhow and thiserror has a clear answer in 2026: use both, in different layers, for different purposes. Treating this as an either/or choice is the wrong frame.
thiserror derives std::error::Error, Display, and From implementations for your enum at compile time — zero runtime cost, full type information preserved for callers who need to match on variants. This is what library code and domain error types should use, because your callers need to know what kind of error they received.
anyhow erases the error type into a single anyhow::Error — any error implementing std::error::Error can be wrapped with one heap allocation. This is what application binary code should use for the “plumbing” layer between domain errors and the output surface. You lose type-level matching in exchange for ergonomics and context-chain building.
Beyond the Compiler: 3 Dangerous Rust Memory Safety Myths Despite the widespread adoption of the language, several Rust memory safety myths persist among developers, giving a false sense of invincibility in production systems. Engineers often...
| Scenario | Use thiserror | Use anyhow |
|---|---|---|
| Library crate public API | ✅ Callers need typed errors | ❌ Leaks implementation details |
| Domain error enum (AppError) | ✅ Maps to HTTP codes by variant | ❌ Loses variant info for mapping |
| Application service layer | ⚠️ Verbose for glue code | ✅ .context() chains are ergonomic |
| CLI binary main() | ❌ Overkill for one-shot tools | ✅ anyhow::Result<()> is idiomatic |
| Axum/Actix handler return type | ✅ AppError → IntoResponse | ❌ Can’t implement IntoResponse cleanly |
The 2026 pattern: define your domain AppError with thiserror, implement IntoResponse on it for Axum, and use anyhow in your service layer functions that don’t need typed error matching — converting to AppError at the boundary where it matters. Each crate in your workspace uses the tool appropriate for its layer.
Rule of thumb: If your function’s caller needs to match on error variants — use thiserror. If your function just propagates errors upward to be logged — use anyhow.
Rust Custom Error Types: Building AppError the Right Way
Your application’s central error type is the contract between your domain logic and your API surface. It needs to be exhaustive enough to map to correct HTTP status codes, expressive enough to carry context for logging, and simple enough that adding a new error variant doesn’t require touching a dozen files.
// Rust — production AppError with thiserror (Axum-ready)
use thiserror::Error;
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
use axum::Json;
#[derive(Error, Debug)]
pub enum AppError {
#[error("resource not found: {resource}")]
NotFound { resource: String },
#[error("unauthorized: {reason}")]
Unauthorized { reason: String },
#[error("validation failed: {field}: {message}")]
Validation { field: String, message: String },
#[error("database error")]
Database(#[from] sqlx::Error), // From impl generated by thiserror
#[error("internal error")]
Internal(#[from] anyhow::Error), // catch-all for unexpected failures
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound { .. } => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Unauthorized { .. } => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::Validation { .. } => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Database(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable".into()),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
The #[from] attribute on Database and Internal generates From<sqlx::Error> for AppError and From<anyhow::Error> for AppError automatically — which means ? on any sqlx call inside a handler that returns Result<T, AppError> converts automatically. No manual .map_err() chains.
Rule of thumb: Map errors to HTTP status codes exactly once, in IntoResponse. Never in individual handlers.
Idiomatic Error Propagation: The From Trait and the ? Operator
Rust idiomatic error propagation has one rule: use ?, implement From. The ? operator desugars to a match that calls From::from(err) on the error type before returning early. If your function returns Result<T, AppError> and calls something that returns Result<T, sqlx::Error>, the ? operator converts sqlx::Error to AppError automatically — as long as From<sqlx::Error> for AppError exists, which thiserror‘s #[from] generates.
// Rust — idiomatic propagation chain vs manual match hell
// WRONG: manual match on every Result — verbose, error-prone, un-Rusty
async fn get_order(id: u64) -> Result<Order, AppError> {
let user = match db.find_user(id).await {
Ok(u) => u,
Err(e) => return Err(AppError::Database(e)),
};
let order = match db.find_order(user.id).await {
Ok(o) => o,
Err(e) => return Err(AppError::Database(e)),
};
Ok(order)
}
// RIGHT: idiomatic ? chain — From trait handles conversion automatically
async fn get_order_idiomatic(id: u64) -> Result<Order, AppError> {
let user = db.find_user(id).await?; // sqlx::Error → AppError::Database
let order = db.find_order(user.id).await?; // same conversion, same line
Ok(order)
}
The idiomatic version is half the lines, reads top-to-bottom like synchronous code, and handles conversion transparently. The manual version is what you write when you haven’t set up From implementations — which is the signal that your error type architecture needs work, not that match is the answer.
Rule of thumb: If you’re writing .map_err(|e| AppError::Something(e)) more than once, add a #[from] to your error enum and let the compiler do the conversion.
What the Compiler Won't Fix: Rust Performance Optimization in Production Most engineers hit Rust for the first time and assume the borrow checker is the only thing standing between them and blazing-fast code. Rust performance...
Rust Error Logging Best Practices: Log at the Boundary, Not the Source
The most common error logging mistake in Rust network services: logging the error at every propagation point, producing five log lines for one failed request. The correct pattern: propagate silently with ?, log exactly once at the outermost boundary where the error leaves your application — the middleware or the response handler. Use tracing with structured fields, not println! or bare eprintln!. Include the full error chain with {:#} formatting on anyhow::Error or implement Display on your error chain explicitly.
The Must-Not List: Anti-Patterns That Break Production Rust
unwrap() in handlers. Every unwrap() in a request handler is a latent panic waiting for the input case you didn’t test. Replace with ?, ok_or(AppError::NotFound {...}), or an explicit match if the semantics require it. There are no exceptions for “this value is always Some” — the value isn’t always Some when malformed input hits production.
Silent errors via .ok() or if let. result.ok() converts a Result to an Option, discarding the error. Fine for “I genuinely don’t care about the error.” Catastrophic when used to silence errors you should be propagating. If you use .ok(), write a comment explaining why you’re discarding the error. If you can’t write that comment confidently, you’re hiding a bug.
String-typed errors. Result<T, String> is the error type of someone who wanted to move fast and skip the error type design. Callers can’t match on string errors. You can’t add context to string errors without concatenation. You can’t implement From for automatic conversion. String errors are acceptable in prototypes and scripts. They’re technical debt in a production service.
Mixing panic strategy across layers. If your library panics on invalid input and your application catches panics at the thread boundary, you have an implicit contract that isn’t expressed in the type system. Libraries should return Result or Option for recoverable errors. Panics in libraries are for programmer errors — violating documented preconditions — not for runtime errors like network failures or malformed input.
// Rust — error handling middleware for Axum (centralized logging)
use axum::{middleware::Next, response::Response, extract::Request};
use tracing::error;
pub async fn error_logging_middleware(
req: Request,
next: Next,
) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let response = next.run(req).await;
if response.status().is_server_error() {
// Log 5xx responses centrally — not in each handler
error!(
method = %method,
uri = %uri,
status = response.status().as_u16(),
"server error response"
);
}
response
// Takeaway: one log point for errors, structured fields for tracing,
// handlers stay clean — no tracing::error! scattered across handlers
}
Rule of thumb: One log statement per error per request. If you’re seeing duplicate error logs for the same request, you’re logging at the wrong layer.
Checklist for PR Review
Before pushing to production, run your code through these three checks. If you fail any of these, don’t even think about merging:
- ❌
Are there anyunwrap()orexpect()calls?
(These are time bombs in request handlers—get rid of them.) - ✅
Does the error propagate with??
(Keep the code clean, rely on theFromtrait, and stop manual matching.) - ✅
Is the error logged at the boundary?
(Avoid log spamming—log once, log clearly, and keep it structured.)
FAQ: Rust Production Error Handling
When is unwrap() actually acceptable in Rust?
In test code — unwrap() in tests makes failures obvious and the panic message shows the test that failed. In main() during early prototyping where you want fast iteration. In code where the Option or Result is provably Some/Ok by construction and you want to communicate that intent — use expect("reason this is always Some") to document the invariant. Never in request handlers, never in library public API, never in code that runs under concurrent load.
What is the performance cost of Result in Rust?
Zero on the success path. The compiler optimizes Result::Ok(value) to direct value passing in most cases — no heap allocation, no dynamic dispatch, no overhead compared to returning the value directly. The error path allocates when the error type allocates, which is your choice to control through error type design. Benchmarks comparing tight loops with Result versus direct returns show no measurable difference. The myth originates from code that allocates large error objects unnecessarily.
Rust Error Handling: anyhow vs thiserror and the ? Operator Explained Rust error handling with anyhow vs thiserror is the decision every Rust developer hits the moment their project grows past a single file. You...
Should I use anyhow or thiserror for a new Rust service in 2026?
Both. Define your domain error enum with thiserror — it gives you typed variants you can map to HTTP status codes and From implementations generated automatically. Use anyhow in service-layer functions that just propagate errors upward without needing to match on variants. Convert anyhow::Error to your AppError::Internal variant at the boundary where typed errors matter. This combination is the current production standard — not a compromise, a deliberate layered architecture.
How do I handle network errors in Rust production services?
Define a variant in your AppError enum for external service failures — AppError::ExternalService or AppError::Network — with a #[from] for the underlying client error type (reqwest, tonic, etc.). Set explicit timeouts on all outbound calls — never rely on default timeouts. Use .context() from anyhow to add the target service name to errors before they propagate, so logs identify which downstream service failed. Map network failures to 503 Service Unavailable, not 500 Internal Server Error — the distinction matters for upstream retry logic.
What is idiomatic error propagation in Rust?
Use the ? operator and implement From trait conversions between error types — either manually or via thiserror‘s #[from] attribute. The ? operator calls From::from(err) automatically when propagating, converting the source error type to the function’s return error type. This produces clean, top-to-bottom readable code without manual match or .map_err() chains. If you find yourself writing repetitive conversion closures, that’s the signal to add a From implementation instead.
How do I map Rust errors to HTTP status codes?
Implement IntoResponse from Axum (or the equivalent for your framework) on your AppError enum. In that implementation, match on error variants and return the appropriate StatusCode. Do this once, centrally, not in individual handlers. Handlers return Result<T, AppError> and let the framework call IntoResponse — they never manually construct error responses. This keeps status code mapping in one place, making it easy to audit and change without touching every handler.
Is it safe to use panic! for “impossible” cases in Rust?
In synchronous single-threaded code that you control end-to-end — defensible for genuine programmer errors (violated documented preconditions). In async tasks, thread pools, or any concurrent context — no. A panic in an async task unwinds that task silently. The request drops. No error is returned to the client. No log fires unless you’ve installed a custom panic hook. Use unreachable!() with a message for branches that are impossible by construction, and accept that if you’re wrong, you’ll see the message in your panic output. For anything that could be triggered by external input, use Result.
Why is Rust error handling with Box<dyn Error> considered an anti-pattern?
Box<dyn Error> erases the error type entirely — callers can’t match on variants, can’t implement From conversions cleanly, and must downcast to recover type information. It’s roughly equivalent to returning a string error but with slightly better tooling support. anyhow::Error does the same type erasure but adds ergonomic context-building, better display formatting, and consistent integration with the Rust error ecosystem. For application code that needs type erasure, use anyhow. For code where callers need types, use thiserror. Avoid bare Box<dyn Error> in 2026 — it has no advantages over either alternative.
While we treat panic! as a failure to be avoided, it is technically the runtime’s emergency brake. If you are currently dealing with production crashes and need to understand the low-level mechanics of stack unwinding and backtrace analysis, our guide on Rust Panic at Runtime covers exactly how to read those crash logs and why your ‘safe’ app still hits the floor.
Written by: