Rust · lesson 16 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Error Handling in Rust
Handle errors in Rust with Result, the ? operator, custom error types, thiserror and anyhow — each example run for real, including the failures.
By SkillByExample · Updated
The Two Strategies: panic! and Result
Rust draws a clear line between two kinds of failure. A panic! signals a bug — something that should never happen in correct code, like an out-of-bounds access or a violated invariant. A Result signals an expected failure — something that can go wrong in normal operation, like a file not found or a network timeout. Separating these means callers always know whether a function can fail in a recoverable way, and the compiler enforces that they handle it.
// panic! — unrecoverable, crashes the thread immediately
fn divide_unsafe(a: i32, b: i32) -> i32 {
if b == 0 { panic!("division by zero!"); }
a / b
}
// Result — recoverable, caller decides what to do with the error
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 { Err(String::from("division by zero")) }
else { Ok(a / b) }
}
fn main() {
match divide(10, 2) {
Ok(n) => println!("{}", n),
Err(e) => eprintln!("Error: {}", e),
}
match divide(10, 0) {
Ok(n) => println!("{}", n),
Err(e) => eprintln!("Error: {}", e),
}
println!("still running");
println!("{}", divide_unsafe(10, 0));
println!("never printed");
}
$ rustc error_handling_the_two_strategies_panic_and_result.rs && ./error_handling_the_two_strategies_panic_and_result
5
Error: division by zero
still running
thread 'main' (6320) panicked at error_handling_the_two_strategies_panic_and_result.rs:3:17:
division by zero!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The Result version reports the problem and the program carries on to still running. divide_unsafe(10, 0) panics instead: the thread unwinds, never printed never prints, and the process exits with a non-zero status. (The number after 'main' is the OS thread ID and differs between runs.)
The ? Operator
Propagating errors manually with match on every fallible call quickly becomes verbose. The ? operator is syntactic sugar that does the propagation for you: it extracts the Ok value if the result succeeded, and if it failed it converts the error (using the From trait) and returns early. This lets you write a chain of fallible operations as if they were sequential, with errors handled implicitly.
use std::fs;
fn read_number_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?; // propagates io::Error if file missing
let n: i32 = content.trim().parse()?; // propagates ParseIntError if not a number
Ok(n * 2)
}
fn main() {
fs::write("good.txt", "21\n").unwrap();
fs::write("bad.txt", "twenty-one").unwrap();
for path in ["good.txt", "bad.txt", "missing.txt"] {
match read_number_from_file(path) {
Ok(n) => println!("{}: {}", path, n),
Err(e) => println!("{}: error: {}", path, e),
}
}
}
$ rustc error_handling_the_operator.rs && ./error_handling_the_operator
good.txt: 42
bad.txt: error: invalid digit found in string
missing.txt: error: The system cannot find the file specified. (os error 2)
One function, two different error types — io::Error from the read, ParseIntError from the parse — both funnelled into Box<dyn Error> by ?. The missing.txt message is the operating system’s own text, so it reads differently on Linux (No such file or directory (os error 2)).
? expands to approximately this match:
use std::fs;
fn read_len(path: &str) -> Result<usize, Box<dyn std::error::Error>> {
let content = match fs::read_to_string(path) {
Ok(v) => v,
Err(e) => return Err(e.into()), // .into() converts via the From trait
};
Ok(content.len())
}
fn main() {
fs::write("hello.txt", "hello").unwrap();
println!("{:?}", read_len("hello.txt").ok());
println!("{:?}", read_len("missing.txt").is_err());
}
$ rustc error_handling_the_operator_2.rs && ./error_handling_the_operator_2
Some(5)
true
? also works with Option — it returns None early instead of Err:
fn first_char(s: &str) -> Option<char> {
let c = s.chars().next()?; // returns None if string is empty
Some(c.to_uppercase().next()?)
}
fn main() {
println!("{:?}", first_char("hello"));
println!("{:?}", first_char(""));
}
$ rustc error_handling_the_operator_3.rs && ./error_handling_the_operator_3
Some('H')
None
Handling Result Without ?
When you need fine-grained control over what to do with each outcome, the Result type has a rich set of methods for transforming, chaining, and providing defaults:
fn main() {
let r: Result<i32, &str> = Ok(42);
// match — full control over both branches
match r { Ok(n) => println!("{}", n), Err(e) => println!("{}", e) }
// unwrap — panics on Err (use only in tests or when Err is truly impossible)
println!("{}", r.unwrap());
// expect — panics with a descriptive message (better than unwrap in tests)
println!("{}", r.expect("should have a number"));
// unwrap_or — provide a fallback value
println!("{}", r.unwrap_or(0));
// unwrap_or_else — compute the fallback lazily
println!("{}", r.unwrap_or_else(|_| -1));
// map — transform the Ok value, pass Err through unchanged
println!("{:?}", r.map(|n| n * 2));
// map_err — transform the Err value, pass Ok through unchanged
let e: Result<i32, i32> = Err(5);
println!("{:?}", e.map_err(|n| n * 2));
// and_then — chain a second fallible operation
let result = r.and_then(|n| if n > 0 { Ok(n) } else { Err("negative") });
println!("{:?}", result);
}
$ rustc error_handling_handling_result_without.rs && ./error_handling_handling_result_without
42
42
42
42
42
Ok(84)
Err(10)
Ok(42)
Five 42s in a row is the point: on an Ok, every one of these accessors agrees. They only differ on the Err path — unwrap and expect panic, unwrap_or and unwrap_or_else substitute a value.
Defining Custom Error Types
For library code, a string error type like String loses information and makes programmatic error handling impossible for callers. A custom error enum lets callers match on specific variants, access structured data, and understand the error chain. The std::error::Error trait and Display implementation make your error work with the broader ecosystem.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Parse(ParseIntError),
OutOfRange { value: i32, min: i32, max: i32 },
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::OutOfRange { value, min, max } =>
write!(f, "{} is out of range [{}, {}]", value, min, max),
AppError::NotFound(key) => write!(f, "'{}' not found", key),
}
}
}
impl std::error::Error for AppError {
// source() lets callers traverse the error chain
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Parse(e) => Some(e),
_ => None,
}
}
}
// Allow ? to convert ParseIntError into AppError automatically
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
fn parse_port(s: &str) -> Result<u16, AppError> {
let n: i32 = s.parse()?; // ParseIntError → AppError via From impl above
if n < 1 || n > 65535 {
return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
}
Ok(n as u16)
}
fn find_setting(key: &str) -> Result<&'static str, AppError> {
match key {
"port" => Ok("8080"),
_ => Err(AppError::NotFound(key.to_string())),
}
}
fn main() {
for input in ["8080", "99999", "http"] {
match parse_port(input) {
Ok(p) => println!("port: {}", p),
Err(e) => {
// source() is Some only for the variant that wraps another error
let cause = std::error::Error::source(&e).map(|s| s.to_string());
println!("error: {} (source: {:?})", e, cause);
}
}
}
match find_setting("host") {
Ok(v) => println!("found {}", v),
Err(e) => println!("error: {}", e),
}
}
$ rustc error_handling_defining_custom_error_types.rs && ./error_handling_defining_custom_error_types
port: 8080
error: 99999 is out of range [1, 65535] (source: None)
error: parse error: invalid digit found in string (source: Some("invalid digit found in string"))
error: 'host' not found
"http" fails inside s.parse(); ? converts the ParseIntError into AppError::Parse through the From impl, and source() hands the original error back. OutOfRange and NotFound are errors this code created itself, so they have no source.
Using thiserror for Cleaner Custom Errors
Writing Display, Error, and From implementations by hand is repetitive boilerplate. The thiserror crate generates all of it from annotations on your enum. This is the standard choice for library crates that need structured, typed errors without the ceremony.
[dependencies]
thiserror = "1"
use thiserror::Error;
use std::num::ParseIntError;
#[derive(Debug, Error)]
enum AppError {
// #[error("...")] generates the Display impl
// #[from] generates the From impl and wires it to ?
#[error("parse error: {0}")]
Parse(#[from] ParseIntError),
#[error("{value} is out of range [{min}, {max}]")]
OutOfRange { value: i32, min: i32, max: i32 },
}
fn parse_port(s: &str) -> Result<u16, AppError> {
let n: i32 = s.parse()?; // auto-converts via #[from] on Parse variant
if !(1..=65535).contains(&n) {
return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
}
Ok(n as u16)
}
fn main() {
for input in ["8080", "99999", "http"] {
match parse_port(input) {
Ok(p) => println!("port: {}", p),
Err(e) => println!("error: {} | {:?}", e, e),
}
}
}
Put that in src/main.rs of a Cargo project with the dependency above:
$ cargo run -q
port: 8080
error: 99999 is out of range [1, 65535] | OutOfRange { value: 99999, min: 1, max: 65535 }
error: parse error: invalid digit found in string | Parse(ParseIntError { kind: InvalidDigit })
The Display text (left of |) comes from the #[error(...)] strings; the Debug text (right) from #[derive(Debug)]. Every hand-written impl from the previous section is gone, and the behaviour is the same.
Using anyhow for Application Code
Library crates need typed errors so callers can react programmatically. Application code (binaries, CLI tools, scripts) usually just needs to display errors clearly and propagate them to main. The anyhow crate provides a single Error type that wraps any error, plus a .context() method that attaches human-readable explanation at each layer of the call stack.
[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail};
fn read_config(path: &str) -> Result<String> {
// .with_context() adds a message that appears when the error is printed
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {}", path))?;
if content.is_empty() {
// bail! is shorthand for return Err(anyhow!(...))
bail!("config file is empty");
}
Ok(content)
}
fn main() -> Result<()> {
let config = read_config("config.toml")
.context("application startup failed")?;
println!("config loaded: {} bytes", config.len());
Ok(())
}
Run it in a directory with no config.toml:
$ cargo run -q
Error: application startup failed
Caused by:
0: failed to read config from config.toml
1: The system cannot find the file specified. (os error 2)
Returning Err from main prints the outermost context first, then each layer underneath as a numbered Caused by: list, and exits with status 1. The last line is the OS error text — this run was on Windows; on Linux it reads No such file or directory (os error 2).
Combining thiserror and anyhow
The two crates are complementary and designed to be used together in larger projects. Use thiserror in library crates to give callers structured errors they can match on. Use anyhow in binary crates to accept errors from multiple libraries, wrap them with context, and present them to the user.
// In a real project DbError would live in a library crate's lib.rs
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("connection failed: {0}")]
Connection(String),
#[error("query failed: {0}")]
Query(String),
}
fn connect_db(url: &str) -> Result<(), DbError> {
if url.starts_with("postgres://") {
Err(DbError::Query(String::from("relation \"users\" does not exist")))
} else {
Err(DbError::Connection(format!("unsupported url '{}'", url)))
}
}
// Application code: anyhow accepts DbError (or any other std::error::Error)
use anyhow::{Context, Result};
fn run(url: &str) -> Result<()> {
connect_db(url).with_context(|| format!("while starting against {}", url))?;
Ok(())
}
fn main() {
for url in ["mysql://db", "postgres://db"] {
if let Err(e) = run(url) {
// {:#} prints the whole context chain on one line
println!("{:#}", e);
// the typed error is still recoverable underneath the context
if let Some(DbError::Query(q)) = e.downcast_ref::<DbError>() {
println!(" -> retryable query error: {}", q);
}
}
}
}
With both crates in [dependencies]:
$ cargo run -q
while starting against mysql://db: connection failed: unsupported url 'mysql://db'
while starting against postgres://db: query failed: relation "users" does not exist
-> retryable query error: relation "users" does not exist
DbError converts into anyhow::Error without any From impl of your own, and downcast_ref recovers the original typed error even after context has been layered on top — so application code can still branch on a specific library failure when it needs to.
Error Handling Summary
| Tool | Use case |
|---|---|
panic! | Bugs, broken invariants, “can’t happen” |
Result<T, E> | All recoverable errors |
? operator | Propagate errors up the stack |
unwrap / expect | Prototyping, tests, when None/Err is truly impossible |
Custom enum + thiserror | Library errors with structured variants |
anyhow | Application code, scripts, main functions |
Practice
-
Put
1500000000in a file and pass it toread_number_from_file. It parses fine as ani32— so what happens?Solution
It panics on the
n * 2, not the parse:thread 'main' (25256) panicked at error_handling_overflow.rs:6:8: attempt to multiply with overflowA plain
rustcbuild is a debug build, which checks arithmetic overflow and panics. Make the doubling itself fallible so it flows through?like every other error:n.checked_mul(2).ok_or_else(|| format!("{} is too large to double", n).into())The function now returns
Err("1500000000 is too large to double"). (A file containing2147483648never gets that far — it fails the parse withnumber too large to fit in target type.) -
Delete the
impl From<ParseIntError> for AppErrorblock from the custom error example and write the parse ass.parse::<i32>()?. What does the compiler say?Solution
error[E0277]: `?` couldn't convert the error to `AppError` ... = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait?is not magic: it callsFrom::fromon the error. NoFromimpl, no?. (Without the::<i32>turbofish you get a more confusingE0271instead, because the compiler tries to infer the parse target from the error type.) -
In the
anyhowexample, create an emptyconfig.tomland run again. What does the error chain look like now?Solution
Error: application startup failed Caused by: config file is emptyThe read succeeded, so there is no OS error at the bottom of the chain;
bail!created the innermost error and.context(...)wrapped it. -
Replace
{:#}with{}in the combined example’sprintln!. What disappears?Solution
Only the outermost context prints —
while starting against mysql://db— and theDbErrortext is dropped.{:#}walks the whole chain onto one line;{:?}would print the multi-lineCaused by:form thatmainuses.