The ? operator (concept angle)Concept
Explanation
? is control-flow sugar for early-return propagation: applied to a
Result or Option, it either unwraps the
success value and lets execution continue, or immediately returns from
the enclosing function with the failure — converting an Err's error
type into the function's own error type along the way, if a conversion is
available. This page is about when and why to reach for that
propagation shape, not the token's exact grammar or every place it's
legal to write — that's the syntax page for ? itself.
It exists to eliminate the boilerplate manual Result handling produces.
Before ?, propagating a failure meant writing out
match some_call() { Ok(v) => v, Err(e) => return Err(e.into()) } at
every single fallible step of a function. ? collapses that whole
pattern into one character placed after the call, so a function with five
sequential fallible steps reads as five lines instead of five nested (or
repeated) match blocks.
The mental model worth keeping is "unwrap and return early, on my
caller's behalf." Using ? on a call is a function saying: if this
particular step fails, I have nothing more useful to do than hand the
failure up to whoever called me, possibly with light reshaping via a
From conversion, but without deciding what should happen about it. Code
that instead wants to inspect, recover from, or react differently to a
failure reaches for match or a combinator like .unwrap_or() instead —
? is specifically for the "not my job to handle this" case.
? requires the enclosing function's return type to be compatible with
what it's applied to: a Result<T, E> expression needs an enclosing
Result<_, F> where E: Into<F>, and an Option<T> expression needs an
enclosing Option<_>. That Into/From conversion is exactly why
well-designed custom error types implement
From<UnderlyingError> — often generated by thiserror's #[from] — so
that ? can silently fold, say, a std::io::Error into an application's
own error enum at the point it's propagated.
Because ? only ever appears on a fallible expression, it is the
mechanism that makes Result and Option
pleasant to chain in practice rather than merely possible in theory — a
function built from several fallible steps can be written as a flat,
readable sequence instead of a staircase of nested matches, while the
type system still guarantees every failure is accounted for somewhere.
Basic usage example
fn read_port_from_env() -> Result<u16, std::num::ParseIntError> {
let raw = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let port: u16 = raw.parse()?; // <- returns early with Err if parsing fails, else unwraps to u16
Ok(port)
}
Best practices & deeper information
Handling and propagating errors
Loading application config from disk chains multiple fallible steps —
reading the file, then parsing a field out of it — and ? lets each
step propagate without a match at every line, converting each
underlying error into one shared ConfigError along the way.
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl From<std::io::Error> for ConfigError { // <- enables `?` to convert io::Error into ConfigError automatically
fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self { ConfigError::Parse(e) }
}
fn load_max_connections(path: &str) -> Result<u32, ConfigError> {
let contents = fs::read_to_string(path)?; // <- `?`: propagate io::Error, converted into ConfigError
let max_connections: u32 = contents.trim().parse()?; // <- `?`: propagate ParseIntError, converted too
Ok(max_connections)
}
Why this way: chaining ? across steps that fail with different
underlying error types works because ? calls From::from on the error
automatically, per the
Rust Book's error propagation chapter —
this is the standard shape for a multi-step fallible pipeline.
Validating input
Validating a signup request rejects the first missing required field it
finds; using ? on Option::ok_or_else turns "value is missing" into an
early return, giving Option the same fail-fast shape ? gives Result.
struct SignupRequest {
email: Option<String>,
age: Option<u8>,
}
#[derive(Debug)]
struct ValidationError(String);
fn validate(request: &SignupRequest) -> Result<(), ValidationError> {
let email = request.email.as_ref()
.ok_or_else(|| ValidationError("email is required".into()))?; // <- `?` on a converted Option: exit early if missing
let age = request.age
.ok_or_else(|| ValidationError("age is required".into()))?;
if age < 18 {
return Err(ValidationError("must be 18 or older".into()));
}
println!("validated {email}, age {age}");
Ok(())
}
Why this way: ok_or_else plus ? lets a validation chain read as a
flat sequence of checks instead of nested if let blocks, each one
exiting immediately on the first problem — the same fail-fast shape the
Rust Book's ?-operator section
demonstrates on Result.
Testing
A test can use ? instead of unwrap() at every step, by returning
Result<(), E> from the test function itself, so a failing step reports
a real error instead of a bare panic message.
use std::num::ParseIntError;
fn parse_config_line(line: &str) -> Result<(String, i64), ParseIntError> {
let (key, value) = line.split_once('=').expect("malformed line");
let parsed_value: i64 = value.trim().parse()?; // <- `?` inside an ordinary function
Ok((key.trim().to_string(), parsed_value))
}
#[test]
fn parses_retry_count() -> Result<(), ParseIntError> { // <- test fn returns Result, so `?` works here too
let (key, value) = parse_config_line("retries = 3")?;
assert_eq!(key, "retries");
assert_eq!(value, 3);
Ok(())
}
Why this way: returning Result<(), E> from a #[test] function
lets it use ? for setup steps that can fail, producing a real Err
message on failure instead of an unwrap panic with less context — a
pattern the
std docs' testing chapter
explicitly supports for #[test] functions.
Designing a public API
A public function accepting user-supplied text returns one
library-specific error type; internally it uses ? to fold together
several fallible steps that raise different underlying error types, but
the boundary itself only ever exposes the one.
use std::str::FromStr;
pub struct OrderId(u32);
#[derive(Debug)]
pub enum ParseOrderIdError {
Empty,
NotNumeric(std::num::ParseIntError),
}
impl From<std::num::ParseIntError> for ParseOrderIdError {
fn from(e: std::num::ParseIntError) -> Self { ParseOrderIdError::NotNumeric(e) }
}
impl FromStr for OrderId {
type Err = ParseOrderIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Err(ParseOrderIdError::Empty);
}
let id: u32 = s.parse()?; // <- `?` keeps FromStr's public error type as the only one callers see
Ok(OrderId(id))
}
}
Why this way: implementing FromStr gives callers the standard
"42".parse::<OrderId>() entry point, and pairing it with ? and a
From impl keeps the public Err type as the one thing callers need to
match on, regardless of how many internal fallible steps are involved —
the
API Guidelines' conversions section
recommends FromStr for exactly this kind of textual parsing API.