Skip to content

Error Handling Patterns

Rust treats errors as values, not exceptions. This is a fundamental design choice: errors are not Special control flow mechanisms that can jump across function boundaries. They are ordinary values That propagate through the type system via Result<T, E>. This makes error paths explicit and force The programmer to handle them.

The core principle: make error states unrepresentable where possible, and where they are Representable, make them unignorable.

In exception-based languages (Java, Python, C++), error handling is opt-in — you can ignore Exceptions and they propagate implicitly. In Rust, Result forces you to acknowledge errors at Every level of the call stack. The ? operator makes propagation ergonomic, but the type system Still tracks the error type.

// Every error is visible in the type signature
fn read_config(path: &str) -> Result<Config, io::Error> { ... }
fn parse_config(content: &str) -> Result<Config, serde_json::Error> { ... }
fn validate_config(config: &Config) -> Result<(), ValidationError> { ... }

The most common approach is an enum with a variant for each error category:

use std::fmt;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
Validation(String),
NotFound(String),
PermissionDenied(String),
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {}", e),
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::Validation(msg) => write!(f, "validation error: {}", msg),
AppError::NotFound(msg) => write!(f, "not found: {}", msg),
AppError::PermissionDenied(msg) => write!(f, "permission denied: {}", msg),
AppError::Internal(msg) => write!(f, "internal error: {}", msg),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + "static)> {
match self {
AppError::Io(e) => Some(e),
AppError::Parse(e) => Some(e),
_ => None,
}
}
}

Each From implementation enables the ? operator to automatically convert the source error type Into your error type:

impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::Parse(e)
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
AppError::Parse(e)
}
}

With these implementations, the ? operator handles conversions automatically:

fn load_config(path: &str) -> Result<Config, AppError> {
let content = std::fs::read_to_string(path)?; // io::Error -> AppError::Io
let config: Config = serde_json::from_str(&content)?; // serde_json::Error -> AppError::Parse
Ok(config)
}

The source() method enables walking an error chain. Each error can optionally return a reference To the underlying error that caused it:

use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct DatabaseError {
message: String,
source: sqlx::Error,
}
impl fmt::Display for DatabaseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.message, self.source)
}
}
impl Error for DatabaseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}

Walking the chain:

fn print_error_chain(err: &dyn Error) {
eprintln!("error: {}", err);
let mut source = err.source();
while let Some(cause) = source {
eprintln!(" caused by: {}", cause);
source = cause.source();
}
}
use std::backtrace::Backtrace;
use std::fmt;
#[derive(Debug)]
struct DetailedError {
message: String,
backtrace: Backtrace,
}
impl fmt::Display for DetailedError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}\nBacktrace:\n{}", self.message, self.backtrace)
}
}

Backtrace::capture() captures the current stack trace. It is available when RUST_BACKTRACE=1 is Set. The backtrace is only captured if an environment variable enables it, so there is no overhead In production by default.

Libraries need precise, typed error types that callers can match on:

use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("connection failed: {0}")]
Connection(#[from] sqlx::Error),
#[error("query failed: {query}")]
Query { query: String, #[source] source: sqlx::Error },
#[error("row not found: table={table}, key={key}")]
NotFound { table: String, key: String },
#[error("timeout after {timeout_ms}ms")]
Timeout { timeout_ms: u64 },
}

Applications need ergonomic error propagation with context, not precise error matching:

use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path))?;
let config: Config = serde_json::from_str(&content)
.context("failed to parse config as JSON")?;
Ok(config)
}

Use thiserror in library crates and anyhow in the application binary that consumes them:

my-project/
├── crates/
│ ├── core/ # uses thiserror for precise error types
│ │ └── src/
│ │ └── error.rs
│ └── cli/ # uses anyhow for ergonomic error handling
│ └── src/
│ └── main.rs
crates/core/src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CoreError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] serde_json::Error),
}
// crates/cli/src/main.rs
use anyhow::Result;
use my_core::{Config, CoreError};
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("config: {:?}", config);
Ok(())
}
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {}", path, e))?;
let config: Config = serde_json::from_str(&content)?;
Ok(config)
}
use std::time::Duration;
use std::thread;
fn with_retry<F, T, E>(max_retries: usize, mut f: F) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
E: std::fmt::Debug,
{
let mut attempt = 0;
loop {
match f() {
Ok(value) => return Ok(value),
Err(e) => {
attempt += 1;
if attempt >= max_retries {
return Err(e);
}
let delay = Duration::from_millis(100 * 2u64.pow(attempt as u32 - 1));
thread::sleep(delay);
}
}
}
}
use std::io;
fn fetch_with_retry(url: &str, max_retries: usize) -> Result<String, io::Error> {
let mut retries = 0;
loop {
match std::fs::read_to_string(url) {
Ok(content) => return Ok(content),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(e),
Err(e) => {
retries += 1;
if retries >= max_retries {
return Err(e);
}
let delay = std::time::Duration::from_millis(100 * (1 << retries));
std::thread::sleep(delay);
}
}
}
}
use tokio::time::{sleep, Duration};
async fn async_retry<F, Fut, T, E>(
max_retries: usize,
f: F,
) -> Result<T, E>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Debug,
{
let mut attempt = 0;
loop {
match f().await {
Ok(value) => return Ok(value),
Err(e) => {
attempt += 1;
if attempt >= max_retries {
return Err(e);
}
let delay = Duration::from_millis(100 * 2u64.pow(attempt as u32 - 1));
sleep(delay).await;
}
}
}
}
## Intuition

Error handling patterns in Rust build on Result and Option to create robust applications. The map_err function transforms error types without unwrapping. and_then chains operations that might fail. The anyhow crate provides dynamic error types for applications, while thiserror facilitates library error types. Combining these patterns with the ? operator creates clean error propagation that the compiler verifies exhaustively.

  • [[rust/04-error-handling/error-handling]] - Result and Option fundamentals
  • [[rust/02-ownership-borrowing/ownership]] - Ownership semantics in error chains
  • [[rust/05-traits-generics/traits-and-generics]] - Trait-based error conversion
  • [[rust/03-structs-enums/advanced-patterns]] - Pattern matching on error variants