The error bubble
You are building a command-line tool. It needs to read a configuration file, parse a port number, and connect to a database. In Python, you might wrap the whole thing in a try block and hope the exception tells you what went wrong. In JavaScript, you might chain .catch() handlers or use async/await with a try-catch.
Rust takes a different approach. Errors are values. You define the error type upfront, and the compiler forces you to handle every failure path before the code runs. You cannot ignore an error. You must either handle it locally or propagate it up the call stack.
Propagation is the act of passing an error from a function back to its caller. The caller then decides whether to handle it or pass it further up. This creates a chain of responsibility. The function that knows how to fix the error handles it. Every other function just passes the error along.
Result and the question mark
Rust represents fallible operations with the Result<T, E> enum. It has two variants:
Ok(T)contains the success value.Err(E)contains the error value.
When a function can fail, it returns a Result. The caller receives the Result and must inspect it. Writing manual match statements for every call gets repetitive fast. Rust provides the ? operator to automate propagation.
Think of error propagation like a relay race where the baton is the result. If a runner drops the baton (an error occurs), they don't try to fix it themselves. They immediately stop and pass the "dropped baton" signal to the next station. The current station doesn't care why it dropped, just that it did. The signal travels up until someone decides to catch it and handle it.
The ? operator is the mechanism that passes the baton. It checks the Result. If it is Ok, it extracts the inner value and continues. If it is Err, it returns the error from the current function immediately.
Minimal example
use std::fs;
/// Reads a file and returns its contents as a String.
/// Returns an io::Error if the file cannot be read.
fn read_config(path: &str) -> Result<String, std::io::Error> {
// fs::read_to_string returns Result<String, io::Error>.
// The ? operator extracts the String on Ok.
// If an error occurs, ? returns the io::Error immediately.
let content = fs::read_to_string(path)?;
// This line only runs if fs::read_to_string succeeded.
Ok(content)
}
The ? operator short-circuits the function. If fs::read_to_string returns an error, read_config returns that error right then. The Ok(content) line never executes. This keeps the happy path clean and readable.
Let the compiler handle the control flow. Write the happy path and let ? manage the failures.
How the question mark works
Under the hood, the ? operator expands to a match expression. When you write let x = some_call()?, the compiler generates code roughly equivalent to this:
let x = match some_call() {
Ok(val) => val,
Err(e) => return Err(e),
};
The ? operator does two things. It unwraps the Ok value so you can use it directly. It returns the Err value from the current function if an error occurs.
This behavior relies on the return type of the function. The error type returned by ? must match the error type in the function's Result. If some_call returns Result<T, ErrorA> and your function returns Result<T, ErrorB>, the compiler checks whether ErrorB can be constructed from ErrorA.
This check uses the From trait. If ErrorB implements From<ErrorA>, the compiler automatically converts the error. If not, you get a compilation error.
The ? operator is syntactic sugar that saves you from writing boilerplate. It also enforces type safety by requiring error conversions to be explicit.
The magic of type conversion
The ? operator does more than unwrap values. It performs automatic type conversion using the From trait. This is where Rust's error handling shines. You can define a custom error type and implement From for standard library errors. Then ? will convert those standard errors into your custom type automatically.
This pattern lets you mix different error sources in a single function. File I/O might produce io::Error. Parsing might produce ParseIntError. With From implementations, ? converts both into your application error type seamlessly.
Write the From implementation once. The ? operator will reuse it everywhere, keeping your code clean.
Realistic example: chaining operations
Real code often chains multiple operations that can fail. Each operation might produce a different error type. A custom error enum centralizes these errors. From implementations allow ? to convert them.
use std::fs;
use std::path::Path;
/// Represents application-specific errors.
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
MissingField(String),
}
/// Converts io::Error into AppError::Io.
/// This implementation allows the ? operator to work seamlessly
/// with functions returning io::Error.
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err)
}
}
/// Converts ParseIntError into AppError::Parse.
impl From<std::num::ParseIntError> for AppError {
fn from(err: std::num::ParseIntError) -> Self {
AppError::Parse(err)
}
}
/// Reads a config file and extracts the port number.
/// Uses ? to propagate errors, relying on From implementations
/// to convert different error types into AppError.
fn load_port(path: &Path) -> Result<u16, AppError> {
// fs::read_to_string returns Result<String, io::Error>.
// The ? operator calls AppError::from(io::Error) automatically
// if an error occurs, converting it to AppError::Io.
let content = fs::read_to_string(path)?;
// Find the port line. ok_or converts Option to Result.
// The ? operator propagates the AppError::MissingField.
let line = content
.lines()
.find(|l| l.starts_with("port="))
.ok_or(AppError::MissingField("port".to_string()))?;
// Parse the number. parse returns Result<u16, ParseIntError>.
// The ? operator calls AppError::from(ParseIntError) automatically.
let port = line.trim_start_matches("port=").parse::<u16>()?;
Ok(port)
}
The load_port function chains three fallible operations. Reading the file uses ? and relies on From<io::Error>. Finding the line uses ok_or to convert a missing value into an error. Parsing uses ? and relies on From<ParseIntError>.
The function body reads like a list of steps. Error handling is implicit but guaranteed. The compiler ensures every error is accounted for.
Convention note: In library code, define a custom error enum like AppError. In application code, crates like anyhow provide a generic error type that implements From for almost everything, reducing boilerplate. Choose based on your audience.
Pitfalls and compiler errors
Type mismatch is the most common pitfall. The ? operator requires the error type of the call to convert to the function's return error type. If you forget a From implementation or use map_err incorrectly, the compiler rejects the code.
If you call a function that returns Result<T, ParseIntError> inside a function returning Result<T, io::Error>, you get E0277 (trait bound not satisfied). The compiler tells you that io::Error does not implement From<ParseIntError>. You must convert the error manually using map_err or add a From implementation.
fn bad_example() -> Result<u16, std::io::Error> {
// This fails with E0277.
// parse returns ParseIntError, but the function returns io::Error.
// There is no From<ParseIntError> for io::Error.
let _port = "123".parse::<u16>()?;
Ok(0)
}
Fix this by converting the error:
fn fixed_example() -> Result<u16, std::io::Error> {
// map_err converts ParseIntError to io::Error.
// The ? operator can now propagate the converted error.
let _port = "123"
.parse::<u16>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(0)
}
Another pitfall is using ? in main. The main function has special rules. It can return Result<T, E> where E implements Debug. If main returns an error, the runtime prints the error and exits with a non-zero status. This is useful for quick scripts. For production binaries, consider using a crate like miette for better error reporting.
Check the error type before you reach for ?. If the types don't match, the compiler will stop you with E0277.
Decision matrix
Use ? when you want to propagate errors up the call stack without writing boilerplate match statements. Use unwrap when you are in a test, a quick script, or you have mathematically proven the value cannot be an error, such as a hardcoded constant. Use expect when you need to panic but want to leave a message for the future debugger explaining why this should never fail. Use map_err when the error type from a sub-call doesn't match your function's return type and you need to convert it inline. Use Box<dyn Error> when you are writing a quick prototype or a main function and don't want to define a custom error enum yet. Use a custom error enum when you are building a library and need to expose specific, actionable error variants to your users.
Propagate errors until you have enough context to handle them. Don't guess.