Rust Error Handling: Result, Option, and Panics
Rust does not have exceptions. Instead, it uses the Result and Option types to represent fallible and nullable computations. This forces you to handle errors explicitly — no uncaught exceptions propagating up the call stack, no null pointer dereferences.
This guide covers Rust’s error handling ecosystem: Option, Result, the ? operator, custom error types, and when to panic.
Option
Option<T> represents a value that may or may not be present. It has two variants:
enum Option<T> {
Some(T), // A value exists
None, // No value
---Using Option
fn find_user(id: u32) -> Option<String> {
let users = vec!["Alice".to_string(), "Bob".to_string()];
users.get(id as usize).cloned()
---
fn main() {
let user = find_user(0);
match user {
Some(name) => println!("Found: {}", name),
None => println!("User not found"),
}
// Shorter alternatives
if let Some(name) = find_user(1) {
println!("Found: {}", name);
}
let name = find_user(5).unwrap_or("Unknown".to_string());
println!("{}", name);
---Common Option Methods
let value: Option<i32> = Some(42);
// Unwrap (panics if None)
let x = value.unwrap();
// Unwrap with default
let y = value.unwrap_or(0);
let z = value.unwrap_or_else(|| expensive_default());
// Transform
let doubled = value.map(|v| v * 2);
let filtered = value.filter(|v| *v > 10);
// Chain
let result = value.and_then(|v| if v > 0 { Some(v * 2) } else { None });
// Combine with default from computation
let computed = value.or_else(|| Some(42));
// Expect with custom message
let name = value.expect("Value should be present");Result
Result<T, E> represents an operation that can succeed or fail:
enum Result<T, E> {
Ok(T), // Success
Err(E), // Failure
---Using Result
use std::fs::File;
use std::io::{self, Read};
fn read_username(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?; // ? propagates the error
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username.trim().to_string())
---
fn main() {
match read_username("user.txt") {
Ok(name) => println!("Username: {}", name),
Err(e) => eprintln!("Failed to read username: {}", e),
}
---The ? Operator
The ? operator is syntactic sugar for propagating errors:
// Without ?
fn read_file(path: &str) -> Result<String, io::Error> {
let mut file = match File::open(path) {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut content = String::new();
match file.read_to_string(&mut content) {
Ok(_) => Ok(content),
Err(e) => Err(e),
}
---
// With ?
fn read_file_short(path: &str) -> Result<String, io::Error> {
let mut content = String::new();
File::open(path)?.read_to_string(&mut content)?;
Ok(content)
---Common Result Methods
let result: Result<i32, String> = Ok(42);
// Unwrap (panics on Err)
let x = result.unwrap();
// Unwrap with default
let y = result.unwrap_or(0);
let z = result.unwrap_or_else(|_| expensive_default());
// Transform Ok value
let doubled = result.map(|v| v * 2);
// Transform Err value
let mapped_err = result.map_err(|e| format!("Error: {}", e));
// Chain with fallible operation
let chained = result.and_then(|v| {
if v > 0 {
Ok(v * 2)
} else {
Err("Value must be positive".to_string())
}
---);
// Ignore the Ok value, keep the Result
let ignored = result.and(Ok(100));Custom Error Types
Simple Error Type
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
InvalidInput(String),
DatabaseError(String),
---
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
---
impl std::error::Error for AppError {}Wrapping External Errors
use std::io;
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(String),
---
impl From<io::Error> for MyError {
fn from(err: io::Error) -> MyError {
MyError::Io(err)
}
---
fn read_config(path: &str) -> Result<String, MyError> {
let content = std::fs::read_to_string(path)?; // io::Error → MyError
Ok(content)
---The thiserror Crate
thiserror derives the Error trait for custom error types:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Validation error: {0}")]
InvalidInput(#[from] ValidationError),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Unknown error")]
Unknown,
---The anyhow Crate
anyhow provides a convenient Result type for application-level code where you don’t need to define custom error types:
use anyhow::{Result, Context};
use std::fs;
fn read_user_config(user_id: u32) -> Result<String> {
let path = format!("/etc/users/{}/config.toml", user_id);
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config for user {}", user_id))?;
Ok(content)
---
fn main() -> Result<()> {
let config = read_user_config(42)?;
println!("Config: {}", config);
Ok(())
---When to panic
Rust distinguishes between unrecoverable errors (panics) and recoverable errors (Results).
Use panic when:
// 1. Example/prototype code (quick and dirty)
let value = some_option.unwrap();
// 2. The error indicates a programming bug
fn divide(a: i32, b: i32) -> i32 {
assert!(b != 0, "Division by zero is undefined");
a / b
---
// 3. Invariant guarantees — you know it cannot fail
let first = vec.first().expect("Vector is guaranteed non-empty");Use Result when:
// 1. The error is expected (file not found, network timeout)
fn read_file(path: &str) -> Result<String, io::Error>;
// 2. The caller should decide how to handle the error
fn parse_number(s: &str) -> Result<i32, ParseIntError>;
// 3. The error should propagate up the call stack
fn process_data(path: &str) -> Result<(), AppError> {
let data = read_file(path)?;
Ok(())
---Rule of thumb
Panic for programming bugs. Use Result for recoverable errors.
If an error could happen in normal operation (file missing, network down, invalid input), use Result. If an error indicates a bug (index out of bounds, division by zero, unwrapping a None that should be Some), panic is acceptable.
Error Handling Patterns
Handling Multiple Error Types
use std::io;
use std::num::ParseIntError;
fn parse_config(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?; // io::Error
let value: i32 = content.trim().parse()?; // ParseIntError
Ok(value)
---Retry Pattern
use std::time::Duration;
fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, reqwest::Error> {
let mut last_error = None;
for attempt in 1..=max_retries {
match reqwest::blocking::get(url) {
Ok(response) => return response.text(),
Err(e) => {
last_error = Some(e);
std::thread::sleep(Duration::from_secs(2u64.pow(attempt)));
}
}
}
Err(last_error.unwrap())
---Fallback Pattern
fn get_config() -> String {
// Try primary source, fall back to default
read_config("production.toml")
.or_else(|_| read_config("default.toml"))
.unwrap_or_else(|_| String::from("{}"))
---Conclusion
Rust’s error handling is explicit and type-safe. Use Option for values that may be absent, Result for operations that can fail, and the ? operator for error propagation. Define custom error types with thiserror for libraries, and use anyhow for applications. Reserve panics for programming bugs, and use Result for recoverable errors.
Related: Check our Rust Ownership and Borrowing guide.
thiserror and anyhow
Two crates dominate error handling in Rust applications. thiserror derives the Error trait for library error types, providing automatic implementations of Display, Debug, and source(). anyhow provides a contextual error type with backtrace support for application code where you want to propagate errors with context messages. Use thiserror in libraries to define specific error types, and anyhow in applications to handle errors with minimal boilerplate.
Error Handling Patterns
Match on Result for fine-grained control, use ? for early propagation, and combine with .context() from anyhow to annotate errors with contextual information. Avoid unwrap in production code except when you can prove the value is always present. Use expect with a descriptive message when panicking is acceptable.
Rust in Production: Case Studies
Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.
Embedded Systems Programming
Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.
FAQ
What background knowledge is recommended? A basic understanding of programming fundamentals will help, but many concepts are explained from first principles.
How can I practice these skills? Start with small projects, contribute to open source, and build a portfolio. Consistent practice is more effective than occasional deep dives.
What tools do I need to get started? Most topics require only a text editor, the relevant runtime, and package manager. Specific tool recommendations are included throughout.
For a comprehensive overview, read our article on Getting Started With Rust.