Every real program meets failures it cannot prevent: a file is missing, a user types "eight" where a number was expected, a network connection drops. Languages differ mainly in how they make you deal with them. Java and Python use exceptions, which jump up the call stack invisibly until something catches them; nothing in a function's signature tells you what it can throw (Java's checked exceptions are a partial exception). C returns error codes that are easy to ignore. Go returns an error value alongside the result, and you check it with if err != nil.
Rust splits failures into two kinds. Unrecoverable errors are bugs, such as an index past the end of an array, and they panic: the thread stops. Recoverable errors are expected possibilities, such as a missing file, and they are ordinary return values of type Result<T, E>, visible in the function's signature and impossible to ignore by accident. The ? operator makes passing errors up the call stack a single character, and the From trait converts between error types along the way.
This lesson covers panic! and when it is appropriate, Result, unwrap and expect, the ? operator for both Result and Option, converting errors with From and map_err, writing a custom error enum that implements Display and std::error::Error, the thiserror and anyhow crates and when to use each, and returning errors from main. Interviewers commonly ask how Rust error handling differs from exceptions, what ? does under the hood, and when unwrap is acceptable; you will be able to answer all three.
Panics: unrecoverable errors
A panic means "the program has reached a state that should be impossible, and it cannot continue safely". You trigger one with the panic! macro, and the standard library triggers one for bugs such as out-of-bounds indexing, integer overflow in debug builds, and division by zero.
fn percentage(part: u32, total: u32) -> u32 {
if total == 0 {
panic!("percentage called with total = 0 (part = {part})");
}
part * 100 / total
}
fn main() {
println!("{}%", percentage(45, 60));
println!("{}%", percentage(1, 0));
println!("never printed");
}
Output (standard output and standard error together):
75%
thread 'main' (1742664) panicked at panic.rs:3:9:
percentage called with total = 0 (part = 1)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The first call prints 75% (45 × 100 / 60 = 75). The second hits the panic!, which prints the message with the file, line and column, and the program stops with exit code 101; "never printed" is indeed never printed. The number after 'main' is a thread identifier and differs between runs.
What happens during a panic:
- By default, Rust unwinds the stack: it walks back up through the calling functions and runs the destructors (
drop) of every value still alive, so files are closed and memory is freed. - If the panicking thread is the main thread, the process exits with code 101. A panic in any other thread ends only that thread, and the parent can detect it when it joins the thread (lesson 16).
- Setting the environment variable
RUST_BACKTRACE=1prints the chain of function calls that led to the panic, which is the first thing to do when debugging one.
You can choose to abort instead of unwinding by adding panic = "abort" to a profile in Cargo.toml (for example under [profile.release]). The process then stops immediately without running destructors, which gives a slightly smaller binary. Most programs keep the default.
When should code panic?
Panic when continuing would be wrong, and the cause is a bug in the program rather than a situation the caller could reasonably expect:
| Situation | Panic or Result? | Why |
|---|---|---|
| A broken internal invariant, "this can never happen" | Panic | It is a bug; continuing could corrupt data |
A function called in violation of its documented contract (like percentage with total 0) | Panic, or better, make the invalid input impossible with types | The caller has a bug |
| User input that might be malformed | Result | Bad input is expected |
| A file, network or database operation | Result | Failure is normal in the outside world |
| Tests, examples and prototypes | Panicking with unwrap/expect is fine | A crash with a message is what you want |
A good rule: a library should almost never panic on input it receives from its caller. It should return a Result and let the application decide.
Result: recoverable errors
Result is an enum from the standard library, always in scope, much like Option from lesson 7:
enum Result<T, E> {
Ok(T),
Err(E),
}
T is the type of the success value and E the type of the error. A function that returns Result<i32, ParseIntError> says, in its signature, "you will get an i32 or a ParseIntError, and you must decide what to do with each". Because Result is marked #[must_use], ignoring one entirely produces a compiler warning.
use std::fs;
use std::io::ErrorKind;
fn main() {
for text in ["42", "4x2", "", "99999999999"] {
match text.parse::<i32>() {
Ok(n) => println!("{text:?} -> parsed {n}"),
Err(e) => println!("{text:?} -> error: {e}"),
}
}
match fs::read_to_string("settings.toml") {
Ok(contents) => println!("read {} bytes", contents.len()),
Err(e) if e.kind() == ErrorKind::NotFound => {
println!("no settings.toml, using defaults ({e})")
}
Err(e) => println!("could not read settings: {e}"),
}
}
Output:
"42" -> parsed 42
"4x2" -> error: invalid digit found in string
"" -> error: cannot parse integer from empty string
"99999999999" -> error: number too large to fit in target type
no settings.toml, using defaults (No such file or directory (os error 2))
Step by step:
text.parse::<i32>()returnsResult<i32, ParseIntError>. Each failing input gives a different error, and printing it with{e}(itsDisplayform) produces a readable message. "99999999999" is too large for ani32, whose maximum is 2,147,483,647.fs::read_to_stringreturnsResult<String, io::Error>. The secondmatchuses a guard (lesson 7) to treat "file not found" differently from other failures, such as missing permissions.e.kind()gives anErrorKind, an enum of portable error categories.- The text "No such file or directory (os error 2)" comes from the operating system; on Windows the wording differs.
Compared with exceptions, the failure is right there in the type. A Java method int parse(String s) might throw NumberFormatException and the compiler does not make you handle it, because it is an unchecked exception. A Rust function returning Result cannot be used as if it always succeeded.
unwrap and expect
unwrap() on a Result returns the Ok value or panics on Err. expect(msg) does the same, but adds your message to the panic. Both turn a recoverable error into a crash, which is sometimes exactly what you want.
fn main() {
let n: i32 = "4x2".parse().unwrap();
println!("{n}");
}
Output (standard error):
thread 'main' (1742077) panicked at unwrap_plain.rs:2:32:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fn main() {
let port: u16 = "8080".parse().unwrap();
println!("port = {port}");
let workers: u8 = "300"
.parse()
.expect("WORKERS must be a number from 0 to 255");
println!("workers = {workers}");
}
Output (standard output, then standard error):
port = 8080
thread 'main' (1741958) panicked at unwrap.rs:7:10:
WORKERS must be a number from 0 to 255: ParseIntError { kind: PosOverflow }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The second message is much more useful. 300 does not fit in a u8 (0 to 255), so parsing fails with PosOverflow, and the expect text tells the reader what the value should have been. When you do panic on an error, prefer expect with a message that states what you expected to be true, not just "failed".
When unwrap and expect are acceptable:
- In tests, examples and quick scripts.
- When you can prove the error cannot happen, and the proof is local and obvious.
"8080".parse::<u16>()on a literal can never fail. Say why in theexpectmessage. - At program start-up, for configuration without which the program cannot do anything useful, though returning an error from
mainis usually nicer.
Everywhere else, handle or propagate the error.
Pitfall: unwrap as a habit
Code full of unwrap() compiles and works on the happy path, then crashes on the first unexpected input with a message like "called Result::unwrap() on an Err value". Treat each unwrap as a claim that the error is impossible. If you cannot justify the claim, use ?.
The ? operator: propagating errors
Most functions cannot sensibly handle an error themselves; they should pass it to their caller. Writing that with match is repetitive:
let width = match w.trim().parse::<u32>() {
Ok(v) => v,
Err(e) => return Err(e),
};
The question mark operator ? does exactly this in one character. Applied to a Result, it unwraps an Ok value, or returns early from the current function with the Err:
use std::num::ParseIntError;
fn parse_size(text: &str) -> Result<(u32, u32), ParseIntError> {
let (w, h) = text.split_once('x').unwrap_or((text, ""));
let width = w.trim().parse::<u32>()?;
let height = h.trim().parse::<u32>()?;
Ok((width, height))
}
fn area(text: &str) -> Result<u32, ParseIntError> {
let (w, h) = parse_size(text)?;
Ok(w * h)
}
fn main() {
for input in ["1920x1080", "800 x 600", "12xabc", "640"] {
match area(input) {
Ok(a) => println!("{input:?}: area {a}"),
Err(e) => println!("{input:?}: {e}"),
}
}
}
Output:
"1920x1080": area 2073600
"800 x 600": area 480000
"12xabc": invalid digit found in string
"640": cannot parse integer from empty string
Tracing the inputs:
- "1920x1080" splits into "1920" and "1080"; both parse, and the area is 1,920 × 1,080 = 2,073,600.
- "800 x 600" has spaces, which
trimremoves: 800 × 600 = 480,000. - "12xabc": the width parses, the height does not, so the second
?returns theParseIntErrorfromparse_size. Inarea,parse_size(text)?sees anErrand returns it again. The error travels up two levels without anymatch. - "640" has no
x, sounwrap_or((text, ""))makes the height an empty string, which fails with "cannot parse integer from empty string".
main --calls--> area --calls--> parse_size --calls--> "abc".parse()
|
main <--Err---- area <--Err-(?)-- parse_size <--Err-(?)---+
(match handles it here)
Exactly what expr? does with a Result:
- Evaluate
expr. - If it is
Ok(v), the whole expression becomesv. - If it is
Err(e), convertewithFrom::from(e)into the function's error type, andreturn Err(converted).
Step 3's conversion is the part people forget, and it is what makes ? so useful with custom error types, as you will see shortly.
? works on Option too
In a function that returns Option, ? on an Option returns None early, or unwraps the Some. To move between the two, ok_or turns an Option into a Result, and map_err changes the error type of a Result:
fn initials(full_name: &str) -> Option<String> {
let mut parts = full_name.split_whitespace();
let first = parts.next()?.chars().next()?;
let last = parts.last()?.chars().next()?;
Some(format!("{first}.{last}."))
}
fn port_from(text: &str) -> Result<u16, String> {
let value = text.strip_prefix("port=").ok_or("expected port=NUMBER")?;
value
.parse::<u16>()
.map_err(|e| format!("bad port {value:?}: {e}"))
}
fn main() {
println!("{:?}", initials("Sachin Ramesh Tendulkar"));
println!("{:?}", initials("Madonna"));
println!("{:?}", port_from("port=8080"));
println!("{:?}", port_from("8080"));
println!("{:?}", port_from("port=99999"));
}
Output:
Some("S.T.")
None
Ok(8080)
Err("expected port=NUMBER")
Err("bad port \"99999\": number too large to fit in target type")
- In
initials, each?bails out withNoneif a piece is missing. "Madonna" has only one part, andparts.last()returnsNonebecausenext()already consumed the only element. - In
port_from,strip_prefixreturnsOption<&str>, andok_or("expected port=NUMBER")converts it to aResultso that?can be used in a function returningResult<u16, String>. The&strerror is converted into aStringby step 3 above, becauseStringimplementsFrom<&str>. map_errreplaces theParseIntErrorwith aStringcontaining more context. 99,999 does not fit in au16, whose maximum is 65,535.
You cannot use ? on an Option in a function that returns Result, or the other way round, without converting first.
Reading the compiler error: ? in a function that returns ()
? needs somewhere to return the error to. This does not compile:
fn main() {
let n: i32 = "42".parse()?;
println!("{n}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> question_unit_err.rs:2:30
|
1 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
2 | let n: i32 = "42".parse()?;
| ^ cannot use the `?` operator in a function that returns `()`
|
help: consider adding return type
|
1 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
2 | let n: i32 = "42".parse()?;
3 | println!("{n}");
4 + Ok(())
|
main returns (), so there is no error type to return. The help shows the fix: make main return a Result, covered at the end of this lesson. A similar error appears when you use ? inside a closure that does not itself return a Result, such as the closure passed to map.
Reading the compiler error: ? could not convert the error
The function's error type and the error you apply ? to may differ. ? then calls From::from, and if no conversion exists, it fails. This does not compile:
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
BadInput(ParseIntError),
}
fn read_age(text: &str) -> Result<u8, AppError> {
let age = text.parse::<u8>()?;
Ok(age)
}
fn main() {
println!("{:?}", read_age("31"));
}
error[E0277]: `?` couldn't convert the error to `AppError`
--> question_mismatch_err.rs:9:33
|
8 | fn read_age(text: &str) -> Result<u8, AppError> {
| -------------------- expected `AppError` because of this
9 | let age = text.parse::<u8>()?;
| -------------^ the trait `From<ParseIntError>` is not implemented for `AppError`
| |
| this can't be annotated with `?` because it has type `Result<_, ParseIntError>`
|
note: `AppError` needs to implement `From<ParseIntError>`
--> question_mismatch_err.rs:4:1
|
4 | enum AppError {
| ^^^^^^^^^^^^^
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
Reading it: the function promises AppError, the expression produces ParseIntError, and ? would need From<ParseIntError> for AppError. There are two fixes. Convert at the call site with text.parse::<u8>().map_err(AppError::BadInput)? (a tuple variant's name works as a function that builds the variant). Or implement From once, and every ? in the program can convert automatically:
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::BadInput(e)
}
}
Custom error types
A library that can fail in several ways should define its own error type, so that callers can tell the failures apart with match. The usual shape is an enum with one variant per kind of failure. To be a well-behaved error, it should implement three traits:
| Trait | Gives | How |
|---|---|---|
Debug | {:?}, needed by unwrap and main | #[derive(Debug)] |
Display | The human-readable message with {} | Write fmt by hand |
std::error::Error | Marks it as an error type; optional source() links to the underlying cause | impl Error for MyError {}, plus source if there is a cause |
Here is a configuration parser with four kinds of failure:
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Io(io::Error),
MissingKey(&'static str),
InvalidNumber {
key: &'static str,
source: ParseIntError,
},
OutOfRange {
key: &'static str,
value: u32,
max: u32,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::Io(_) => write!(f, "could not read the config file"),
ConfigError::MissingKey(key) => write!(f, "missing key `{key}`"),
ConfigError::InvalidNumber { key, .. } => write!(f, "`{key}` is not a number"),
ConfigError::OutOfRange { key, value, max } => {
write!(f, "`{key}` = {value} is out of range (max {max})")
}
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ConfigError::Io(e) => Some(e),
ConfigError::InvalidNumber { source, .. } => Some(source),
_ => None,
}
}
}
impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self {
ConfigError::Io(e)
}
}
struct Config {
port: u32,
workers: u32,
}
fn number(map: &HashMap<&str, &str>, key: &'static str, max: u32) -> Result<u32, ConfigError> {
let raw = map.get(key).ok_or(ConfigError::MissingKey(key))?;
let value = raw
.parse::<u32>()
.map_err(|source| ConfigError::InvalidNumber { key, source })?;
if value > max {
return Err(ConfigError::OutOfRange { key, value, max });
}
Ok(value)
}
fn parse_config(text: &str) -> Result<Config, ConfigError> {
let map: HashMap<&str, &str> = text
.lines()
.filter_map(|line| line.split_once('='))
.map(|(k, v)| (k.trim(), v.trim()))
.collect();
Ok(Config {
port: number(&map, "port", 65_535)?,
workers: number(&map, "workers", 64)?,
})
}
fn load(path: &str) -> Result<Config, ConfigError> {
let text = fs::read_to_string(path)?;
parse_config(&text)
}
fn report(result: Result<Config, ConfigError>) {
match result {
Ok(config) => println!("ok: port {}, {} workers", config.port, config.workers),
Err(e) => {
print!("error: {e}");
if let Some(cause) = e.source() {
print!(" (caused by: {cause})");
}
println!();
}
}
}
fn main() {
report(parse_config("port = 8080\nworkers = 8"));
report(parse_config("port = 8080"));
report(parse_config("port = eighty\nworkers = 8"));
report(parse_config("port = 8080\nworkers = 500"));
report(load("missing.conf"));
}
Output:
ok: port 8080, 8 workers
error: missing key `workers`
error: `port` is not a number (caused by: invalid digit found in string)
error: `workers` = 500 is out of range (max 64)
error: could not read the config file (caused by: No such file or directory (os error 2))
How the pieces fit:
- The enum lists the ways loading can fail.
InvalidNumberkeeps the originalParseIntErrorin a field calledsource, andOutOfRangekeeps the numbers needed to explain the problem. Callers canmatchon the variant: a missing key might get a default, while an I/O error might be retried. Displayturns each variant into a message for humans. It does not repeat the underlying cause's message, because that is reachable throughsource().Error::sourcereturns the error that caused this one, if any. Followingsource()repeatedly gives an error chain, from the high-level description down to the root cause.reportprints one level of it.From<io::Error>letsloadwritefs::read_to_string(path)?, and theio::Erroris wrapped inConfigError::Ioautomatically.ok_orandmap_errconvert the other failures at the point where the context (which key) is known.
The return type Option<&(dyn Error + 'static)> in source reads: "maybe a reference to some value of any type that implements Error". dyn Error is a trait object, a way to refer to a value by the trait it implements rather than by its concrete type; lesson 12 explains it, and 'static (lesson 13) says the error type does not borrow temporary data.
ConfigError::InvalidNumber { key: "port" } Display: "`port` is not a number"
|
| source()
v
ParseIntError { kind: InvalidDigit } Display: "invalid digit found in string"
|
| source()
v
None
Interview tip
If asked what ? does, give the precise three steps: on Ok(v) it evaluates to v; on Err(e) it calls From::from(e) to convert to the function's error type and returns early. Then mention that the conversion is why implementing From for your error enum lets ? work across library boundaries, and that ? also works on Option.
Box<dyn Error> and errors in main
Sometimes you do not want to define an enum, for example in a small program, or when the caller only needs to print the error. Box<dyn Error> is a pointer to any error type at all, stored on the heap. The standard library implements From<E> for Box<dyn Error> for every error type E, so ? converts any error into it.
main itself can return Result<(), E> as long as E implements Debug:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let workers: u8 = "8".parse()?;
println!("workers = {workers}");
let port: u16 = "70000".parse()?;
println!("port = {port}");
Ok(())
}
Output (standard output and standard error together, then the exit code):
workers = 8
Error: ParseIntError { kind: PosOverflow }
exit code: 1
When main returns Err(e), Rust prints Error: followed by the Debug form of e to standard error and exits with code 1. That is why the output shows ParseIntError { kind: PosOverflow } rather than a friendly message: Debug output is aimed at developers. For end users, either print the error yourself with {} and call std::process::exit, or use anyhow, whose Debug output is designed to be readable, as shown below.
| Error type | Callers can match on the cause? | Extra cost | Best for |
|---|---|---|---|
| Custom enum | Yes | None | Libraries; any code whose callers react differently to different failures |
Box<dyn Error> | Only by downcasting | One heap allocation per error | Small programs, prototypes, main |
anyhow::Error | Only by downcasting | One heap allocation per error | Applications that mostly report errors |
String | No | Allocation | Quick experiments only; loses the original error |
thiserror and anyhow
Writing Display, Error and From by hand for every error enum is repetitive. Two widely used crates remove the boilerplate, and they serve different purposes:
thiserroris for libraries. It generatesDisplay,Error,source()andFromimplementations for your own error enum from attributes. The result is an ordinary enum: callers can stillmatchon it, andthiserrordoes not appear in your public API.anyhowis for applications. It provides one error type,anyhow::Error, that can hold any error (likeBox<dyn Error>), plus.context(...)to add a message at each level, and a Debug format that prints the whole chain readably.
The rule of thumb that most Rust developers follow: libraries define precise error types with thiserror; applications use anyhow to collect and report them.
Setting up the project
Here is the configuration parser again, as a Cargo project with a library part (src/lib.rs, using thiserror) and a command-line program (src/main.rs, using anyhow). Lesson 10 explains how one package can contain both. The crates are added with cargo add:
$ cargo add thiserror anyhow
Adding thiserror v2.0.21 to dependencies
Features:
+ std
Adding anyhow v1.0.104 to dependencies
Features:
+ std
- backtrace
That leaves this Cargo.toml (the versions are the latest ones at the time of writing; yours may be newer):
[package]
name = "configcheck"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
thiserror = "2.0.21"
The library: thiserror
src/lib.rs:
use std::collections::HashMap;
use std::fs;
use std::io;
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("could not read the file")]
Io(#[from] io::Error),
#[error("missing key `{0}`")]
MissingKey(&'static str),
#[error("`{key}` is not a number")]
InvalidNumber {
key: &'static str,
#[source]
source: ParseIntError,
},
#[error("`{key}` = {value} is out of range (max {max})")]
OutOfRange {
key: &'static str,
value: u32,
max: u32,
},
}
#[derive(Debug)]
pub struct Config {
pub port: u32,
pub workers: u32,
}
fn number(map: &HashMap<&str, &str>, key: &'static str, max: u32) -> Result<u32, ConfigError> {
let raw = map.get(key).ok_or(ConfigError::MissingKey(key))?;
let value = raw
.parse::<u32>()
.map_err(|source| ConfigError::InvalidNumber { key, source })?;
if value > max {
return Err(ConfigError::OutOfRange { key, value, max });
}
Ok(value)
}
pub fn parse_config(text: &str) -> Result<Config, ConfigError> {
let map: HashMap<&str, &str> = text
.lines()
.filter_map(|line| line.split_once('='))
.map(|(k, v)| (k.trim(), v.trim()))
.collect();
Ok(Config {
port: number(&map, "port", 65_535)?,
workers: number(&map, "workers", 64)?,
})
}
pub fn load(path: &str) -> Result<Config, ConfigError> {
let text = fs::read_to_string(path)?;
parse_config(&text)
}
Compared with the hand-written version:
#[derive(Error)]implementsstd::error::Error, and each#[error("...")]attribute becomes that variant'sDisplaymessage. Fields are available by name ({key}) or position ({0}).#[from]on theIofield generatesFrom<io::Error> for ConfigError, sofs::read_to_string(path)?inloadconverts automatically. It also marks the field as the source.#[source]marks the field returned bysource(). A field namedsourceis treated this way even without the attribute; it is written out here for clarity.- Everything is
pubso that the binary can use it. Lesson 10 coverspub.
The enum is still a plain Rust enum. A caller can write match err { ConfigError::MissingKey(k) => ..., _ => ... }.
The application: anyhow
src/main.rs:
use anyhow::{Context, Result, bail};
fn main() -> Result<()> {
let Some(path) = std::env::args().nth(1) else {
bail!("usage: configcheck <file>");
};
let config = configcheck::load(&path).with_context(|| format!("could not load {path}"))?;
println!("{path}: port {}, {} workers", config.port, config.workers);
Ok(())
}
anyhow::Result<T>is short forResult<T, anyhow::Error>. Any error that implementsstd::error::Error(plusSend,Syncand'static, which ordinary error types are) converts intoanyhow::Errorthrough?.with_context(|| ...)wraps the error with a higher-level message. The closure is only called if there is an error, so building the message withformat!costs nothing on success..context("fixed text")is the version for messages that need no formatting.bail!(...)returns early with an error built from a message, likereturn Err(anyhow!(...)). Here it combines withlet elsefrom lesson 7.
Running it with a good file, a file containing workers = eight, a missing file, and no argument:
$ cargo run -q -- good.conf
good.conf: port 8080, 8 workers
$ cargo run -q -- bad.conf
Error: could not load bad.conf
Caused by:
0: `workers` is not a number
1: invalid digit found in string
$ cargo run -q -- missing.conf
Error: could not load missing.conf
Caused by:
0: could not read the file
1: No such file or directory (os error 2)
$ cargo run -q --
Error: usage: configcheck <file>
$ echo $?
1
When main returns an anyhow::Error, its Debug output prints the top message, then each cause from source(), numbered. The chain for the bad file has three levels: the context added in main, the ConfigError from the library, and the ParseIntError inside it. A user can read it, and a developer can see exactly where it came from. The exit code is 1, as for any Err returned from main.
Pitfall: anyhow in a library's public API
Returning anyhow::Error from a library forces callers to downcast to find out what went wrong, and ties them to anyhow. Use it inside applications. Libraries should expose their own error types, written by hand or with thiserror.
Idioms and pitfalls
Idiom: add context where you know it
An error like "No such file or directory" is useless without the file name. Add context at the level that knows it: map_err into a variant that carries the key or path, or with_context in an application. Each layer should say what it was trying to do.
Idiom: collect an iterator of Results
Result implements FromIterator, so iter.map(parse).collect::<Result<Vec<_>, _>>() gives Ok(vec) if every item succeeded, or the first Err. You will use this in the exercises.
Pitfall: converting errors to strings too early
map_err(|e| e.to_string()) everywhere makes code compile quickly, but it throws away the error's type and its source() chain, so callers can no longer react to specific failures. Keep typed errors until the point where you report them.
Pitfall: logging and returning the same error
If every layer both logs an error and returns it, one failure appears in the logs five times. Either handle an error (log it, retry, fall back) or return it with added context, not both.
Error handling compared with other languages
| Java / Python exceptions | Go | C | Rust | |
|---|---|---|---|---|
| Error visible in the signature | Only Java checked exceptions | Yes, a second return value | Sometimes, by convention | Yes, Result<T, E> |
| Can be ignored silently | Unchecked exceptions propagate unseen | Yes, _ = or forgetting to check | Yes | No: #[must_use] warning, and T cannot be used without unwrapping |
| Propagation | Automatic stack unwinding | if err != nil { return err } | Manual | ?, explicit but one character |
| Cost on the success path | Low, but throwing is expensive | A comparison | A comparison | A comparison |
| Bugs and impossible states | Exceptions too | panic | abort, or undefined behaviour | panic! |
Exercises
Exercise 1: a typed withdrawal error
Revisit the bank account from lesson 6. Make withdraw return Result<u64, WithdrawError> (the remaining balance on success), with variants for a zero amount and for insufficient funds that records how much was needed and available. Implement Display and Error.
Solution
impl std::error::Error for WithdrawError {} is enough, because the default source() returns None and there is no underlying cause.
use std::fmt;
#[derive(Debug, PartialEq)]
enum WithdrawError {
ZeroAmount,
InsufficientFunds { needed: u64, available: u64 },
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::ZeroAmount => write!(f, "amount must be positive"),
WithdrawError::InsufficientFunds { needed, available } => {
write!(f, "need {needed} but only {available} available")
}
}
}
}
impl std::error::Error for WithdrawError {}
struct Account {
balance: u64,
}
impl Account {
fn withdraw(&mut self, amount: u64) -> Result<u64, WithdrawError> {
if amount == 0 {
return Err(WithdrawError::ZeroAmount);
}
if amount > self.balance {
return Err(WithdrawError::InsufficientFunds {
needed: amount,
available: self.balance,
});
}
self.balance -= amount;
Ok(self.balance)
}
}
fn main() {
let mut acc = Account { balance: 5_000 };
for amount in [1_200, 0, 9_000] {
match acc.withdraw(amount) {
Ok(left) => println!("withdrew {amount}, {left} left"),
Err(e) => println!("withdraw {amount} failed: {e}"),
}
}
}
withdrew 1200, 3800 left
withdraw 0 failed: amount must be positive
withdraw 9000 failed: need 9000 but only 3800 available
5,000 − 1,200 = 3,800, which is then less than 9,000.
Exercise 2: sum the numbers in a text
Write fn sum_lines(text: &str) -> Result<i64, ParseIntError> that adds up one integer per line, skipping blank lines, and returns the first parse error.
Solution
? inside the loop returns at the first bad line.
use std::num::ParseIntError;
fn sum_lines(text: &str) -> Result<i64, ParseIntError> {
let mut total = 0;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
total += line.parse::<i64>()?;
}
Ok(total)
}
fn main() {
println!("{:?}", sum_lines("10\n20\n\n-5\n"));
println!("{:?}", sum_lines("10\ntwenty\n30"));
}
Ok(25)
Err(ParseIntError { kind: InvalidDigit })
10 + 20 − 5 = 25. The second input stops at "twenty".
Exercise 3: all or nothing, and partition
Parse a slice of strings into Vec<u32>, failing if any item is invalid, without writing a loop. Then, for a different input, separate the successes from the failures.
Solution
Collecting into Result<Vec<u32>, _> stops at the first error. partition splits the results into two vectors instead; Result::unwrap is safe on the good half because every element there is Ok.
fn parse_all(items: &[&str]) -> Result<Vec<u32>, std::num::ParseIntError> {
items.iter().map(|s| s.parse::<u32>()).collect()
}
fn main() {
println!("{:?}", parse_all(&["3", "14", "15"]));
println!("{:?}", parse_all(&["3", "-14", "15"]));
let (good, bad): (Vec<_>, Vec<_>) = ["7", "x", "9", "y"]
.iter()
.map(|s| s.parse::<u32>())
.partition(|r| r.is_ok());
let good: Vec<u32> = good.into_iter().map(Result::unwrap).collect();
println!("good = {good:?}, bad count = {}", bad.len());
}
Ok([3, 14, 15])
Err(ParseIntError { kind: InvalidDigit })
good = [7, 9], bad count = 2
"-14" is invalid for a u32, which has no sign.
Exercise 4: fix the conversion
Make the read_age example from the "could not convert the error" section compile in two different ways.
Solution
Way one, convert at the call site: let age = text.parse::<u8>().map_err(AppError::BadInput)?;. Way two, implement the conversion once with impl From<ParseIntError> for AppError { fn from(e: ParseIntError) -> Self { AppError::BadInput(e) } }, after which the original ? compiles. With thiserror, the second way is the attribute BadInput(#[from] ParseIntError).
Exercise 5: panic or Result?
For each, decide whether to panic or return a Result: (a) a Stack::pop called on an empty stack; (b) parsing a date typed by a user; (c) a function that receives an index it computed itself from the length of the same vector; (d) opening a log file at start-up in a command-line tool.
Solution
(a) Neither: return Option<T>, as Vec::pop does, because "empty" is a normal state rather than an error. (b) Result: bad user input is expected. (c) Panic (plain indexing) is fine: if the index is wrong, the program has a bug. (d) Result, propagated to main with context, so the user sees "could not open log.txt: permission denied" rather than a panic message and a backtrace hint.
Interview questions
Q1. How does Rust's error handling differ from exceptions?
Recoverable errors are ordinary values of type Result<T, E> returned from functions, so they appear in signatures, cannot be used as successes without being handled, and are propagated explicitly with ? rather than unwinding invisibly. Unrecoverable bugs use panic!, which unwinds the thread. There is no try/catch for normal control flow; std::panic::catch_unwind exists but is meant for boundaries such as FFI and thread pools.
Q2. What does the ? operator do exactly?
On a Result, expr? evaluates to the Ok value, or converts the Err value with From::from into the enclosing function's error type and returns it early. On an Option, it evaluates to the Some value or returns None. It can only be used in functions (and closures) whose return type supports it, such as Result, Option, or types implementing the Try-related traits.
Q3. When is it acceptable to use unwrap or expect?
In tests, examples and prototypes, and where an error is genuinely impossible and you can show why, for example parsing a literal. expect with a message explaining the assumption is better than unwrap. In library code and on any path that handles external input, propagate the error instead.
Q4. What is the difference between panic and returning Err?
A panic signals a bug or an unrecoverable state: it unwinds the stack running destructors (or aborts, if configured) and ends the thread, and the main thread's panic ends the process with code 101. Err is a normal return value that the caller can inspect, retry, or pass on. Libraries should prefer Result for anything a caller might reasonably trigger.
Q5. How do you convert one error type into another?
Implement From<SourceError> for MyError, after which ? converts automatically, or convert explicitly with map_err. For Option, use ok_or or ok_or_else to produce a Result. thiserror's #[from] attribute generates the From implementation.
Q6. What should a custom error type implement?
Debug (usually derived), Display for a human-readable message, and std::error::Error, overriding source() when it wraps an underlying error. Using an enum lets callers match on the kind of failure. It should be Send + Sync + 'static where possible so that it works across threads and with anyhow and Box<dyn Error + Send + Sync>.
Q7. When would you use thiserror versus anyhow?
thiserror is for libraries: it derives Display, Error and From for your own error enums, so callers get precise, matchable types without boilerplate. anyhow is for applications: one catch-all error type with context messages and readable error chains, for code that mainly reports errors rather than reacting to specific ones. Many projects use both, thiserror in the library crate and anyhow in the binary.
Q8. What happens when main returns an Err?
main may return Result<(), E> where E: Debug. On Err, the runtime prints Error: followed by the Debug representation of the error to standard error and exits with a failure code (1). Because Debug output is developer-oriented, applications often use anyhow, whose Debug output prints the message and its cause chain readably.
Q9. What is Box<dyn Error>, and what are its trade-offs?
It is a heap-allocated trait object that can hold any type implementing Error, and ? converts any error into it through a blanket From implementation. It is convenient for small programs and main, but callers can no longer match on specific error kinds without downcasting, and each error costs an allocation. Add + Send + Sync if the error must cross threads.
Q10. What is an error chain?
A sequence of errors linked through Error::source(), from the high-level failure ("could not load config.toml") down to the root cause ("permission denied"). Each layer adds context about what it was doing without losing the original error. Reporting tools, including anyhow's Debug output, walk the chain and print every level.
Q11. Can you use ? on an Option inside a function that returns Result?
Not directly, because there is no automatic conversion from None to an error value; the compiler rejects it. Convert first with opt.ok_or(MyError::Missing)? or ok_or_else when building the error is expensive. The reverse, using a Result in an Option function, works with .ok()?, which discards the error.
Key takeaways
- Rust separates bugs (
panic!, which unwinds or aborts) from expected failures (Result<T, E>, an ordinary return value). Resultappears in signatures, is#[must_use], and must be handled before the success value can be used.unwrapandexpectturn errors into panics; use them in tests or when an error is provably impossible, and preferexpectwith a reason.?returns early with the error, converting it throughFrom; it also works onOptionin functions returningOption.ok_orturns anOptioninto aResult;map_errchanges the error type and can add context.- Custom error enums implement
Debug,DisplayandError(withsource()for chains) and let callers match on the failure. - Use
thiserrorto derive error types in libraries andanyhowwithcontextin applications. maincan returnResult; onErrit printsError:plus the Debug form and exits with code 1.
Next lesson
Continue with Modules and Cargo in Rust.

