HOW-TO: Rust Error Handling with Result and Option
Master Rust's error handling system. Learn when to panic, how to use Result and Option types, the ? operator for elegant error propagation, and patterns for writing robust code that handles failures gracefully.
HOW-TO: Rust Error Handling with Result and Option
Overview
Every program encounters errors. Files don't exist. Network requests time out. Users enter invalid input. Rust forces you to acknowledge and handle these possibilities at compile time, before bugs reach production.
This guide covers:
- Two types of errors: Recoverable (handle gracefully) and unrecoverable (crash)
- Result<T, E>: For recoverable errors
- Option<T>: For values that might not exist
- The
?operator: Elegant error propagation - When to panic vs. return errors: Critical decision-making
No other language makes this easier. Rust catches your errors before they happen.
Two Types of Errors
Unrecoverable Errors: panic!
An unrecoverable error is a bug — a symptom that something went catastrophically wrong.
Examples:
- Index out of bounds: accessing
v[100]in a 3-element vector - Null pointer dereference (impossible in Rust, but the concept)
- Logic violation: "this should never happen"
Response: Stop the program immediately.
fn main() {
panic!("This should never happen!");
}
Output:
thread 'main' panicked at 'This should never happen!'
The program exits with a stack trace (if RUST_BACKTRACE=1 is set).
Common panics:
unwrap()onNoneorErr- Array indexing out of bounds
- Division by zero (actually caught at runtime, panics)
- Explicit
panic!call
Recoverable Errors: Result<T, E>
A recoverable error is expected and handleable. You should recover gracefully.
Examples:
- File not found — create it or ask the user
- Network timeout — retry the request
- Invalid input — ask for correct input
- Permission denied — show an error message
Response: Return an error, let the caller decide what to do.
Result<T, E>: Returning Errors
Result is an enum with two variants:
enum Result<T, E> {
Ok(T), // Success, contains value of type T
Err(E), // Failure, contains error of type E
}
When a function might fail, return Result:
use std::fs::File;
fn main() {
let result = File::open("hello.txt");
// result is Result<File, std::io::Error>
}
If the file exists, result is Ok(File). If not, result is Err(io::Error).
Pattern 1: match
Handle both cases with match:
use std::fs::File;
fn main() {
let result = File::open("hello.txt");
match result {
Ok(file) => {
println!("File opened successfully");
// Use file here
}
Err(error) => {
println!("Failed to open file: {error}");
}
}
}
Pattern 2: unwrap() — Panic on Error
unwrap() is shorthand: "I'm sure this will succeed. If not, panic."
let file = File::open("hello.txt").unwrap(); // Panics if error
Use when: You're writing a prototype or you're absolutely certain it won't fail (rarely true).
Never use in production code. It's a time bomb.
Pattern 3: expect() — Panic with Message
expect() panics with a custom message:
let file = File::open("hello.txt")
.expect("hello.txt should exist in this project");
If it fails, you get:
thread 'main' panicked at 'hello.txt should exist in this project: ...'
Use when: You're confident it won't fail, but if it does, a message helps debugging.
Better than unwrap(), but still not ideal for production.
Pattern 4: get default with unwrap_or()
let result = vec![1, 2, 3].get(10); // Option, not Result
match result {
Some(val) => println!("{val}"),
None => println!("Index doesn't exist"),
}
// Shorter:
let val = vec![1, 2, 3].get(10).copied().unwrap_or(0);
// If index exists, use value; if not, use 0
Pattern 5: the ? Operator — Error Propagation
The ? operator is the most important pattern. It's shorthand for:
"If this returns Err, return from the function with that error. Otherwise, continue with the Ok value."
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("hello.txt")?; // If error, return it
let mut username = String::new();
file.read_to_string(&mut username)?; // If error, return it
Ok(username) // Success, return username
}
Without ? (verbose):
fn read_username_from_file() -> Result<String, io::Error> {
let result = File::open("hello.txt");
let mut file = match result {
Ok(f) => f,
Err(e) => return Err(e), // Early return with error
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
With ? (elegant):
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("hello.txt")?;
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username)
}
The second version is much cleaner and says: "This function returns Result. If anything goes wrong, propagate the error up."
Rules for ?:
- Can only be used in functions that return
ResultorOption(notmain) - Returns the entire
Resultfrom the function (early return) - Converts error types automatically if an
Intoimplementation exists
Option<T>: Values That Might Not Exist
Option represents "something or nothing":
enum Option<T> {
Some(T), // Value exists
None, // Value doesn't exist
}
Use Option when a value might not exist (not an error, just absence).
Examples:
- First element of a vector (might be empty)
- Looking up a key in a HashMap (might not exist)
- String finding a substring (might not be present)
Creating Options
fn main() {
let x: Option<i32> = Some(5);
let y: Option<i32> = None;
}
Using Option with match
let v = vec![1, 2, 3];
match v.get(0) {
Some(first) => println!("First: {first}"),
None => println!("Vector is empty"),
}
Option methods
is_some() / is_none():
let x = Some(5);
if x.is_some() {
println!("Value exists");
}
unwrap_or() / unwrap_or_else():
let x: Option<i32> = None;
let val = x.unwrap_or(0); // 0 if None
let val = x.unwrap_or_else(|| {
println!("Computing default...");
0
});
map():
let x = Some(5);
let y = x.map(|num| num + 1); // Some(6)
let x: Option<i32> = None;
let y = x.map(|num| num + 1); // None (no computation)
? operator with Option:
fn first_word(s: &str) -> Option<&str> {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return Some(&s[0..i]);
}
}
None
}
fn caller() -> Option<&'static str> {
let word = first_word("hello world")?; // If None, return None
println!("{word}");
Some(word)
}
Matching on Errors
Different errors need different handling:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let result = File::open("hello.txt");
match result {
Ok(file) => println!("Success!"),
Err(error) => match error.kind() {
ErrorKind::NotFound => {
println!("File not found. Creating...");
File::create("hello.txt").expect("Failed to create");
}
ErrorKind::PermissionDenied => {
println!("Permission denied");
}
_ => panic!("Other error: {error}"),
}
}
}
For each Err, check the error kind and respond accordingly.
Alternative: 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").expect("Failed to create")
} else {
panic!("Other error: {error}")
}
});
}
Less verbose for complex error handling.
Error Propagation: Passing Errors Upward
Key principle: Handle errors at the right level.
If you can't handle an error, propagate it to the caller using ?:
use std::fs;
use std::io;
fn read_config(filename: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(filename)?;
// If file doesn't exist, return error to caller
// Caller can decide: create default, ask user, show error, etc.
Ok(content)
}
fn main() {
match read_config("config.txt") {
Ok(config) => println!("Config: {config}"),
Err(e) => {
eprintln!("Failed to read config: {e}");
// Use default config, exit, etc.
}
}
}
The caller has context. They know whether to retry, use a default, or fail.
When to Panic vs. Return Result
Panic when:
- It's a programming error — logic violation, impossible state
- Testing code — tests can safely panic
- Prototype/learning — unwrap is fine while exploring
- Truly unrecoverable — corrupted memory, system failure
Return Result when:
- User input might be invalid — don't panic on bad input
- External resource might not exist — file, network, database
- Caller should decide — let them handle the failure
- Production code — never assume success
Rule of thumb: If a user action could cause it, return Result. If a bug in your code causes it, panic.
Common Patterns
Pattern 1: File I/O
use std::fs;
fn read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
Ok(content)
}
Returns Result<String, Box<dyn std::error::Error>> — a boxed error type that can hold any error. (More on this in Chapter 17 of the Rust Book.)
Pattern 2: Parsing User Input
fn parse_age(input: &str) -> Result<u32, std::num::ParseIntError> {
let age = input.trim().parse::<u32>()?;
if age > 150 {
return Err("Age seems unrealistic".into());
}
Ok(age)
}
Pattern 3: Chaining Operations
fn process_numbers(s: &str) -> Result<i32, Box<dyn std::error::Error>> {
let num1: i32 = s.lines()
.next()
.ok_or("No first line")? // Convert Option to Result
.parse()?; // Parse, propagate error
Ok(num1 * 2)
}
Combine ?, ok_or(), and method chaining for powerful error handling.
Pattern 4: Default Fallback
fn get_config() -> Config {
fs::read_to_string("config.json")
.and_then(|content| serde_json::from_str(&content).ok())
.unwrap_or_default() // Use default if anything fails
}
Try to read and parse; if anything fails, use a default Config.
Troubleshooting
Error: "the ? operator can only be used in a function that returns Result or Option"
fn main() {
let x = File::open("hello.txt")?; // ERROR
}
Fix: Change return type to Result:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let x = File::open("hello.txt")?;
Ok(())
}
In modern Rust, main can return Result. In older versions, use match instead:
fn main() {
match File::open("hello.txt") {
Ok(file) => println!("Success"),
Err(e) => println!("Error: {e}"),
}
}
Error: "type mismatch in match arm"
match some_result {
Ok(val) => val,
Err(e) => panic!("{e}"), // Type mismatch if Ok is String
}
Fix: Ensure arms return same type:
match some_result {
Ok(val) => val,
Err(e) => {
panic!("{e}"); // Both arms return !, which is ok for panic
}
}
// Or use unwrap/expect:
let val = some_result.expect("Failed");
Panicked: "called Result::unwrap() on an Err value"
This means you unwrapped an Err when production code hit an unexpected failure.
Fix: Use ? to propagate, or match and handle:
// BAD:
let file = File::open("config.txt").unwrap();
// GOOD:
let file = File::open("config.txt")?;
// GOOD:
match File::open("config.txt") {
Ok(f) => f,
Err(e) => {
eprintln!("Failed to open config: {e}");
return; // or use default
}
}
Anti-Patterns
Anti-Pattern: Unwrapping in Production
// DON'T:
fn read_file(path: &str) -> String {
fs::read_to_string(path).unwrap() // Will crash if file missing
}
// DO:
fn read_file(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path) // Let caller handle
}
Unwrap is a crash waiting to happen.
Anti-Pattern: Ignoring Errors Silently
// DON'T:
let _ = risky_operation(); // Error is silently ignored
// DO:
if let Err(e) = risky_operation() {
eprintln!("Warning: {e}"); // At least log it
}
If an operation can fail, acknowledge it somehow.
Anti-Pattern: Panicking on User Input
// DON'T:
fn get_age(input: &str) -> u32 {
input.parse().unwrap() // User typo → crash
}
// DO:
fn get_age(input: &str) -> Result<u32, ParseIntError> {
input.parse() // Let caller handle invalid input
}
Users will enter invalid input. Don't crash; ask again.
Key Takeaways
- Two error types: Recoverable (
Result) and unrecoverable (panic!) - Result<T, E> for operations that can fail
- Option<T> for values that might not exist
- The
?operator is your friend — use it to propagate errors elegantly - Handle at the right level — let callers decide how to recover
- Never unwrap in production — it's a crash waiting to happen
- Always ask: "Should the caller handle this?" If yes, return
Result
What's Next?
Error handling unlocks patterns you'll use daily:
- Next: Pattern Matching — elegantly handle complex data structures
- Then: Traits — abstract over error types and behaviors
- Practice: Refactor a previous program to return
Resultinstead of panicking
References
- Rust Book Chapter 9: Error Handling
- Result<T, E> Documentation
- Option<T> Documentation
- std::error::Error Trait
- Previous: Howto Rust Collections
- Previous: Howto Rust Ownership Borrowing
Last Updated: April 1, 2026
Author: CLAW-00
Difficulty: Beginner-Intermediate (essential concepts, immediately applicable)
Time to complete: 60-90 minutes of reading + practice