Error Handling
Rust has no exceptions. Instead it uses two types for handling errors: Result<T, E> for recoverable errors and panic! for unrecoverable ones. This forces you to handle errors explicitly.
For Python developers: This is a major paradigm shift! No try/except blocks. Instead, errors are values that you must explicitly handle. This prevents forgotten error handling at compile time.
Python vs Rust Error Handling
Python approach:
# Exceptions - runtime errors
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
try:
result = divide(10, 0)
print(result)
except ZeroDivisionError as e:
print(f"Error: {e}")
Rust approach:
// Result type - compile-time safety
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("Cannot divide by zero"))
} else {
Ok(a / b)
}
}
match divide(10, 0) {
Ok(result) => println!("{}", result),
Err(e) => println!("Error: {}", e),
}
Key differences:
- Python: Errors can be ignored (leads to crashes)
- Rust: Compiler forces you to handle Result
- Python: try/except blocks
- Rust: match expressions or ? operator
Unrecoverable Errors with panic!
When to Panic
Use panic! when your program reaches an unrecoverable state:
fn main() {
panic!("crash and burn");
}
Output:
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
Common Panic Situations
fn main() {
// Index out of bounds
let v = vec![1, 2, 3];
// v[99]; // panics
// Unwrap on None
let x: Option<i32> = None;
// x.unwrap(); // panics
// Unwrap on Err
let result: Result<i32, &str> = Err("bad");
// result.unwrap(); // panics
}
Getting a Backtrace
RUST_BACKTRACE=1 cargo run
Recoverable Errors with Result
The Result Type
enum Result<T, E> {
Ok(T),
Err(E),
}
Basic Usage
use std::fs::File;
fn main() {
let file_result = File::open("hello.txt");
let file = match file_result {
Ok(f) => f,
Err(error) => {
println!("Problem opening file: {}", error);
return;
}
};
println!("File opened successfully: {:?}", file);
}
Matching on Different Errors
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file = match File::open("hello.txt") {
Ok(f) => f,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating file: {:?}", e),
},
other_error => {
panic!("Problem opening file: {:?}", other_error);
}
},
};
}
Cleaner with unwrap_or_else
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file = File::open("hello.txt").unwrap_or_else(|error| {
if error.kind() == ErrorKind::NotFound {
File::create("hello.txt").unwrap_or_else(|error| {
panic!("Problem creating file: {:?}", error);
})
} else {
panic!("Problem opening file: {:?}", error);
}
});
}
Result Shortcuts
unwrap and expect
use std::fs::File;
fn main() {
// unwrap: panic if Err, return value if Ok
let f = File::open("hello.txt").unwrap();
// expect: same as unwrap but with custom message
let f = File::open("hello.txt")
.expect("Failed to open hello.txt");
}
Use expect over unwrap - the error message helps debugging.
Result Methods
| Method | On Ok(v) | On Err(e) |
|---|---|---|
unwrap() | Returns v | Panics |
expect("msg") | Returns v | Panics with "msg" |
unwrap_or(default) | Returns v | Returns default |
unwrap_or_else(f) | Returns v | Returns f(e) |
unwrap_or_default() | Returns v | Returns T::default() |
map(f) | Returns Ok(f(v)) | Returns Err(e) |
map_err(f) | Returns Ok(v) | Returns Err(f(e)) |
and_then(f) | Returns f(v) | Returns Err(e) |
ok() | Returns Some(v) | Returns None |
is_ok() | true | false |
fn main() {
let result: Result<i32, String> = Ok(42);
// map: transform the success value
let doubled = result.map(|v| v * 2);
println!("{:?}", doubled); // Ok(84)
// and_then: chain operations that can fail
let chained = result.and_then(|v| {
if v > 0 { Ok(v * 2) } else { Err("negative".to_string()) }
});
println!("{:?}", chained); // Ok(84)
// Convert Result to Option
let option = result.ok();
println!("{:?}", option); // Some(42)
}
The ? Operator
The ? operator is syntactic sugar for propagating errors. It returns the error early if the Result is Err, or unwraps the Ok value.
Without ?
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let file_result = File::open("username.txt");
let mut file = match file_result {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
With ?
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("username.txt")?;
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username)
}
Chaining with ?
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("username.txt")?.read_to_string(&mut username)?;
Ok(username)
}
Even Shorter
use std::fs;
fn read_username_from_file() -> Result<String, std::io::Error> {
fs::read_to_string("username.txt")
}
? with Option
The ? operator also works with Option:
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
fn main() {
println!("{:?}", last_char_of_first_line("hello\nworld")); // Some('o')
println!("{:?}", last_char_of_first_line("")); // None
}
Note: ? can only be used in functions that return Result or Option.
The Option Type
Recap
fn main() {
let some_value: Option<i32> = Some(42);
let no_value: Option<i32> = None;
// Pattern matching
match some_value {
Some(v) => println!("Got: {}", v),
None => println!("Nothing"),
}
// if let
if let Some(v) = some_value {
println!("Got: {}", v);
}
}
Option Combinators
fn main() {
let name: Option<String> = Some("Alice".to_string());
let empty: Option<String> = None;
// map
let len = name.as_ref().map(|n| n.len());
println!("Name length: {:?}", len); // Some(5)
// and_then (flatmap)
let first_char = name.as_ref().and_then(|n| n.chars().next());
println!("First char: {:?}", first_char); // Some('A')
// or / or_else
let result = empty.or(Some("Default".to_string()));
println!("Result: {:?}", result); // Some("Default")
// filter
let big = Some(100).filter(|&x| x > 50);
let small = Some(10).filter(|&x| x > 50);
println!("Big: {:?}, Small: {:?}", big, small); // Some(100), None
// zip
let name = Some("Alice");
let age = Some(30);
let combined = name.zip(age);
println!("{:?}", combined); // Some(("Alice", 30))
}
Converting Between Option and Result
fn main() {
// Option to Result
let opt: Option<i32> = Some(42);
let result: Result<i32, &str> = opt.ok_or("value was None");
println!("{:?}", result); // Ok(42)
let none: Option<i32> = None;
let result: Result<i32, &str> = none.ok_or("value was None");
println!("{:?}", result); // Err("value was None")
// Result to Option
let result: Result<i32, &str> = Ok(42);
let opt: Option<i32> = result.ok();
println!("{:?}", opt); // Some(42)
}
Custom Error Types
Simple Custom Error
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
PermissionDenied,
ParseError(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(item) => write!(f, "Not found: {}", item),
AppError::PermissionDenied => write!(f, "Permission denied"),
AppError::ParseError(msg) => write!(f, "Parse error: {}", msg),
}
}
}
fn find_user(id: u32) -> Result<String, AppError> {
if id == 0 {
Err(AppError::NotFound("user".to_string()))
} else if id == 999 {
Err(AppError::PermissionDenied)
} else {
Ok(format!("User#{}", id))
}
}
fn main() {
match find_user(0) {
Ok(user) => println!("Found: {}", user),
Err(e) => println!("Error: {}", e),
}
}
Converting Between Error Types with From
use std::fs;
use std::io;
use std::num::ParseIntError;
use std::fmt;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO error: {}", e),
AppError::Parse(e) => write!(f, "Parse error: {}", e),
}
}
}
impl From<io::Error> for AppError {
fn from(error: io::Error) -> Self {
AppError::Io(error)
}
}
impl From<ParseIntError> for AppError {
fn from(error: ParseIntError) -> Self {
AppError::Parse(error)
}
}
// Now ? automatically converts errors
fn read_number_from_file(path: &str) -> Result<i32, AppError> {
let content = fs::read_to_string(path)?; // io::Error -> AppError
let number = content.trim().parse::<i32>()?; // ParseIntError -> AppError
Ok(number)
}
Using Box<dyn Error>
For quick prototyping or when you don't need specific error types:
use std::error::Error;
use std::fs;
fn read_number(path: &str) -> Result<i32, Box<dyn Error>> {
let content = fs::read_to_string(path)?;
let number = content.trim().parse::<i32>()?;
Ok(number)
}
fn main() {
match read_number("number.txt") {
Ok(n) => println!("Got: {}", n),
Err(e) => println!("Error: {}", e),
}
}
Using the thiserror Crate
For real projects, thiserror reduces boilerplate:
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("file not found: {0}")]
NotFound(String),
#[error("permission denied")]
PermissionDenied,
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("parse error")]
Parse(#[from] std::num::ParseIntError),
}
This auto-generates Display, Error, and From implementations.
Error Handling Patterns
Pattern 1: Fail Fast
fn process_config(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let port: u16 = content.trim().parse()?;
println!("Server running on port {}", port);
Ok(())
}
fn main() {
if let Err(e) = process_config("config.txt") {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
Pattern 2: Provide Defaults
fn get_port() -> u16 {
std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8080)
}
fn main() {
let port = get_port();
println!("Running on port {}", port);
}
Pattern 3: Collect Errors
fn parse_numbers(input: &[&str]) -> (Vec<i32>, Vec<String>) {
let mut numbers = Vec::new();
let mut errors = Vec::new();
for s in input {
match s.parse::<i32>() {
Ok(n) => numbers.push(n),
Err(e) => errors.push(format!("'{}': {}", s, e)),
}
}
(numbers, errors)
}
fn main() {
let input = vec!["1", "abc", "3", "xyz", "5"];
let (numbers, errors) = parse_numbers(&input);
println!("Parsed: {:?}", numbers);
println!("Errors: {:?}", errors);
}
Pattern 4: Map and Chain Errors
use std::collections::HashMap;
fn get_user_age(data: &HashMap<String, String>, name: &str) -> Result<u32, String> {
data.get(name)
.ok_or_else(|| format!("User '{}' not found", name))
.and_then(|age_str| {
age_str.parse::<u32>()
.map_err(|e| format!("Invalid age for '{}': {}", name, e))
})
}
fn main() {
let mut data = HashMap::new();
data.insert("Alice".to_string(), "30".to_string());
data.insert("Bob".to_string(), "not_a_number".to_string());
println!("{:?}", get_user_age(&data, "Alice")); // Ok(30)
println!("{:?}", get_user_age(&data, "Bob")); // Err(...)
println!("{:?}", get_user_age(&data, "Charlie")); // Err(...)
}
When to Use What
| Situation | Approach |
|---|---|
| Programming bug (index out of bounds) | panic! |
| Expected failure (file not found) | Result<T, E> |
| Value might not exist | Option<T> |
| Quick prototype | unwrap() / expect() |
| Library code | Custom error types |
| Application code | Box<dyn Error> or anyhow crate |
| Multiple error types | From conversions or thiserror |
Practice Exercises
Exercise 1: Safe Division
Write a function that divides two numbers and returns an error for division by zero:
fn divide(a: f64, b: f64) -> Result<f64, String> {
// Your code
}
Exercise 2: Config Parser
Write a function that reads a config file with key=value pairs and returns a HashMap. Handle missing file and malformed lines:
use std::collections::HashMap;
fn parse_config(path: &str) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
// Your code
}
Exercise 3: Chain of Operations
Write a pipeline that reads a file of numbers (one per line), parses them, filters even numbers, and returns their sum. Propagate errors with ?.
Common Mistakes
1. Overusing unwrap
let value = some_result.unwrap(); // Ticking time bomb
Fix: Use ?, match, or unwrap_or.
2. Using ? in main
fn main() {
let f = File::open("file.txt")?; // ERROR: main returns ()
}
Fix: Change main's return type:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let f = File::open("file.txt")?;
Ok(())
}
3. Ignoring Errors
let _ = File::create("file.txt"); // Error silently ignored
Fix: Handle the error or at least log it.
Key Takeaways
- Rust uses
Result<T, E>for recoverable errors andpanic!for unrecoverable ones - The
?operator propagates errors concisely Option<T>handles the absence of a value (no null)- Use
expect()overunwrap()for better error messages - Custom error types with
Fromenable automatic conversion with? thiserrorandanyhoware the standard crates for error handling- Handle errors at the appropriate level - propagate up, handle at boundaries
Next Steps
In the next chapter, we'll explore traits and generics - Rust's system for shared behavior and type-level abstraction.
Quick Reference
// Result
let ok: Result<i32, String> = Ok(42);
let err: Result<i32, String> = Err("failed".to_string());
// ? operator (in functions returning Result)
let value = might_fail()?;
// Option
let some: Option<i32> = Some(42);
let none: Option<i32> = None;
// unwrap variants
result.unwrap(); // Panic on Err
result.expect("message"); // Panic with message
result.unwrap_or(default); // Default on Err
result.unwrap_or_else(|e| f(e));
// Combinators
result.map(|v| v * 2);
result.and_then(|v| another_result(v));
option.ok_or("error message");
// Custom error type
#[derive(Debug)]
enum MyError { IoError(io::Error) }
impl From<io::Error> for MyError { ... }