The ? Operator
Manual Propagation Is Noisy
Section titled “Manual Propagation Is Noisy”Without the ? operator, propagating an error from a called function requires a match expression every time:
fn parse_and_double(s: &str) -> Result<i32, std::num::ParseIntError> { let n = match s.parse::<i32>() { Ok(v) => v, Err(e) => return Err(e), }; Ok(n * 2)}This pattern — unwrap the Ok or return the Err early — is so common that Rust has a dedicated operator for it.
The ? Operator
Section titled “The ? Operator”Placing ? after a Result expression does exactly the same thing as the match above:
- If the value is
Ok(v), it evaluates tov. - If the value is
Err(e), it returnsErr(e)immediately from the enclosing function.
The function must return Result (or Option) for ? to work — the compiler enforces this.
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> { let n: i32 = s.parse()?; // ? returns early on Err Ok(n * 2)}What ? Desugars To
Section titled “What ? Desugars To”? is not magic — it is sugar for a match that either extracts the value or converts the error and returns it. On a Result<T, E> expression inside a function returning Result<T2, E2>, expr? is exactly:
// @skip-verify illustrative desugaring only — `expr` is a placeholder, not real codematch expr { Ok(v) => v, Err(e) => return Err(std::convert::From::from(e)),}Both forms below compile to the same thing — they are here side by side so you can see precisely what the sugar hides:
use std::num::ParseIntError;
// Desugared by handfn parse_and_double_manual(s: &str) -> Result<i32, ParseIntError> { let n: i32 = match s.parse::<i32>() { Ok(v) => v, Err(e) => return Err(std::convert::From::from(e)), }; Ok(n * 2)}
// Using ?fn parse_and_double_sugar(s: &str) -> Result<i32, ParseIntError> { let n: i32 = s.parse()?; Ok(n * 2)}
fn main() { println!("{:?}", parse_and_double_manual("21")); println!("{:?}", parse_and_double_sugar("21"));}flowchart TD
A["expr?"] --> B{"expr is Ok or Err?"}
B -->|"Ok(v)"| C["Evaluates to v,
execution continues"]
B -->|"Err(e)"| D["From::from(e) converts
the error type if needed"]
D --> E["return Err(converted_e)
from the enclosing function"] Chaining ?
Section titled “Chaining ?”Because ? returns the Ok value directly, you can call multiple fallible functions in sequence without nesting:
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> { let n: i32 = s.parse()?; Ok(n * 2)}
fn chain(a: &str, b: &str) -> Result<i32, ParseIntError> { let x = parse_and_double(a)?; let y = parse_and_double(b)?; Ok(x + y)}Each ? either unwraps the success value or short-circuits the whole function. No nested matches, no manual early returns.
What the Compiler Says: ? Needs a Result/Option-Returning Function
Section titled “What the Compiler Says: ? Needs a Result/Option-Returning Function”The desugaring above only type-checks when the enclosing function can hold onto the early return Err(...) — which means it must itself return Result (or Option). Using ? inside a function returning () is a compile error, not a runtime one:
// @expect-error E0277fn main() { let x: Result<i32, String> = Err(String::from("no")); let n = x?; println!("{}", n);}error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`) --> question_unit.rs:3:14 |1 | fn main() { | --------- this function should return `Result` or `Option` to accept `?`2 | let x: Result<i32, String> = Err(String::from("no"));3 | let n = x?; | ^ cannot use the `?` operator in a function that returns `()` |help: consider adding return type |1 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {2 | let x: Result<i32, String> = Err(String::from("no"));3 | let n = x?;4 | println!("{}", n);5 + Ok(()) |
error: aborting due to 1 previous errorThe compiler’s own suggestion is exactly the pattern in the next section: give main a Result return type.
main Returning Result
Section titled “main Returning Result”main can also return Result<(), Box<dyn std::error::Error>>, which lets you use ? inside main itself:
fn main() -> Result<(), Box<dyn std::error::Error>> { let n: i32 = "42".parse()?; println!("parsed: {}", n); Ok(())}Box<dyn std::error::Error> is a trait object that can hold any error type, making it useful in main where you do not want to fix a single concrete error type.
The Real Exit Code and Output Format
Section titled “The Real Exit Code and Output Format”When main returns Err(e), the Rust runtime prints Error: {:?} (the Debug formatting of e) to stderr and exits with status 1 — no panic, no backtrace by default, just that one line:
// @expect-panic exits with status 1, which the harness treats like a non-zero-exit demofn main() -> Result<(), Box<dyn std::error::Error>> { let n: i32 = "abc".parse()?; println!("parsed: {}", n); Ok(())}$ rustc --edition 2024 main_parse.rs -o main_parse && ./main_parse; echo "exit=$?"Error: ParseIntError { kind: InvalidDigit }exit=1Note what did not happen: println!("parsed: {}", n) never ran (the ? returned early), and there is no thread 'main' panicked at ... line — this is a controlled, non-panicking termination. Contrast this with std::process::exit, covered in the Panics lesson: unlike an explicit process::exit call, returning Err from main is a normal function return, so any value still in scope in main is dropped exactly as it would be on any other return:
// @expect-panic exits with status 1 (Err from main), no panic message involveduse std::error::Error;use std::fmt;
#[derive(Debug)]struct Boom;impl fmt::Display for Boom { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "boom") }}impl Error for Boom {}
struct Guard;impl Drop for Guard { fn drop(&mut self) { println!("dropping guard"); }}
fn might_fail() -> Result<(), Boom> { Err(Boom)}
fn main() -> Result<(), Box<dyn Error>> { let _g = Guard; might_fail()?; Ok(())}$ rustc --edition 2024 main_drop.rs -o main_drop && ./main_drop; echo "exit=$?"dropping guardError: Boomexit=1dropping guard prints before Error: Boom — the Guard’s destructor ran during the normal unwind-free return out of main, the exact opposite of the std::process::exit/panic = "abort" behaviour where destructors are skipped entirely.
? on Option
Section titled “? on Option”? works the same way inside a function returning Option<T>: Some(v) unwraps to v, None returns None immediately from the enclosing function. There is no error type to convert, so no From call is involved:
fn first_char_upper(s: &str) -> Option<char> { let c = s.chars().next()?; // returns None early if s is empty Some(c.to_ascii_uppercase())}
fn main() { println!("{:?}", first_char_upper("hello")); println!("{:?}", first_char_upper(""));}? cannot mix the two: you cannot use it on a Result inside a function returning Option, or vice versa, without an explicit conversion first (.ok() for Result -> Option, .ok_or(...) for Option -> Result — both covered in the Result lesson).
Error Type Conversion with ?
Section titled “Error Type Conversion with ?”When the error type returned by the called function differs from the error type of the enclosing function, ? automatically calls From::from to convert — this is the From::from(e) step in the desugaring shown earlier. This is how a function returning MyError can use ? on a call that returns ParseIntError, as long as From<ParseIntError> for MyError is implemented.
What the Compiler Says: No From Impl
Section titled “What the Compiler Says: No From Impl”If the target error type has no From implementation for the source error, ? fails to compile — with a message that names the exact trait you are missing:
// @expect-error E0277use std::num::ParseIntError;
#[derive(Debug)]struct MyError(String);
fn inner(s: &str) -> Result<i32, ParseIntError> { s.parse()}
fn outer(s: &str) -> Result<i32, MyError> { let n = inner(s)?; Ok(n)}
fn main() { match outer("abc") { Ok(n) => println!("{}", n), Err(e) => println!("{:?}", e), }}error[E0277]: `?` couldn't convert the error to `MyError` --> question_mismatch.rs:11:21 |10 | fn outer(s: &str) -> Result<i32, MyError> { | -------------------- expected `MyError` because of this11 | let n = inner(s)?; | --------^ the trait `From<ParseIntError>` is not implemented for `MyError` | | | this can't be annotated with `?` because it has type `Result<_, ParseIntError>` |note: `MyError` needs to implement `From<ParseIntError>` --> question_mismatch.rs:4:1 | 4 | struct MyError(String); | ^^^^^^^^^^^^^^ = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
error: aborting due to 1 previous errorThe fix is exactly what the note says — implement the missing conversion once, and every ? call site benefits automatically:
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> { let n: i32 = s.parse()?; // ? returns early on Err Ok(n * 2)}
fn chain(a: &str, b: &str) -> Result<i32, ParseIntError> { let x = parse_and_double(a)?; let y = parse_and_double(b)?; Ok(x + y)}
fn main() -> Result<(), Box<dyn std::error::Error>> { let doubled = parse_and_double("21")?; println!("doubled: {}", doubled);
let sum = chain("10", "5")?; println!("chain(10, 5): {}", sum);
// Demonstrate early return on error match parse_and_double("abc") { Ok(v) => println!("ok: {}", v), Err(e) => println!("early return caught: {}", e), }
println!("main returned Ok(())"); Ok(())}Compiling…
Mental Model
Section titled “Mental Model”Read expr? as “assume this succeeded, and if it did not, this whole function is over — hand the failure to whoever called me, converted into my own error currency along the way.” The From::from step is the important, easy-to-miss part: it means the caller’s error type is the one that matters at every ? site, not the callee’s, and a single From impl per error-type pair is enough to make every future ? call between those two types just work.
Trade-offs
Section titled “Trade-offs”? makes the happy path of a function read top-to-bottom with no nesting, which is a large readability win once a function calls more than one or two fallible operations. Its cost is that the actual control flow — an early return — is compressed into a single punctuation character, which is easy to skim past when reading unfamiliar code, and it requires every intermediate error type in a call chain to either already be From-convertible into the function’s declared error type or be erased into Box<dyn Error>. Reaching for Box<dyn Error> to sidestep writing From impls trades that one-time cost for a real, ongoing one: callers of your function lose the ability to match on the specific failure (covered in the Custom Errors lesson).
Common Misconceptions
Section titled “Common Misconceptions”- “
?only works withResult.” It works with any type implementingFromResidual— in practice,Result<T, E>andOption<T>, but never a mix of the two in a single call without converting first. - “
?swallows the error if the types don’t match.” The opposite — a type mismatch with noFromimpl is a compile error (E0277), not a silent runtime failure. Rust would rather refuse to build than guess how to convert your error. - “Returning
Errfrommainis basically the same aspanic!.” It is not — there is no unwinding table walked, nothread 'main' panicked at ...line, and (as shown above) local variables are dropped through a completely ordinary function return, not through a panic’s unwind machinery. The only things they share are “the program stops” and a nonzero-ish exit status.
Reading AI-generated code
Section titled “Reading AI-generated code”AI assistants sometimes erase a specific, actionable error into Box<dyn Error> via ? one level too early — inside a function whose caller genuinely needs to distinguish which failure occurred, not just that failure occurred at all.
🐛 Spot the bug
fn load_user(id: u32) -> Result<String, Box<dyn std::error::Error>> { let raw = std::fs::read_to_string(format!("users/{id}.json"))?; Ok(raw)}
fn handle_request(id: u32) { match load_user(id) { Ok(user) => println!("loaded: {}", user), Err(_) => println!("something went wrong"), // cannot tell "not found" from "disk full" }}Show the answer
load_user erases every possible failure — a missing file, a permissions error, a full disk — into the same opaque Box<dyn Error>, and handle_request cannot recover which one happened without downcasting (the awkward pattern covered in the Custom Errors lesson). Since this function has exactly one caller in this codebase and the caller clearly wants to distinguish “no such user” from “something else broke,” a small enum LoadUserError { NotFound(u32), Io(std::io::Error) } with a From<std::io::Error> impl lets ? keep working and lets handle_request match on the real cause.