Rust Error Handling: Result, Option, Panic and ?
Deep dive into Rust error handling: unrecoverable panics, recoverable Result<T, E>, the ? propagation operator, custom errors, and best practices.
Error handling in Rust is unique compared to most mainstream languages. Rust has no try/catch exceptions and no null pointers. Instead, it makes error handling explicit, type-safe, and verifiable at compile time.
Explain Like I’m 10: The Postal Delivery Service#
Think of functions in Rust like ordering a parcel through a postal delivery system:
- Successful Delivery (
Ok(Package)): The postman arrives, rings your doorbell, and hands you the item. - Recoverable Delivery Issue (
Err(Reason)): The package address was slightly wrong or the recipient was not home. The post office sends a notice back to the sender so they can decide what to do (retry, call the customer, or redirect). - Unrecoverable Catastrophe (
panic!): The delivery truck broke down completely or the post office building was destroyed. The entire operation halts immediately because there is no safe way to continue.
graph TD
Op["Operation / Function Call"] --> Choice{"Is the error recoverable?"}
Choice -->|No: Critical Bug / Invariant Broken| Panic["panic! Macro<br/>(Crash, Unwind Stack, Terminate)"]
Choice -->|Yes: File Missing, Network Failure, Invalid Input| ResultEnum["Result<T, E>"]
ResultEnum --> OkBranch["Ok(value) -> Success"]
ResultEnum --> ErrBranch["Err(error) -> Handle or Propagate with ?"]
Two Categories of Errors in Rust#
| Feature | Recoverable Errors (Result<T, E>) | Unrecoverable Errors (panic!) |
|---|---|---|
| Mechanism | Enum Result<T, E> (Ok or Err) | Macro panic!("...") |
| Typical Cause | File not found, parse failure, timeout | Out-of-bounds index, broken invariant, assertion fail |
| Handling | match, if let, ?, combinators | Process termination or stack unwinding |
| Philosophy | Expected part of application flow | Severe bug; program cannot reliably continue |
Unrecoverable Errors with panic!#
When a panic! occurs:
- Rust prints the failure message to
stderr. - By default, it unwinds the stack — walking back up the stack to clean up resources and memory.
- The process exits with a non-zero status code.
fn main() {
// Explicit panic
// panic!("System failure: database connection pool corrupted");
// Implicit panic from runtime checks
let numbers = vec![10, 20, 30];
let _item = numbers[99]; // panics: index out of bounds: the len is 3 but the index is 99
}rust[!NOTE] Setting
RUST_BACKTRACE=1in your terminal environment displays the exact call stack leading to where the panic originated.
Recoverable Errors with Result<T, E>#
Rust defines Result in the standard library prelude as:
enum Result<T, E> {
Ok(T),
Err(E),
}rustInspecting Result with match#
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {e:?}"),
},
other_error => {
panic!("Problem opening the file: {other_error:?}");
}
},
};
println!("File opened successfully: {greeting_file:?}");
}rustUnwrapping Shortcuts: unwrap vs expect#
For rapid prototyping or test code, Result provides convenience extraction methods:
use std::fs::File;
// 1. .unwrap() -> returns value if Ok, panics with generic message if Err
let f1 = File::open("config.json").unwrap();
// 2. .expect(msg) -> returns value if Ok, panics with custom message if Err
let f2 = File::open("config.json").expect("config.json is required to boot the application");rust[!TIP] Always prefer
.expect()over.unwrap()in production code. A descriptive error message saves hours of debugging in production logs.
Error Propagation with the ? Operator#
Instead of handling every error locally with match, the ? operator unwraps Ok(T) or immediately returns Err(E) from the enclosing function:
sequenceDiagram
participant Caller as Calling Function
participant Worker as read_username
participant FS as File System
Worker->>FS: File::open("username.txt")
alt File exists
FS-->>Worker: Ok(file)
Worker->>FS: read_to_string()
alt Read succeeds
FS-->>Worker: Ok(bytes)
Worker-->>Caller: Ok(username)
else Read fails
FS-->>Worker: Err(io_error)
Worker-->>Caller: Returns Err(io_error) via ?
end
else File missing
FS-->>Worker: Err(io_error)
Worker-->>Caller: Returns Err(io_error) via ?
end
Clean Propagation in Action#
use std::fs::File;
use std::io::{self, Read};
// Verbose version with match
fn read_username_verbose() -> Result<String, io::Error> {
let username_file_result = File::open("username.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
// Concise version using ?
fn read_username_concise() -> Result<String, io::Error> {
let mut username = String::new();
File::open("username.txt")?.read_to_string(&mut username)?;
Ok(username)
}rustHow the ? Operator Works Under the Hood#
The ? operator automatically calls From::from on the error type, converting the specific error into the return error type of the calling function:
graph LR
Expr["Expression evaluates to Result<T, E1>"] --> Check{"Is it Ok or Err?"}
Check -->|Ok value| Extract["Unwrap value and continue execution"]
Check -->|Err e1| Convert["Convert e1 using From::from into E2"]
Convert --> ReturnEarly["Early return Err(e2) from function"]
Creating Custom Error Types#
In real-world Rust applications, functions may fail with multiple different error kinds (I/O error, JSON parsing error, database error). We group them into an Enum:
use std::fmt;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum AppError {
Io(io::Error),
Parse(ParseIntError),
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(err) => write!(f, "I/O error occurred: {err}"),
AppError::Parse(err) => write!(f, "Parse error occurred: {err}"),
AppError::NotFound(item) => write!(f, "Item not found: {item}"),
}
}
}
impl std::error::Error for AppError {}
// Enable automatic conversion using ?
impl From<io::Error> for AppError {
fn from(err: io::Error) -> Self {
AppError::Io(err)
}
}
impl From<ParseIntError> for AppError {
fn from(err: ParseIntError) -> Self {
AppError::Parse(err)
}
}
fn process_port_config() -> Result<u16, AppError> {
let mut content = String::new();
std::fs::File::open("port.txt")?.read_to_string(&mut content)?; // converts io::Error -> AppError
let port: u16 = content.trim().parse()?; // converts ParseIntError -> AppError
Ok(port)
}rustFunctional Combinators for Result and Option#
Rust provides functional combinators to transform errors smoothly:
.map(f): TransformsOk(v)usingf, leavesErruntouched..and_then(f): Chains another operation returningResult..unwrap_or(default): Returns inner value or default fallback..unwrap_or_else(f): Computes fallback value lazily from closure.
fn get_port_or_default() -> u16 {
std::env::var("PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(8080)
}rustSummary#
panic!is for bugs and fatal invariant breaches.Result<T, E>is for predictable failures that the caller must decide how to handle.- The
?operator provides zero-cost, clean error propagation and automatic type conversion. - Custom enum errors unify diverse error types under a single robust interface.