Rust Error Handling in 2026: Result, Option, thiserror and anyhow
Master Rust error handling with Result, Option, the ? operator, thiserror for libraries, and anyhow for applications. Practical patterns for 2026.

Rust error handling centers on two enums—Result<T, E> and Option<T>—that force explicit handling of success, failure, and absence at compile time. Unlike exceptions that propagate invisibly, Rust's approach makes error paths visible in function signatures, eliminating entire categories of runtime surprises. The ? operator, combined with crates like thiserror and anyhow, streamlines this explicit model without sacrificing clarity.
Use Option<T> for values that may legitimately be absent (config fields, search results). Use Result<T, E> when operations can fail with meaningful error information (file I/O, network requests, parsing).
Understanding Result and Option Fundamentals
Result<T, E> represents either success (Ok(T)) or failure (Err(E)). Option<T> represents either a value (Some(T)) or absence (None). Both are sum types—the compiler ensures every variant gets handled.
// Demonstrates Result and Option basic patterns
fn find_user(id: u64) -> Option<String> {
// Returns None if user doesn't exist
if id == 0 {
None
} else {
Some(format!("User-{}", id))
}
}
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
// Returns Err if parsing fails
s.parse::<u16>()
}
fn main() {
// Option handling - must address None case
match find_user(42) {
Some(name) => println!("Found: {}", name),
None => println!("User not found"),
}
// Result handling - must address Err case
match parse_port("8080") {
Ok(port) => println!("Port: {}", port),
Err(e) => println!("Invalid port: {}", e),
}
}The compiler rejects code that ignores these return values without explicit handling. This design catches bugs at compile time that would surface as null pointer exceptions or uncaught errors in other languages.
The Question Mark Operator for Concise Propagation
The ? operator transforms verbose match chains into readable linear code. When applied to a Result, it returns early with the error if present, or unwraps the success value. The same applies to Option.
// Using ? for clean error propagation
use std::fs::File;
use std::io::{self, BufRead, BufReader};
fn read_first_line(path: &str) -> Result<String, io::Error> {
let file = File::open(path)?; // Returns early if open fails
let mut reader = BufReader::new(file);
let mut line = String::new();
reader.read_line(&mut line)?; // Returns early if read fails
Ok(line.trim().to_string())
}
fn get_port_from_config(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
let content = read_first_line(path)?;
let port = content.parse::<u16>()?; // ParseIntError converts via From
Ok(port)
}The ? operator requires the error type to be convertible to the function's return error type via the From trait. Using Box<dyn std::error::Error> as shown above accepts any error type implementing the standard Error trait.
Creating Custom Errors with thiserror
The thiserror crate eliminates boilerplate for custom error types. It derives Error, Display, and From implementations through a procedural macro.
// Custom error types using thiserror 2.0
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("configuration file not found at {path}")]
NotFound { path: String },
#[error("invalid port number: {0}")]
InvalidPort(#[from] std::num::ParseIntError),
#[error("IO error reading config")]
IoError(#[from] std::io::Error),
#[error("missing required field: {0}")]
MissingField(String),
}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path)?; // IoError auto-converts
let port: u16 = content
.lines()
.find(|l| l.starts_with("port="))
.ok_or(ConfigError::MissingField("port".into()))?
.strip_prefix("port=")
.unwrap()
.parse()?; // ParseIntError auto-converts to InvalidPort
Ok(Config { port })
}
struct Config {
port: u16,
}The #[from] attribute generates automatic conversions, enabling seamless use of ? with different underlying error types. Error messages become self-documenting through the #[error(...)] format strings.
Application-Level Errors with anyhow
While thiserror suits library code with specific error types, anyhow targets applications where error context matters more than type granularity. Its Context trait adds descriptive messages to any error.
// Application error handling with anyhow 1.0
use anyhow::{Context, Result, bail, ensure};
fn load_database_url() -> Result<String> {
std::env::var("DATABASE_URL")
.context("DATABASE_URL environment variable not set")
}
fn connect_to_database(url: &str) -> Result<DatabaseConnection> {
ensure!(!url.is_empty(), "database URL cannot be empty");
let conn = DatabaseConnection::new(url)
.context("failed to establish database connection")?;
if !conn.is_healthy() {
bail!("database connection unhealthy after establishment");
}
Ok(conn)
}
fn main() -> Result<()> {
let url = load_database_url()?;
let conn = connect_to_database(&url)
.context("application startup failed")?;
// Context chains create readable error traces:
// Error: application startup failed
// Caused by:
// 0: failed to establish database connection
// 1: connection refused
Ok(())
}
struct DatabaseConnection;
impl DatabaseConnection {
fn new(_url: &str) -> Result<Self> { Ok(Self) }
fn is_healthy(&self) -> bool { true }
}The context() method wraps errors with additional information, creating a chain that aids debugging. The bail! macro provides early exit with a formatted error message, while ensure! acts as an assertion that returns an error instead of panicking.
Ready to ace your Rust interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Combining thiserror and anyhow in Real Projects
Libraries expose structured errors via thiserror for programmatic handling by consumers. Applications wrap those errors with anyhow for human-readable output. This separation keeps APIs clean while maintaining debuggability.
// Exposes typed errors for programmatic handling
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PaymentError {
#[error("insufficient funds: required {required}, available {available}")]
InsufficientFunds { required: u64, available: u64 },
#[error("card declined: {reason}")]
CardDeclined { reason: String },
#[error("payment provider unavailable")]
ProviderUnavailable(#[source] reqwest::Error),
}
pub fn process_payment(amount: u64) -> Result<Receipt, PaymentError> {
// Library returns specific, matchable error types
Err(PaymentError::InsufficientFunds {
required: amount,
available: 50,
})
}
pub struct Receipt;// Wraps library errors with context
use anyhow::{Context, Result};
use my_payment_lib::{process_payment, PaymentError};
fn checkout(cart_total: u64) -> Result<()> {
match process_payment(cart_total) {
Ok(_receipt) => Ok(()),
Err(PaymentError::InsufficientFunds { required, available }) => {
// Handle specific case differently
println!("Add {} to your balance", required - available);
Ok(())
}
Err(e) => Err(e).context("checkout payment processing failed"),
}
}This pattern enables callers to match on specific variants when recovery is possible, while still benefiting from rich error context when propagating failures upward. The Rust community has largely standardized on this approach, as discussed in the Rust API guidelines.
Error Handling Patterns for Async Code
Async functions return Result just like synchronous ones. The ? operator works identically within async blocks, and both thiserror and anyhow integrate without modification.
// Error handling in async Rust with Tokio
use anyhow::{Context, Result};
use std::time::Duration;
async fn fetch_user_data(user_id: u64) -> Result<UserData> {
let response = reqwest::get(format!("https://api.example.com/users/{}", user_id))
.await
.context("HTTP request to user API failed")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("user API returned status {}", status);
}
let data: UserData = response
.json()
.await
.context("failed to parse user data JSON")?;
Ok(data)
}
async fn fetch_with_retry(user_id: u64, attempts: u32) -> Result<UserData> {
let mut last_error = None;
for attempt in 1..=attempts {
match fetch_user_data(user_id).await {
Ok(data) => return Ok(data),
Err(e) => {
last_error = Some(e);
if attempt < attempts {
tokio::time::sleep(Duration::from_millis(100 * attempt as u64)).await;
}
}
}
}
Err(last_error.unwrap()).context(format!("failed after {} attempts", attempts))
}
#[derive(serde::Deserialize)]
struct UserData {
name: String,
}When combining multiple async operations, use try_join! from tokio or futures to run them concurrently while propagating the first error. For related concepts, explore the async/await interview questions module.
Downcasting and Error Inspection
Both anyhow::Error and Box<dyn Error> support downcasting to recover the original error type. This enables logging specific details while still propagating generic errors.
// Inspecting wrapped error types
use anyhow::{Context, Result};
use std::io;
fn log_and_propagate(result: Result<()>) -> Result<()> {
if let Err(ref e) = result {
// Check if the root cause is a specific type
if let Some(io_err) = e.downcast_ref::<io::Error>() {
match io_err.kind() {
io::ErrorKind::NotFound => {
tracing::warn!("file not found, using defaults");
}
io::ErrorKind::PermissionDenied => {
tracing::error!("permission denied - check file ownership");
}
_ => {
tracing::error!("IO error: {:?}", io_err);
}
}
}
}
result
}Downcasting bridges the gap between generic error handling and specific recovery logic. Use it sparingly—if frequent downcasting occurs, consider whether a typed error enum would serve better.
Converting Between Option and Result
The standard library provides methods to convert between Option and Result, enabling smooth composition when different APIs use different patterns.
// Option and Result interoperability
fn get_env_port() -> Option<u16> {
std::env::var("PORT")
.ok() // Result -> Option (discards error)
.and_then(|s| s.parse().ok())
}
fn get_env_port_with_error() -> Result<u16, String> {
std::env::var("PORT")
.map_err(|_| "PORT not set".to_string())?
.parse()
.map_err(|_| "PORT is not a valid number".to_string())
}
fn lookup_and_parse(map: &std::collections::HashMap<String, String>, key: &str) -> Result<u16, String> {
map.get(key)
.ok_or_else(|| format!("key '{}' not found", key))? // Option -> Result
.parse()
.map_err(|e| format!("parse error for '{}': {}", key, e))
}The ok() method discards error details when only presence matters. The ok_or() and ok_or_else() methods convert None into a custom error, enabling ? propagation from Option values. These patterns appear frequently in pattern matching interview questions.
Performance Considerations
Error handling in Rust carries no runtime cost on the success path. Result and Option are stack-allocated enums with deterministic size. The compiler optimizes away checks when it can prove a branch is unreachable.
| Approach | Success Path Cost | Failure Path Cost | |----------|------------------|-------------------| | Result/Option | Zero | Stack unwinding (cheap) | | panic! | Zero | Full stack unwinding + cleanup | | C++ exceptions | Zero (usually) | Expensive heap allocation + RTTI |
Avoid unwrap() and expect() in library code—reserve them for cases where failure genuinely indicates a bug. For performance-critical paths where errors are common, consider using enums with inline data rather than heap-allocated error types.
Conclusion
Result<T, E>handles recoverable failures;Option<T>handles absence—both enforce compile-time handling- The
?operator propagates errors concisely, requiringFromtrait implementations for type conversion thiserrorgenerates structured error types for libraries with zero boilerplateanyhowprovides contextual error chains for applications, supportingcontext(),bail!, andensure!- Combine both: libraries expose typed errors via
thiserror, applications wrap them withanyhow - Async code uses identical patterns—
?works in async blocks without modification - Use
ok_or()to convertOptiontoResult; useok()for the reverse - Downcast wrapped errors only when specific recovery logic requires the original type
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Rust Smart Pointers Explained: Box, Rc, Arc and RefCell in 2026
Rust smart pointers Box, Rc, Arc and RefCell explained with compilable 2026 examples, a decision table and common interview questions.

Rust Traits and Generics in 2026: Trait Upcasting, AsyncFn and Advanced Patterns
Master Rust traits and generics with the latest 2024 Edition features: trait upcasting, AsyncFn closures, RPITIT, and advanced patterns tested in real interviews.

Async/Await in Rust: Tokio, Futures and Asynchronous Concurrency Explained
Rust async/await deep dive covering Tokio runtime, Futures trait, task spawning, structured concurrency, and real-world patterns for building high-performance asynchronous applications.