Propagate Rust errors with Result and the ? operator
Use the ? operator and From conversions to propagate Rust errors cleanly instead of panicking with unwrap.
17 Jul 2025, 13:58 UTC

Start here: propagate, do not panic
You are writing a function that reads a file and parses a number. Each operation returns Result. Writing match for every call nests the success path deep inside failure branches. Replacing those matches with .unwrap() is shorter, but a missing file or bad input panics the thread at runtime instead of returning an error.
The practical alternative is Rust's ? operator. It unwraps an Ok value and continues; on Err, it returns early from the enclosing function. The caller receives the error and decides whether to retry, log, or exit. This guide shows one worked configuration, then covers where ? does not apply.
Assumptions
Examples assume Rust 1.70 or later, edition 2021, and a standard library-only project. fn main returning Result has been stable since Rust 1.26.
Worked example: load a counter from a file
Create a binary crate, then add this to src/main.rs. The function reads counter.txt, trims whitespace, and parses an integer.
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl From for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err)
}
}
impl From for AppError {
fn from(err: ParseIntError) -> Self {
AppError::Parse(err)
}
}
fn load_count(path: &str) -> Result {
let text = fs::read_to_string(path)?;
let count: i32 = text.trim().parse()?;
Ok(count)
}
fn main() -> Result<(), AppError> {
let count = load_count("counter.txt")?;
println!("count: {count}");
Ok(())
}
Place a file named counter.txt in the project directory containing a single integer, such as 42.
Run the checks
- Open a terminal in the crate directory. No special permissions are required beyond read access to
counter.txt. - Run
cargo check. It should compile without errors. - Run
cargo run. With a valid file, it printscount: 42. - Rename or delete
counter.txt, then runcargo runagain. The program prints theAppError::Iovariant and exits with a non-zero status instead of panicking.
Risk: replacing either ? with .unwrap() removes propagation. Running with a missing file then causes a runtime panic, which cargo check cannot catch.
How the mechanism works
The expression fs::read_to_string(path)? has two outcomes:
Ok(text):?evaluates totextand execution continues to the next line.Err(err):?convertserrintoAppErrorusing theFromimplementation and returnsErr(AppError::Io(err))fromload_countimmediately.
The same applies to text.trim().parse()?. Because AppError implements From<ParseIntError>, the parse error is converted automatically. Without those From blocks, the code does not compile: ? requires the error type to be convertible into the function's return error type.
fn main returning Result<(), AppError> is valid because AppError derives Debug. Rust prints the error and exits with code 1 when main returns an Err.
Limits and common mistakes
- Distinct error types do not mix automatically. If a library returns
LibraryErrorand your function returnsAppError,?fails to compile unless you implementFrom<LibraryError> for AppErroror call.map_err(AppError::Other)?explicitly. ?adds no context. It propagates the error value as-is, without source location or a descriptive message. Wrap errors manually or include context variants in your enum when debugging information matters.- A closure using
?must returnResultitself. The enclosing function does not adopt the closure's error type just because the closure uses?. - Errors do not cross thread boundaries with
?. A spawned thread'sResultmust be joined and handled explicitly. unwrapandexpectpanic at runtime. They are acceptable in tests or provably unreachable code, but risky in request handlers or long-running processes.
Comparison: ?, unwrap, and match
| Style | Behavior on Err | When to use |
|---|---|---|
? | Returns early from the function | Most application and library logic |
.unwrap() | Panics with the error value | Tests and unreachable cases |
.expect("msg") | Panics with a message | Tests and unreachable cases where context helps |
match | Handles both branches inline | Custom recovery, retry, or partial success |
Practical verification
- Run
cargo checkwith the example above. - Delete
counter.txtand runcargo run; expect anAppError::Ioprintout and a non-zero exit code. - Change
fs::read_to_string(path)?tofs::read_to_string(path).unwrap(), run with a missing file, and observe a panic. Revert the change before committing.
This pattern scales to larger error enums, but if you find yourself writing many From implementations, consider whether a dedicated error crate fits your project. That choice is outside the standard library examples shown here.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.