Error Handling in Practice
Rust has no exceptions. Errors are values, and functions that can fail say so explicitly in their return type. This forces error handling to be a first-class design decision rather than an afterthought, but it also means you need clear patterns for when to use what.
This tutorial covers the practical side: how Result and Option work in real code, how ? removes boilerplate, and when to reach for thiserror or anyhow.
Scenario 1 - The basics: Result and Option
Result<T, E> represents either a success (Ok(T)) or a failure (Err(E)).
Option<T> represents either a value (Some(T)) or nothing (None).
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
s.parse::<u16>()
}
fn first_item(items: &[&str]) -> Option<&str> {
items.first().copied()
}
Use Result when failure has a meaningful cause. Use Option when absence is not an error. The item just is not there.
Extracting values:
// Crash on failure — only acceptable when failure is a programming error
let port = parse_port("8080").unwrap();
// Crash with a readable message
let port = parse_port("8080").expect("PORT must be a valid u16");
// Provide a default
let port = parse_port(input).unwrap_or(3000);
// Branch on the result
match parse_port(input) {
Ok(p) => println!("Listening on {}", p),
Err(e) => eprintln!("Invalid port: {}", e),
}
Tip: Reserve
.unwrap()for two cases only: tests, and places where you can prove by logic that the value is alwaysOkorSome. In all other cases, handle or propagate the error.
Scenario 2 - Propagating errors with ?
When a function can fail at multiple points, .unwrap()-ing every step produces noisy code. The ? operator propagates an error up to the caller automatically.
Without ?
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(Box::new(e)),
};
let trimmed = match content.trim().parse::<String>() {
Ok(s) => s,
Err(e) => return Err(Box::new(e)),
};
Ok(trimmed)
}
With ?
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
Ok(content.trim().to_string())
}
? does three things: if the value is Err, it calls .into() to convert the error to the function's error type, then returns early. If the value is Ok, it unwraps and continues. The function's return type matters here: ? needs a target type to convert into.
? also works on Option:
fn get_username(id: u32) -> Option<String> {
let user = find_user(id)?; // returns None if find_user returns None
Some(user.name.clone())
}
Scenario 3 - Multiple error types in one function
A real function often calls library code that returns different error types. Returning Box<dyn std::error::Error> works but loses type information at the call site.
The standard solution is to define your own error type.
Defining an error type with thiserror
thiserror is a small derive macro that eliminates the boilerplate of implementing std::error::Error and Display by hand.
# Cargo.toml
[dependencies]
thiserror = "2"
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("could not read file: {0}")]
Io(#[from] std::io::Error),
#[error("invalid value for '{field}': {message}")]
InvalidValue { field: String, message: String },
#[error("missing required key: {0}")]
MissingKey(String),
}
#[from] implements From<std::io::Error> for ConfigError automatically, so ? can convert io::Error into ConfigError::Io without any extra code.
fn load_config(path: &str) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path)?; // io::Error -> ConfigError::Io via From
let value = content.trim();
if value.is_empty() {
return Err(ConfigError::MissingKey("config content".to_string()));
}
Ok(parse(value)?)
}
Use
thiserrorwhen you are writing a library, a module with a public API, or any code where callers need to match on specific error variants and respond differently to each.
Scenario 4 - Application-level error handling with anyhow
In application code (binaries, CLI tools, integration code), you often do not need callers to match on error variants. You just need errors to propagate, carry context, and produce a readable message at the top.
anyhow is designed for exactly this.
# Cargo.toml
[dependencies]
anyhow = "1"
use anyhow::{Context, Result};
fn run() -> Result<()> {
let config = std::fs::read_to_string("config.toml")
.context("failed to read config.toml")?;
let port: u16 = config.trim().parse()
.context("config.toml must contain a valid port number")?;
println!("Starting on port {}", port);
Ok(())
}
anyhow::Result<T> is Result<T, anyhow::Error>. Any error type that implements std::error::Error converts into it automatically, so ? works with any library error without a custom enum.
.context() wraps the error with an additional message. When the error is printed, both messages appear in order, giving you a readable chain:
Error: failed to read config.toml
Caused by:
No such file or directory (os error 2)
Use
anyhowwhen you are writing a binary or application layer and you want errors to bubble up with context rather than be matched on.
Scenario 5 - Transforming and combining Results
Mapping the success value
let port: Result<u16, _> = "8080".parse();
let display: Result<String, _> = port.map(|p| format!(":{}", p));
// Ok(":8080")
Mapping the error
let result = std::fs::read_to_string("file.txt")
.map_err(|e| format!("read failed: {}", e));
Collecting a Vec of Results
When you have an iterator of Result<T, E> and want either a Vec<T> or the first error:
let strings = vec!["1", "2", "bad", "4"];
let numbers: Result<Vec<i32>, _> = strings.iter().map(|s| s.parse::<i32>()).collect();
// Err(ParseIntError) — stops at "bad"
collect() into Result<Vec<T>, E> short-circuits at the first Err. If all items are Ok, you get Ok(Vec<T>).
Scenario 6 - unwrap in tests and scripts
unwrap() and expect() are fine in tests. A panic is the correct failure mode there, and the message from expect() helps identify the failing assertion.
#[test]
fn parses_valid_port() {
let port = "8080".parse::<u16>().expect("test input should be valid");
assert_eq!(port, 8080);
}
For quick scripts and main() functions where you genuinely do not want to handle errors, propagate to main instead:
fn main() -> anyhow::Result<()> {
let content = std::fs::read_to_string("data.txt")?;
println!("{}", content);
Ok(())
}
When main() returns Result, a returned Err prints the error and exits with a non-zero code. No manual eprintln! or process::exit() needed.
Choosing the Right Tool
| Situation | Reach for |
|---|---|
| Library or public API | thiserror: typed, matchable errors |
| Application / binary | anyhow: context-rich, propagate freely |
| Prototype or script | Box<dyn Error> or anyhow |
| Absence is not an error | Option |
| Tests | .unwrap() / .expect() |
| Guaranteed safe unwrap | .unwrap() with a comment explaining why |
Key Takeaways
- Errors are values. Functions that can fail declare it in their return type.
?propagates errors up to the caller and converts between error types viaFrom.- Define a typed error enum with
thiserrorwhen callers need to distinguish between error variants. - Use
anyhowin application code when you only need errors to propagate and display cleanly. .context()adds human-readable explanation to an error without replacing it.- Collect an iterator of
ResultintoResult<Vec<T>, E>to fail fast on the first error.