Error Handling with the ? Operator
Propagating errors up a call chain concisely, instead of matching on every Result by hand.
3 min read
The previous lesson covered Result<T, E> and matching on it by hand. In real code, you often want to try something, and if it fails, immediately hand the error up to whoever called your function — without writing a match at every single step. That's exactly what the ? operator does.
The problem it solves
Without ?, propagating an error through several fallible steps means a match at each one:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let file_result = File::open("username.txt");
let mut file = match file_result {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}Every step is the same shape: if Ok, keep going with the value; if Err, return it immediately. That pattern is common enough that Rust gives it dedicated syntax.
The same function with ?
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("username.txt")?;
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username)
}expr? unwraps expr if it's Ok(value), giving you value to keep working with — or, if it's Err(e), immediately returns Err(e) from the enclosing function, no match required. It's a straight line through the success path, with the error path handled implicitly.
? requires a matching return type
? can only be used inside a function whose return type is compatible with the error it might propagate — typically Result<T, E> (or Option<T> for ? on an Option). This won't compile:
fn main() {
let file = File::open("username.txt")?; // error: `main()` doesn't return Result
}main can, however, be given a Result return type itself, which lets you use ? right at the top level:
use std::fs::File;
fn main() -> Result<(), std::io::Error> {
let _file = File::open("username.txt")?;
Ok(())
}Chaining ? across multiple calls
The real value shows up once several fallible operations happen in sequence:
fn get_config_value() -> Result<i32, std::num::ParseIntError> {
let raw = std::env::var("PORT").unwrap_or_else(|_| String::from("8080"));
let port: i32 = raw.parse()?;
Ok(port)
}Each ? is a single-line guard: succeed and continue, or bail out immediately with the underlying error. Compare this to the equivalent in a language using exceptions — ? gives you the same "stop on first failure" behavior, but it's visible directly in the function's return type and at every call site, rather than an invisible control-flow path that can jump out of a try block from anywhere.
Converting between error types
Real programs often call functions that return different error types from the same function. ? automatically converts the error using the From trait, as long as a conversion exists — commonly handled by defining one shared application error type and implementing From for each underlying error, or by using a crate like anyhow for quick prototypes and thiserror for structured, library-quality errors. That's a detail worth knowing exists, even before you need to reach for it.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.