R
RUSTY YELLOW PAGES

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

Scenario

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.

Scenario

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.

Scenario

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.

Scenario

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.

Explanation

Embedded support: Full

On bare metal there's no OS-level try/catch to fall back on even in spirit — no unwinding runtime underneath most #![no_std] builds (see Panic & unwinding), so panic! is reserved for conditions the firmware truly cannot continue past, and ?-based Result chains become the only realistic way to propagate an ordinary, expected failure — a bus that didn't ACK, a sensor that timed out — back up to a caller who can actually decide what to do about it: retry, fall back to a cached value, or escalate to a fault state. The ? syntax page's embedded section covers the mechanics — same desugaring, same zero-cost core::result/ core::option operands, no allocator involved — this page is about why that shape fits embedded code particularly well: a driver function built from several dependent hardware transactions (power on a sensor, wait for a ready bit, read the result) reads as a flat sequence of ?-suffixed calls, each one exiting immediately the moment a step the rest of the sequence depends on fails, with no match boilerplate at each line and, critically, no hidden path where a step's failure is silently read as its success.

Basic usage example

use embedded_hal::i2c::I2c;

#[derive(Debug)]
struct SensorError;

fn configure(i2c: &mut impl I2c, addr: u8) -> Result<(), SensorError> {
    i2c.write(addr, &[0x20, 0x01]).map_err(|_| SensorError) // <- enable the sensor
}

fn read_value(i2c: &mut impl I2c, addr: u8) -> Result<u16, SensorError> {
    let mut buf = [0u8; 2];
    i2c.write_read(addr, &[0x28], &mut buf).map_err(|_| SensorError)?;
    Ok(u16::from_be_bytes(buf))
}

fn read_configured_value(i2c: &mut impl I2c, addr: u8) -> Result<u16, SensorError> {
    configure(i2c, addr)?; // <- exits immediately if enabling the sensor fails
    read_value(i2c, addr)  // <- last expression: its own Result is returned as-is
}

Best practices & deeper information

Scenario

Handling and propagating errors

Reading a calibrated sensor value chains three independent hardware steps — waking the sensor, waiting for it to report ready, then reading the result — and ? exits at the first one that fails instead of reading a result from a sensor that was never actually ready.

use embedded_hal::i2c::I2c;

#[derive(Debug)]
enum SensorError {
    Bus,
    NotReady,
}

fn wake(i2c: &mut impl I2c, addr: u8) -> Result<(), SensorError> {
    i2c.write(addr, &[0x10, 0x01]).map_err(|_| SensorError::Bus) // <- power-on command
}

fn wait_ready(i2c: &mut impl I2c, addr: u8) -> Result<(), SensorError> {
    let mut status = [0u8; 1];
    i2c.write_read(addr, &[0x00], &mut status).map_err(|_| SensorError::Bus)?;
    if status[0] & 0x01 == 0 {
        return Err(SensorError::NotReady);
    }
    Ok(())
}

fn read_measurement(i2c: &mut impl I2c, addr: u8) -> Result<u16, SensorError> {
    wake(i2c, addr)?;       // <- exits here if the power-on write fails
    wait_ready(i2c, addr)?; // <- exits here if the status read fails, or the sensor isn't ready yet
    let mut buf = [0u8; 2];
    i2c.write_read(addr, &[0x28], &mut buf).map_err(|_| SensorError::Bus)?;
    Ok(u16::from_be_bytes(buf))
}

Why this way: each ? stops the sequence the instant a step the rest depends on fails, so read_measurement can never reach the final register read after a wake or readiness step actually failed — the same fail-fast shape the classic page's load_max_connections example gives a config-loading pipeline, applied here to a hardware transaction sequence instead of file I/O.

Scenario

Validating input

Reading a device's operating mode out of a small on-flash config block rejects a missing field before it's used, using ? on Option::ok_or exactly as the classic page does for a signup form — just with the form replaced by a byte read out of flash.

struct DeviceConfig<'a> {
    mode_byte: Option<&'a u8>,
}

#[derive(Debug)]
struct ConfigError(&'static str);

fn read_mode(config: &DeviceConfig) -> Result<u8, ConfigError> {
    let mode = config.mode_byte
        .ok_or(ConfigError("mode byte missing from config block"))?; // <- exits early if the field was never written
    Ok(*mode)
}

Why this way: ? on ok_or/ok_or_else gives Option the same fail-fast propagation shape ? gives Result, so a config reader for a flash-backed struct reads as flat checks instead of nested if lets — the same idiom as the classic page's validate example, applied to firmware config instead of a signup form.