Error Handling
Rust does not have exceptions. Instead, it splits errors into two categories: unrecoverable errors (the program must stop) and recoverable errors (the caller can handle the problem). This chapter shows you how to handle both.
Unrecoverable errors with panic!
panic! crashes the program immediately with an error message:
fn main() {
panic!("Something went terribly wrong");
}
thread 'main' panicked at 'Something went terribly wrong', src/main.rs:2:5
You also get panics from:
- Array out-of-bounds access:
numbers[100] - Integer overflow in debug mode
- Calling
.unwrap()on aNoneorErr
panic! is for bugs -- situations that should never happen if the code is correct. It is not for expected
failures like "file not found" or "invalid user input".
Backtraces
Set the environment variable RUST_BACKTRACE=1 to see a full stack trace on panic:
RUST_BACKTRACE=1 cargo run
This is invaluable for debugging.
Recoverable errors with Result
For errors the caller can handle, use Result<T, E>:
use std::fs;
fn main() {
let content = fs::read_to_string("config.txt");
match content {
Ok(text) => println!("Config: {text}"),
Err(error) => println!("Could not read config: {error}"),
}
}
fs::read_to_string returns Result<String, std::io::Error>. It is either Ok(content) or Err(error).
Handling different error kinds
use std::fs;
use std::io::ErrorKind;
fn main() {
match fs::read_to_string("config.txt") {
Ok(text) => println!("{text}"),
Err(error) => match error.kind() {
ErrorKind::NotFound => println!("File not found -- using defaults"),
ErrorKind::PermissionDenied => println!("No permission to read file"),
other => println!("Unexpected error: {other:?}"),
},
}
}
unwrap and expect
For quick prototyping or when you are certain the operation will succeed:
fn main() {
// unwrap -- get the value or panic
let number: i32 = "42".parse().unwrap();
// expect -- same as unwrap but with a custom panic message
let port: u16 = "8080".parse().expect("PORT must be a valid number");
println!("{number}, {port}");
}
| Method | On Ok/Some | On Err/None |
|---|---|---|
unwrap() | Returns T | Panics with generic message |
expect(m) | Returns T | Panics with custom message m |
Warning: Do not use
unwrap()in production code unless you have proven theErrcase is impossible. Useexpect()at minimum -- the custom message helps with debugging.
The ? operator
The ? operator is Rust's ergonomic way to propagate errors. It replaces verbose match chains:
Without ?
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let content = match fs::read_to_string("username.txt") {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(content.trim().to_string())
}
fn main() {
match read_username() {
Ok(name) => println!("Hello, {name}"),
Err(e) => println!("Error: {e}"),
}
}
With ?
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let content = fs::read_to_string("username.txt")?;
Ok(content.trim().to_string())
}
fn main() {
match read_username() {
Ok(name) => println!("Hello, {name}"),
Err(e) => println!("Error: {e}"),
}
}
? does exactly what the match version does: if the result is Ok, extract the value. If it is Err, return the
error from the current function immediately.
Chaining ?
You can chain multiple ? operations:
use std::fs;
use std::io;
fn read_first_line(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?;
let first_line = content.lines().next().unwrap_or("").to_string();
Ok(first_line)
}
fn main() {
match read_first_line("data.txt") {
Ok(line) => println!("First line: {line}"),
Err(e) => println!("Error: {e}"),
}
}
? in main
You can use ? in main by changing its return type:
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
let content = fs::read_to_string("config.txt")?;
println!("{content}");
Ok(())
}
If the program exits with an Err, Rust prints the error message and exits with a non-zero code.
? with Option
The ? operator also works with Option<T>:
fn first_even(numbers: &[i32]) -> Option<i32> {
let first = numbers.first()?; // Returns None if empty
if first % 2 == 0 {
Some(*first)
} else {
None
}
}
fn main() {
println!("{:?}", first_even(&[2, 4, 6])); // Some(2)
println!("{:?}", first_even(&[1, 3, 5])); // None
println!("{:?}", first_even(&[])); // None
}
Important: You cannot mix
?onResultandOptionin the same function. The function must return eitherResultorOption.
Custom error types
For libraries and larger applications, define your own error types:
Simple approach -- enum
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
NotFound(String),
ParseError(ParseIntError),
InvalidInput(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(name) => write!(f, "Not found: {name}"),
AppError::ParseError(e) => write!(f, "Parse error: {e}"),
AppError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
}
}
}
Converting between error types with From
The ? operator automatically converts errors using the From trait. Implement From to enable this:
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
NotFound(String),
ParseError(ParseIntError),
InvalidInput(String),
}
impl From<ParseIntError> for AppError {
fn from(error: ParseIntError) -> Self {
AppError::ParseError(error)
}
}
fn parse_age(input: &str) -> Result<u32, AppError> {
let age: u32 = input.parse()?; // ParseIntError automatically converts to AppError
if age > 150 {
return Err(AppError::InvalidInput("Age too high".to_string()));
}
Ok(age)
}
The thiserror crate
Writing Display and From implementations by hand is tedious. The thiserror crate automates it:
[dependencies]
thiserror = "2"
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Parse error: {0}")]
ParseError(#[from] std::num::ParseIntError),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Invalid input: {0}")]
InvalidInput(String),
}
fn load_config(path: &str) -> Result<i32, AppError> {
let content = std::fs::read_to_string(path)?; // io::Error → AppError
let value: i32 = content.trim().parse()?; // ParseIntError → AppError
Ok(value)
}
fn main() {
match load_config("config.txt") {
Ok(val) => println!("Config value: {val}"),
Err(e) => println!("Error: {e}"),
}
}
#[from] auto-generates the From implementation. #[error("...")] auto-generates Display. This is the
recommended approach for applications and libraries.
The anyhow crate
For applications (not libraries), the anyhow crate provides a catch-all error type:
[dependencies]
anyhow = "1"
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<i32> {
let content = std::fs::read_to_string(path)
.context("Failed to read config file")?;
let value: i32 = content.trim().parse()
.context("Config value must be an integer")?;
Ok(value)
}
fn main() -> Result<()> {
let config = load_config("config.txt")?;
println!("Config: {config}");
Ok(())
}
| Crate | Use case | Error type | Notes |
|---|---|---|---|
thiserror | Libraries | Custom enums | Structured, pattern-matchable |
anyhow | Applications | anyhow::Error | Easy, any error, context() |
Use thiserror when callers need to match on specific error variants. Use anyhow when you just want to propagate
errors with context messages.
When to panic vs return Result
| Situation | Use |
|---|---|
| Bug in your code (should never happen) | panic! |
| Invalid hardcoded value | panic! / unreachable! |
| Prototype / example code | unwrap() / expect() |
| User input validation | Result |
| File I/O | Result |
| Network operations | Result |
| Parsing external data | Result |
| Library functions | Result (almost always) |
The rule of thumb: if the caller could reasonably recover from the error, return Result. If it indicates a
programming mistake, panic! is appropriate.
Common Result methods
fn main() {
let ok: Result<i32, String> = Ok(42);
let err: Result<i32, String> = Err(String::from("oops"));
// map -- transform the Ok value
let doubled = ok.map(|n| n * 2); // Ok(84)
// map_err -- transform the Err value
let prefixed = err.map_err(|e| format!("Error: {e}")); // Err("Error: oops")
// unwrap_or -- default on error
let value = err.unwrap_or(0); // 0
// unwrap_or_else -- default with closure
let value2 = err.unwrap_or_else(|_| 99); // 99
// and_then -- chain Result-returning operations
let parsed: Result<i32, String> = Ok(String::from("42"))
.and_then(|s| s.parse::<i32>().map_err(|e| e.to_string()));
// is_ok / is_err
println!("ok is ok: {}", ok.is_ok()); // true
println!("err is err: {}", err.is_err()); // true
println!("{doubled:?}, {prefixed:?}, {value}, {value2}, {parsed:?}");
}
Summary
panic!is for unrecoverable errors (bugs). It crashes the program.Result<T, E>is for recoverable errors. Functions returnOk(value)orErr(error).?propagates errors concisely -- it returnsErrearly or extracts theOkvalue.unwrap()andexpect()extract values but panic on errors -- avoid in production.- Custom error enums with
DisplayandFromgive structured, typed error handling. thiserrorautomates custom error boilerplate (best for libraries).anyhowprovides a universal error type with context (best for applications).- Panic for bugs; return
Resultfor expected failures.
Next up: Modules & Crates -- organizing code into modules, using external crates, and understanding Cargo's dependency system.