Error Handling in Rust
Rust has no exceptions and no null. Instead, the possibility of failure is baked directly into a function’s return type using two enums, Option<T> and Result<T, E>. The compiler forces you to acknowledge that a value might be missing or an operation might fail before you can use the result, which eliminates an entire class of bugs (null pointer dereferences, unchecked error codes) at compile time rather than at 3am in production. This lesson covers both the "give up" strategy (panicking) and the "handle it" strategy (returning Result), and how to write functions that propagate errors cleanly.
Overview: How Rust Handles Errors
Most languages split errors into two families, and so does Rust — but it makes the split explicit in the type system rather than leaving it to convention.
Unrecoverable errors are bugs: an index out of bounds, a violated invariant, a situation your program has no sensible way to continue past. Rust’s tool for this is the panic! macro. When a panic happens, Rust by default unwinds the stack, running destructors as it goes, and the program (or thread) terminates with a message. You don’t catch a panic in normal code — it’s a signal that something the programmer assumed to be true wasn’t.
Recoverable errors are the expected, everyday kind: a file might not exist, a network request might time out, a string the user typed might not be a valid number. For these, Rust doesn’t throw anything. Instead, a function that can fail returns a value of type Result<T, E>, an enum with two variants: Ok(T) holding the success value, or Err(E) holding the error value. Similarly, a function whose result might simply be absent (not wrong, just not there) returns Option<T>: either Some(T) or None.
Here is the mental model that matters most: a Result or Option is just an ordinary value, like an integer or a struct. It is not a thrown exception traveling invisibly up the call stack. It sits in a variable, gets passed to functions, and — critically — the compiler will not let you use the T inside without first proving, via match, if let, or a method like .unwrap(), that you’ve dealt with the possibility of Err or None. Because Result is marked #[must_use], even silently ignoring it produces a compiler warning. This is what people mean when they say Rust makes error handling "impossible to forget": the type system, not developer discipline, enforces it.
The other key idea is propagation. Most functions that can fail don’t want to handle the error themselves — they want to pass it up to whoever called them, who has more context. Rust has a dedicated operator, ?, for exactly this: it says "if this is Ok, give me the inner value and keep going; if it’s Err, stop this function immediately and return that error to my caller." This turns what would be deeply nested error-checking in other languages into flat, readable code.
Syntax
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
| Construct | Meaning |
|---|---|
Result<T, E> |
Either Ok(T) (success) or Err(E) (failure). Used for recoverable, expected failures. |
Option<T> |
Either Some(T) or None. Used when a value may legitimately be absent. |
match |
Exhaustively handles every variant; the compiler rejects a match that misses a case. |
if let Ok(x) = r { ... } |
Handles just the case you care about, ignoring the rest. |
expr? |
Unwraps Ok/Some, or returns Err/None from the current function immediately. Requires the function’s return type to be a compatible Result/Option. |
.unwrap() |
Returns the inner value or panics if it’s Err/None. Use sparingly. |
.expect("msg") |
Like .unwrap() but panics with a custom message — better for debugging. |
.map_err(f) |
Transforms the Err value, leaving Ok untouched. |
.ok_or_else(f) |
Converts an Option<T> into a Result<T, E>, calling f to build the error if it was None. |
panic!("msg") |
Immediately aborts the current thread with the given message. |
Examples
Example 1: Handling a Result with match
Parsing a string into a number can fail, so str::parse returns a Result, not a bare number.
fn main() {
let inputs = vec!["42", "seven", "100"];
for input in inputs {
match input.parse::<i32>() {
Ok(number) => println!("Parsed {} as {}", input, number),
Err(e) => println!("Failed to parse {:?}: {}", input, e),
}
}
}
Output:
Parsed 42 as 42
Failed to parse "seven": invalid digit found in string
Parsed 100 as 100
Each iteration produces a fresh Result<i32, ParseIntError>. The match is exhaustive: the compiler would refuse to compile this if either arm were missing. Notice there’s no exception thrown for "seven" — the loop simply keeps running because the failure was a normal value, not a control-flow interruption.
Example 2: Propagating errors with the ? operator
Writing a match at every call site gets repetitive. When a function just wants to pass a failure up to its caller, ? does it in one character.
use std::num::ParseIntError;
fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
let number = input.parse::<i32>()?;
Ok(number * 2)
}
fn main() {
match parse_and_double("21") {
Ok(value) => println!("Doubled value: {}", value),
Err(e) => println!("Error: {}", e),
}
match parse_and_double("abc") {
Ok(value) => println!("Doubled value: {}", value),
Err(e) => println!("Error: {}", e),
}
}
Output:
Doubled value: 42
Error: invalid digit found in string
parse_and_double returns Result<i32, ParseIntError>, which matches the error type parse already produces, so ? can use it directly. When the input is "abc", the ? immediately returns Err(...) from parse_and_double — the line Ok(number * 2) never runs.
Example 3: Custom error types and Box<dyn Error>
Real programs usually have several distinct failure reasons. Modeling them as your own enum, and implementing the standard Error and Display traits, makes them behave like any other Rust error.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
enum ConfigError {
Missing(String),
Invalid(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::Missing(key) => write!(f, "missing config key: {}", key),
ConfigError::Invalid(key) => write!(f, "invalid value for key: {}", key),
}
}
}
impl Error for ConfigError {}
fn get_port(config: &[(&str, &str)]) -> Result<u16, ConfigError> {
let value = config
.iter()
.find(|(key, _)| *key == "port")
.map(|(_, v)| *v)
.ok_or_else(|| ConfigError::Missing("port".to_string()))?;
value
.parse::<u16>()
.map_err(|_| ConfigError::Invalid("port".to_string()))
}
fn load_settings(config: &[(&str, &str)]) -> Result<(), Box<dyn Error>> {
let port = get_port(config)?;
println!("Server will listen on port {}", port);
Ok(())
}
fn main() {
let good_config = vec![("port", "8080")];
let bad_config = vec![("host", "localhost")];
if let Err(e) = load_settings(&good_config) {
println!("Startup failed: {}", e);
}
if let Err(e) = load_settings(&bad_config) {
println!("Startup failed: {}", e);
}
}
Output:
Server will listen on port 8080
Startup failed: missing config key: port
get_port returns the specific ConfigError type, but load_settings returns the more general Box<dyn Error>. The ? operator automatically converts ConfigError into a boxed trait object, which is what lets a single function absorb errors of many different concrete types — a common pattern at the top level of an application.
How It Works Step by Step
When the compiler sees expr?, it desugars roughly into:
match expr {
Ok(value) => value,
Err(err) => return Err(From::from(err)),
}
That From::from(err) call is why ? can convert a ConfigError into a Box<dyn Error> automatically in Example 3 — the standard library provides a blanket From<E> for Box<dyn Error> implementation for any E: Error. It’s also why Example 2 needed parse_and_double‘s error type to match (or be convertible from) ParseIntError: if there’s no applicable From conversion, the code simply won’t compile (see Common Mistakes below).
For panic!, the sequence is different: the panicking thread prints the message to standard error, then, under the default unwind panic strategy, walks back up the call stack running each in-scope value’s destructor (Drop) as it goes, freeing memory and closing resources cleanly, before the thread exits. This is why a panic in one thread of a multithreaded program doesn’t necessarily corrupt memory in another — but it also isn’t something you should rely on for routine control flow, since the whole thread terminates.
Common Mistakes
Mistake 1: Reaching for .unwrap() on a Result that can realistically fail
.unwrap() is convenient in throwaway code, but it panics the moment it sees an Err, which is rarely what you want in a real program:
let numbers = vec!["10", "twenty", "30"];
let parsed: i32 = numbers[1].parse().unwrap(); // panics: "twenty" isn't a number
println!("{}", parsed);
This compiles fine — the compiler has no way to know the string won’t parse — but running it panics with called \`Result::unwrap()\` on an \`Err\` value: ParseIntError { kind: InvalidDigit } and the process exits. Handle the error instead:
let numbers = vec!["10", "twenty", "30"];
match numbers[1].parse::<i32>() {
Ok(n) => println!("Parsed: {}", n),
Err(e) => println!("Could not parse '{}': {}", numbers[1], e),
}
Output:
Could not parse 'twenty': invalid digit found in string
Now a bad input prints a message and the program keeps running instead of crashing.
Mistake 2: Using ? when the error types don’t match and there’s no conversion
The ? operator only compiles when the function’s error type implements (or can be reached via) From for the error being propagated. This looks reasonable but fails to compile:
fn read_count(input: &str) -> Result<i32, String> {
let count = input.parse::<i32>()?; // error: no `From<ParseIntError> for String`
Ok(count)
}
input.parse::<i32>() produces Result<i32, ParseIntError>, but the function promises Result<i32, String>, and the standard library has no automatic conversion from ParseIntError to String. The compiler rejects this with a trait-not-implemented error. Convert the error explicitly with .map_err():
fn read_count(input: &str) -> Result<i32, String> {
input.parse::<i32>().map_err(|e| e.to_string())
}
fn main() {
match read_count("12") {
Ok(n) => println!("Count: {}", n),
Err(e) => println!("Error: {}", e),
}
match read_count("nope") {
Ok(n) => println!("Count: {}", n),
Err(e) => println!("Error: {}", e),
}
}
Output:
Count: 12
Error: invalid digit found in string
.map_err() runs only on the Err path, turning the ParseIntError into a String before ? (or, here, the bare return) ever sees it — so the types line up and the function compiles.
Best Practices
- Prefer returning
Resultover panicking in any function a caller might reasonably want to recover from — reservepanic!for genuine bugs and broken invariants. - Use
?to propagate errors instead of manually matching and re-returning at every call site; it keeps the success path readable. - Give custom error types a real
Displayimplementation (and implementstd::error::Error) so they compose with?,Box<dyn Error>, and logging code written by others. - Use
Box<dyn Error>(or an enum wrapping several error variants) at boundaries where multiple unrelated error types need to flow through one return type, such as a program’smainor a top-level handler. - Avoid
.unwrap()and.expect()outside of tests, quick prototypes, and cases where failure is truly a programming bug you’d want a panic for anyway; prefer.expect("message")over bare.unwrap()when you do panic, since the message tells you exactly which assumption failed. - For larger applications, consider the popular
thiserrorcrate (for derivingDisplay/Erroron custom enums) andanyhow(for ergonomicBox<dyn Error>-style handling) once your error-handling needs outgrow hand-written impls. - Don’t use
Optionwhere aResultis more appropriate — if you can say why something failed, return aResultso callers get that information instead of a bareNone.
Practice Exercises
- Exercise 1: Write a function
fn safe_divide(a: f64, b: f64) -> Result<f64, String>that returnsErr("division by zero".to_string())whenbis0.0, andOk(a / b)otherwise. Call it with a few pairs of numbers, including a zero divisor, and print each result withmatch. - Exercise 2: Write a function
fn first_word(s: &str) -> Option<&str>that returns the first whitespace-separated word ofs, orNoneif the string is empty. Hint:s.split_whitespace().next()already returns anOption<&str>. - Exercise 3: Define an enum
enum StackError { Empty }with aDisplayimpl, then writefn pop_or_error(stack: &mut Vec<i32>) -> Result<i32, StackError>that returns the popped value orErr(StackError::Empty)when the vector is empty. Expected behavior: popping fromvec![1, 2]twice succeeds, and a third pop returns the error.
Summary
- Rust has no exceptions or
null; failure is represented by theResult<T, E>andOption<T>enums, which are ordinary values the compiler forces you to handle. panic!is for unrecoverable bugs and unwinds the stack, running destructors, before terminating the thread; it is not a substitute for normal error handling.- The
?operator unwrapsOk/Someor returnsErr/Noneimmediately, usingFromto convert the error type when needed — if no conversion exists, the code fails to compile. .unwrap()and.expect()panic onErr/Noneand should be reserved for cases where failure genuinely can’t happen or doesn’t matter.- Custom error enums that implement
Displayandstd::error::Errorintegrate cleanly with?andBox<dyn Error>, letting you unify many failure types behind one return type. - Choose
Resultwhen you can explain why something failed, andOptionwhen a value is simply, legitimately absent.
