The single character that saves your sanity
You are writing a function that reads a configuration file, parses JSON, and connects to a database. In Python, you wrap the logic in a try block or check if config is None after every step. In JavaScript, you check if (!config) return or nest callbacks. Rust gives you a single character that collapses this boilerplate: ?.
The ? operator reads the value if it is present and returns the error immediately if it is not. Your code stays linear. The error handling happens automatically. You write the happy path; the compiler handles the sad path.
What the ? operator actually does
The ? operator is shorthand for error propagation. It works on two types: Result<T, E> and Option<T>.
For Result, ? extracts the value inside Ok. If the result is Err, ? returns that error from the current function immediately. For Option, ? extracts the value inside Some. If the option is None, ? returns None immediately.
Think of ? as a relay baton. You are running the happy path. If you drop the baton (an error occurs), you hand the drop report to the coach (the caller) and stop running. You do not fix the drop. You just report it and exit.
This behavior requires the function to return a compatible type. If you use ? on a Result, the function must return a Result. If you use ? on an Option, the function must return an Option. The compiler enforces this. If you try to use ? in a function that returns (), the compiler rejects the code.
Minimal example
Here is a function that parses a string into an integer and doubles it. The parsing can fail. The ? operator handles the failure without a match statement.
/// Parses a string to i32 and returns double the value.
/// Returns an error if the string is not a valid integer.
fn double_input(input: &str) -> Result<i32, std::num::ParseIntError> {
// ? attempts to unwrap the Ok value.
// If parsing fails, ? returns the ParseIntError immediately.
// The function exits at this line if an error occurs.
let number = input.parse::<i32>()?;
Ok(number * 2)
}
fn main() {
// Success case: prints Ok(42).
println!("{:?}", double_input("21"));
// Failure case: prints Err(ParseIntError).
// The ? operator returned the error from double_input.
println!("{:?}", double_input("not_a_number"));
}
The ? operator turns a nested structure into a flat sequence. You do not need to wrap the multiplication in an Ok manually if the parsing succeeds. The ? extracts the i32, you perform the math, and you wrap the result in Ok.
How the compiler expands ?
The ? operator is not magic. The compiler desugars it into a match expression with error conversion. When you write expr?, the compiler transforms it roughly like this:
match expr {
Ok(val) => val,
Err(e) => return Err(From::from(e)),
}
For Option, the expansion is similar:
match expr {
Some(val) => val,
None => return None,
}
The critical detail is From::from(e). The ? operator does not just return the error as-is. It attempts to convert the error type to the function's return error type using the From trait.
If your function returns Result<T, MyError> and expr produces Result<T, OtherError>, ? calls MyError::from(OtherError). This requires an implementation of impl From<OtherError> for MyError. If this implementation exists, the conversion happens automatically. If it does not exist, the compiler emits E0277 (trait bound not satisfied) telling you that From is not implemented for the error types.
This design allows you to use ? across different error types in the same function. You can chain operations that return io::Error, ParseIntError, and custom errors, provided your return type implements From for all of them.
Realistic example with error conversion
Real applications often combine multiple sources of errors. File I/O produces io::Error. Parsing produces ParseIntError. You usually want a single error type for your application to handle at the top level.
Implement From for each source error to enable ? to convert them automatically.
use std::fs;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
struct Config {
port: u16,
}
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
InvalidPort(String),
}
// ? relies on From for automatic conversion.
// Implement From<io::Error> to allow ? to convert file errors.
impl From<io::Error> for AppError {
fn from(err: io::Error) -> Self {
AppError::Io(err)
}
}
// Implement From<ParseIntError> to allow ? to convert parse errors.
impl From<ParseIntError> for AppError {
fn from(err: ParseIntError) -> Self {
AppError::Parse(err)
}
}
/// Loads configuration from a file and validates the port.
/// Returns AppError if any step fails.
fn load_config() -> Result<Config, AppError> {
// fs::read_to_string returns Result<String, io::Error>.
// ? converts io::Error to AppError::Io via the From impl.
let content = fs::read_to_string("config.txt")?;
// parse returns Result<u16, ParseIntError>.
// ? converts ParseIntError to AppError::Parse via the From impl.
let port = content.trim().parse::<u16>()?;
// Manual validation that returns a custom error variant.
if port == 0 {
return Err(AppError::InvalidPort("Port cannot be zero".to_string()));
}
Ok(Config { port })
}
fn main() {
match load_config() {
Ok(config) => println!("Loaded config: {:?}", config),
Err(e) => println!("Failed: {:?}", e),
}
}
The From implementations keep the load_config function clean. Each ? handles the specific error type of that operation and converts it to AppError. You do not need map_err calls cluttering the logic.
Convention note: The Rust community prefers From implementations for error conversion over map_err. From allows ? to work transparently. Use map_err only when you cannot implement From for the source type or when you need a one-off conversion that does not warrant a trait implementation.
The Try trait and custom types
Since Rust 1.39, the ? operator is implemented via the Try trait. Result and Option both implement Try. This means ? is not hardcoded for these types. You can implement Try for your own types to support ?.
The Try trait defines three methods: from_output, branch, and from_error. When you write ?, the compiler calls Try::branch to determine if the value is success or failure. If it is failure, the compiler calls Try::from_error to convert the error.
This unification is why ? works seamlessly across different error types. Libraries like anyhow and eyre implement Try for their error types, allowing you to use ? with them just like standard Result. If you are building a domain-specific language or a custom monad, implementing Try lets you use ? for control flow.
Pitfalls and compiler errors
The ? operator is powerful, but it has constraints. Understanding these constraints prevents common mistakes.
Using ? on a borrowed Result
The ? operator takes ownership of the Result. You cannot use ? on a reference to a Result. If you try, the compiler emits E0507 (cannot move out of borrowed content).
fn bad_borrow() {
let r: Result<i32, ()> = Ok(42);
// Error: cannot move out of borrowed content.
// ? requires ownership of the Result.
// let x = (&r)?;
// Fix: Use as_ref() to get a Result<&T, &E>, then ? works on the reference.
let x = r.as_ref()?;
}
Use as_ref() when you need to check a Result without consuming it. as_ref() converts Result<T, E> to Result<&T, &E>. The ? operator then works on the references.
Missing From implementation
If you use ? on a Result with an error type that does not convert to the function's return error type, the compiler emits E0277. The error message will say that the trait From<SourceError> is not implemented for TargetError.
Check your error enum. You likely need an impl From<SourceError> for TargetError. If you cannot implement From because you do not own the source type, use map_err before ?.
// When From is not available, map_err bridges the gap.
let value = some_operation().map_err(|e| MyError::from_string(e.to_string()))?;
? in main
The main function can return Result<T, E> where E implements Debug. This allows you to use ? in main. If the function returns an error, main prints the error to stderr and exits with a non-zero status code.
use std::fs;
fn main() -> Result<(), std::io::Error> {
// ? works in main because it returns Result.
// If the file is missing, main prints the error and exits.
let _content = fs::read_to_string("data.txt")?;
Ok(())
}
This pattern is common in CLI tools. It avoids wrapping the entire body of main in a match statement.
Option and None
The ? operator works on Option just like Result. If the option is None, ? returns None immediately. The function must return an Option.
fn find_first_digit(input: &str) -> Option<char> {
// ? returns None immediately if find returns None.
// Otherwise, it extracts the char.
let digit = input.chars().find(|c| c.is_ascii_digit())?;
Some(digit)
}
Use ? on Option when you want to propagate absence up the call stack. It keeps the code linear just like with Result.
When to use ? versus alternatives
Choose the right tool based on how you want to handle errors.
Use ? when you want to propagate errors up the call stack without handling them locally. This is the default choice for library code and business logic where the caller should decide how to recover.
Use match when you need to handle the error differently based on its variant or transform it before returning. Use match when the error requires specific recovery logic, such as retrying an operation or logging a warning.
Use unwrap() when the code is in main or a test and failure should crash the program immediately. Use unwrap() only when you are certain the value cannot be an error. In production code, unwrap() indicates a logic bug if it panics.
Use expect() when you want to panic with a custom message that explains why the value should be valid. Use expect() in tests and examples to provide context for failures. The message helps debug why the assumption was wrong.
Use map_err combined with ? when you need to convert an error type but cannot implement From for the source type. Use map_err for one-off conversions or when the source type is external and you cannot add a trait implementation.
Trust ? to keep your code linear. Reach for match only when the error needs special treatment. The compiler will guide you if you choose wrong.