TL;DR: Rust has no exceptions. Errors are values;
Result<T, E>for recoverable errors,panic!for unrecoverable ones. The?operator propagates errors up the call stack. For real projects, use two crates:thiserrorto define structured error types in libraries, andanyhowto handle and report errors ergonomically in applications. These two cover 95% of Rust error handling.
How Does the ? Operator Work?
The ? operator is shorthand for "if this is Err, return the error from the current function; if it's Ok, unwrap and continue."
use std::fs;
use std::io;
// Without ?
fn read_file(path: &str) -> Result<String, io::Error> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(content)
}
// With ?; identical behavior, far less noise
fn read_file(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?;
Ok(content)
}? also calls .into() on the error, so it can convert between compatible error types automatically.
What Is thiserror?
thiserror is a derive macro for creating custom, structured error types in libraries; it generates Display, Error, and From implementations for your enum variants.
# Cargo.toml
[dependencies]
thiserror = "2"use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("user not found: id={id}")]
UserNotFound { id: i64 },
#[error("unauthorized")]
Unauthorized,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
// Now ? automatically converts sqlx::Error into AppError::Database
async fn get_user(id: i64) -> Result<User, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&pool)
.await?; // sqlx::Error → AppError::Database via #[from]
user.ok_or(AppError::UserNotFound { id })
}Use thiserror when callers need to match on specific error variants.
What Is anyhow?
anyhow provides a single anyhow::Error type that can wrap any error; ideal for application code where you need to propagate errors without defining specific types.
[dependencies]
anyhow = "1"use anyhow::{Context, Result};
// Result<T> is anyhow::Result<T>; shorthand for Result<T, anyhow::Error>
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {path}"))?;
let config: Config = toml::from_str(&content)
.context("failed to parse config TOML")?;
Ok(config)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
// anyhow prints the full error chain on failure
Ok(())
}anyhow::Context adds descriptive messages to errors, building a readable chain: "failed to read config from config.toml: No such file or directory (os error 2)".
When Should You Use thiserror vs anyhow?
thiserror for libraries (callers need to match errors). anyhow for applications (you just need to report errors).
thiserror | anyhow | |
|---|---|---|
| Use in | Libraries, domain crates | Binaries, applications, main() |
| Error type | Your custom enum | Opaque anyhow::Error |
Callers can match | ✅ | ❌ |
| Context messages | Manual | Built-in .context() |
| Conversion | #[from] derive | Automatic for any std::error::Error |
A common pattern: use thiserror for your domain/library errors, then in your binary's main() or handler layer, use anyhow to wrap everything.
How Do You Create Custom Errors Without Crates?
For simple cases, implement std::error::Error manually; useful when you want zero dependencies.
use std::fmt;
#[derive(Debug)]
pub enum ParseError {
InvalidFormat(String),
OutOfRange(i64),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::InvalidFormat(s) => write!(f, "invalid format: {s}"),
ParseError::OutOfRange(n) => write!(f, "value {n} is out of range"),
}
}
}
impl std::error::Error for ParseError {}In practice, thiserror generates this boilerplate for you. Manual impl is only necessary if you can't add dependencies.
Frequently Asked Questions
Only for values you can prove are never None/Err; like a regex that's valid at compile time, or initial setup that must succeed. In hot paths or user-facing code, prefer ? or explicit match. A production panic is rarely acceptable.
panic! is for unrecoverable bugs; violated invariants, programming errors. Err is for expected, recoverable failure conditions (file not found, network timeout, invalid input). Rule of thumb: if a user could cause it, return Err.
Yes; implement From<SourceError> for MyError (or use #[from] with thiserror) and ? will call .into() automatically when propagating errors.
Box<dyn std::error::Error> is the simple escape hatch; any error type can be boxed. It's useful in main() or quick scripts: fn main() -> Result<(), Box<dyn std::error::Error>>. For real projects, prefer anyhow; it adds context chaining and better formatting.
Sources
- thiserror crate: Derive macros for error types
- anyhow crate: Flexible error handling for applications
- The Rust Book; Error Handling
Related Glossary Terms
- Result: The core error-handling type that
?operates on - Panic: The alternative to
Resultfor unrecoverable errors - Trait:
std::error::Erroris a trait that all error types implement - Enum: Custom error types are typically enums
- anyhow: The most common application-level crate for ergonomic Rust error propagation
- Display/Debug: Good error types need clear
Displayoutput and useful debug formatting
Keep Reading
- Rust Error Handling: Result and Option: the definitive guide to Result, Option, and the ? operator
- Rust Ownership and Borrowing Explained: errors are values; understanding ownership explains why
- Rust for Python Developers: exceptions vs explicit error types
