Error Handling in Rust
Use Result<T,E>, the ? operator, custom error types, thiserror, and anyhow for robust error handling.
The Two Strategies: panic! and Result
Rust draws a clear line between two kinds of failure. A panic! signals a bug — something that should never happen in correct code, like an out-of-bounds access or a violated invariant. A Result signals an expected failure — something that can go wrong in normal operation, like a file not found or a network timeout. Separating these means callers always know whether a function can fail in a recoverable way, and the compiler enforces that they handle it.
// panic! — unrecoverable, crashes the thread immediately
fn divide_unsafe(a: i32, b: i32) -> i32 {
if b == 0 { panic!("division by zero!"); }
a / b
}
// Result — recoverable, caller decides what to do with the error
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 { Err(String::from("division by zero")) }
else { Ok(a / b) }
}
fn main() {
match divide(10, 2) {
Ok(n) => println!("{}", n), // 5
Err(e) => eprintln!("Error: {}", e),
}
match divide(10, 0) {
Ok(n) => println!("{}", n),
Err(e) => eprintln!("Error: {}", e), // Error: division by zero
}
}
The ? Operator
Propagating errors manually with match on every fallible call quickly becomes verbose. The ? operator is syntactic sugar that does the propagation for you: it extracts the Ok value if the result succeeded, and if it failed it converts the error (using the From trait) and returns early. This lets you write a chain of fallible operations as if they were sequential, with errors handled implicitly.
use std::fs;
use std::num::ParseIntError;
fn read_number_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?; // propagates io::Error if file missing
let n: i32 = content.trim().parse()?; // propagates ParseIntError if not a number
Ok(n * 2)
}
? expands to approximately:
let content = match fs::read_to_string(path) {
Ok(v) => v,
Err(e) => return Err(e.into()), // .into() converts via the From trait
};
? also works with Option — it returns None early instead of Err:
fn first_char(s: &str) -> Option<char> {
let c = s.chars().next()?; // returns None if string is empty
Some(c.to_uppercase().next()?)
}
fn main() {
println!("{:?}", first_char("hello")); // Some('H')
println!("{:?}", first_char("")); // None
}
Handling Result Without ?
When you need fine-grained control over what to do with each outcome, the Result type has a rich set of methods for transforming, chaining, and providing defaults:
fn main() {
let r: Result<i32, &str> = Ok(42);
// match — full control over both branches
match r { Ok(n) => println!("{}", n), Err(e) => println!("{}", e) }
// unwrap — panics on Err (use only in tests or when Err is truly impossible)
println!("{}", r.unwrap());
// expect — panics with a descriptive message (better than unwrap in tests)
println!("{}", r.expect("should have a number"));
// unwrap_or — provide a fallback value
println!("{}", r.unwrap_or(0));
// unwrap_or_else — compute the fallback lazily
println!("{}", r.unwrap_or_else(|_| -1));
// map — transform the Ok value, pass Err through unchanged
println!("{:?}", r.map(|n| n * 2)); // Ok(84)
// map_err — transform the Err value, pass Ok through unchanged
let e: Result<i32, i32> = Err(5);
println!("{:?}", e.map_err(|n| n * 2)); // Err(10)
// and_then — chain a second fallible operation
let result = r.and_then(|n| if n > 0 { Ok(n) } else { Err("negative") });
println!("{:?}", result); // Ok(42)
}
Defining Custom Error Types
For library code, a string error type like String loses information and makes programmatic error handling impossible for callers. A custom error enum lets callers match on specific variants, access structured data, and understand the error chain. The std::error::Error trait and Display implementation make your error work with the broader ecosystem.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Parse(ParseIntError),
OutOfRange { value: i32, min: i32, max: i32 },
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::OutOfRange { value, min, max } =>
write!(f, "{} is out of range [{}, {}]", value, min, max),
AppError::NotFound(key) => write!(f, "'{}' not found", key),
}
}
}
impl std::error::Error for AppError {
// source() lets callers traverse the error chain
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Parse(e) => Some(e),
_ => None,
}
}
}
// Allow ? to convert ParseIntError into AppError automatically
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
fn parse_port(s: &str) -> Result<u16, AppError> {
let n: i32 = s.parse()?; // ParseIntError → AppError via From impl above
if n < 1 || n > 65535 {
return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
}
Ok(n as u16)
}
fn main() {
match parse_port("8080") {
Ok(p) => println!("port: {}", p),
Err(e) => eprintln!("error: {}", e),
}
match parse_port("99999") {
Ok(p) => println!("port: {}", p),
Err(e) => eprintln!("error: {}", e), // 99999 is out of range [1, 65535]
}
}
Using thiserror for Cleaner Custom Errors
Writing Display, Error, and From implementations by hand is repetitive boilerplate. The thiserror crate generates all of it from annotations on your enum. This is the standard choice for library crates that need structured, typed errors without the ceremony.
[dependencies]
thiserror = "1"
use thiserror::Error;
use std::num::ParseIntError;
#[derive(Debug, Error)]
enum AppError {
// #[error("...")] generates the Display impl
// #[from] generates the From impl and wires it to ?
#[error("parse error: {0}")]
Parse(#[from] ParseIntError),
#[error("{value} is out of range [{min}, {max}]")]
OutOfRange { value: i32, min: i32, max: i32 },
#[error("'{0}' not found")]
NotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
fn parse_port(s: &str) -> Result<u16, AppError> {
let n: i32 = s.parse()?; // auto-converts via #[from] on Parse variant
if !(1..=65535).contains(&n) {
return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
}
Ok(n as u16)
}
Using anyhow for Application Code
Library crates need typed errors so callers can react programmatically. Application code (binaries, CLI tools, scripts) usually just needs to display errors clearly and propagate them to main. The anyhow crate provides a single Error type that wraps any error, plus a .context() method that attaches human-readable explanation at each layer of the call stack.
[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail};
fn read_config(path: &str) -> Result<String> {
// .with_context() adds a message that appears when the error is printed
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {}", path))?;
if content.is_empty() {
// bail! is shorthand for return Err(anyhow!(...))
bail!("config file is empty");
}
Ok(content)
}
fn main() -> Result<()> {
// Errors chain: "application startup failed: failed to read config from ...: ..."
let config = read_config("config.toml")
.context("application startup failed")?;
println!("config loaded: {} bytes", config.len());
Ok(())
}
Combining thiserror and anyhow
The two crates are complementary and designed to be used together in larger projects. Use thiserror in library crates to give callers structured errors they can match on. Use anyhow in binary crates to accept errors from multiple libraries, wrap them with context, and present them to the user.
// lib.rs — structured errors that library consumers can match on
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("connection failed: {0}")]
Connection(String),
#[error("query failed: {0}")]
Query(String),
}
// main.rs — application combines errors from many libraries via anyhow
use anyhow::Result;
fn run() -> Result<()> {
// DbError automatically converts to anyhow::Error via the From blanket impl
let _ = connect_db()?;
Ok(())
}
Error Handling Summary
| Tool | Use case |
|---|---|
panic! | Bugs, broken invariants, “can’t happen” |
Result<T, E> | All recoverable errors |
? operator | Propagate errors up the stack |
unwrap / expect | Prototyping, tests, when None/Err is truly impossible |
Custom enum + thiserror | Library errors with structured variants |
anyhow | Application code, scripts, main functions |