| |

Rust 34 🦀 Propagating Errors with the ? Operator

A function that calls a fallible function must decide what to do with the error. It can handle the error, or it can pass it to its own caller. Handling the error is the match or the unwrap. Passing it is propagation. The ? operator is the shorthand for propagation: it extracts the success value if the result is Ok, and returns the error from the current function if the result is Err. It is the single most important ergonomic feature in Rust’s error handling, and it is what makes the Result usable in long chains of operations.

This chapter covers the ? operator in full. You will learn the syntax, the desugaring into match, the automatic error conversion via the From trait, the use with Option, the restrictions on where it can be used, and the patterns that use it for file I/O, parsing, and network calls. You will also see the try blocks that extend the ? to the cases where the error needs local handling.

Key point: The ? operator is the postfix operator that propagates the error. Applied to a Result<T, E>, it returns T if the result is Ok, and returns Err(e.into()) from the enclosing function if the result is Err. The .into() is the automatic conversion via the From trait. The operator can be used in any function that returns Result or Option, and the error type must be convertible to the function’s error type. The ? is the shorthand for the match that propagates.


Why the ? operator matters

The boilerplate problem. The explicit propagation is verbose. A function that calls three fallible functions and propagates the errors writes three match expressions, each with the Ok binding and the Err return. The ? operator replaces each with a single character. The intent—propagate on error—is visible in the operator, and the code is shorter.

The chaining problem. A chain of fallible operations is natural when the operations compose. The ? operator makes the chain possible without the intermediate matches. The chain reads as a sequence of steps: open the file, read the contents, parse the number, return the result. Each step is a ?, and the error propagates from the first failure.

The conversion problem. The error types differ between the functions. The File::open returns io::Error, the parse returns ParseIntError. The ? operator converts the error to the function’s error type via the From trait. The function’s error type is the unified type, and the ? does the conversion.

The readability problem. A function with three match expressions is harder to read than the same function with three ? operators. The match interleaves the error handling with the logic. The ? separates them: the logic is the sequence of operations, and the error handling is the operator.

The main problem. The ? operator can be used in main when the return type is Result<(), E>. The error propagates to the runtime, which prints it and exits with a non-zero status. The main becomes the top-level handler.

The try block problem. The ? propagates to the enclosing function. When the error should be handled locally, the try block scopes the ? to a block. The block returns a Result, and the error is handled where the block is used.


a. The syntax and the desugaring

The ? operator is a postfix operator. It is applied to an expression that evaluates to a Result or an Option.

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = std::fs::read_to_string(path)?;
    Ok(contents)
}

The std::fs::read_to_string(path)? returns the String if the read succeeds, or returns the io::Error from the function if it fails.

The desugaring is a match.

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    Ok(contents)
}

The ? is the shorthand for the match with the Ok binding and the Err return. The return is Err(e) because the error type is the same as the function’s error type.

The ? can be chained.

fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}

The first ? propagates the io::Error from the read. The second ? propagates the ParseIntError from the parse. The function’s error type is Box<dyn Error>, and the ? converts each error to the boxed type.

The ? on the last expression is optional.

fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}

// Or, the last ? without the Ok
fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}

The last expression is Ok(number). If the parse is the last operation and the result is already a Result, the ? can be the last expression.

fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    contents.trim().parse::<i32>().map_err(Into::into)
}

The map_err(Into::into) converts the error type. The ? is not used because the expression is the return value.

The ? can be used in a method.

struct Config {
    path: String,
}

impl Config {
    fn load(&self) -> Result<String, std::io::Error> {
        let contents = std::fs::read_to_string(&self.path)?;
        Ok(contents)
    }
}

The ? works the same way in a method as in a function. The return type is the Result.

The ? can be used in a closure if the closure returns Result.

let results: Result<Vec<i32>, _> = strings
    .iter()
    .map(|s| s.parse::<i32>())
    .collect();

The closure returns a Result, and the collect gathers them into a single Result. The ? is not used in the closure, but the pattern is the same: the Result propagates through the iterator.


b. The error conversion with From

The ? operator converts the error type with the From trait. The expression expr? is the equivalent of:

match expr {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
}

The From::from(e) converts the error e to the function’s error type. The conversion is automatic when the From implementation exists.

The Box<dyn Error> implements From for any error type that implements Error.

fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;  // io::Error
    let number = contents.trim().parse::<i32>()?;   // ParseIntError
    Ok(number)
}

The io::Error and the ParseIntError both implement Error, and the Box<dyn Error> implements From<E> for E: Error. The ? converts each one.

The custom error type implements From for each error type.

use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let contents = std::fs::read_to_string(path)?;  // converts io::Error
    let number = contents.trim().parse::<i32>()?;   // converts ParseIntError
    Ok(number)
}

The From implementations convert the io::Error and the ParseIntError to the AppError. The ? uses the From to convert each error.

The From implementation can be derived with the thiserror crate.

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] io::Error),
    #[error("Parse error: {0}")]
    Parse(#[from] ParseIntError),
}

The #[from] attribute generates the From implementation. The #[error] attribute generates the Display implementation.

The ? can convert between the error types when the From is implemented in either direction.

impl From<AppError> for Box<dyn std::error::Error> {
    fn from(e: AppError) -> Self {
        Box::new(e)
    }
}

The conversion is the mechanism. The ? is the syntax.


c. The ? with Option and the restrictions

The ? operator works with Option as well as Result.

fn first_word_length(s: &str) -> Option<usize> {
    let word = s.split_whitespace().next()?;
    Some(word.len())
}

The s.split_whitespace().next()? returns the word if the iterator has a next element, or returns None from the function if the iterator is exhausted. The ? on the Option is the shorthand for the match with the None return.

The ? on an Option in a function that returns Result is a type error. The Option and the Result are different types, and the ? does not convert between them.

// ERROR: the `?` on an Option in a Result function
fn f() -> Result<i32, String> {
    let x = Some(42)?; // error
    Ok(x)
}

The fix is to convert the Option to a Result with ok_or or ok_or_else.

fn f() -> Result<i32, String> {
    let x = Some(42).ok_or("missing")?;
    Ok(x)
}

The ok_or("missing") converts the Option to a Result<i32, &str>. The ? propagates the Err if the value is None.

The ? on a Result in a function that returns Option is also a type error.

// ERROR: the `?` on a Result in an Option function
fn f() -> Option<i32> {
    let x = Ok(42)?; // error
    Some(x)
}

The fix is to convert the Result to an Option with ok.

fn f() -> Option<i32> {
    let x = Ok(42).ok()?;
    Some(x)
}

The .ok() converts the Result to an Option. The ? propagates the None if the result is Err.

The ? cannot be used in a function that returns neither Result nor Option.

// ERROR: the `?` in a function that returns ()
fn f() {
    let x = Ok(42)?; // error
}

The ? requires the enclosing function to return Result or Option. The function that returns () cannot propagate the error.

The ? in main requires main to return Result.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let x = std::fs::read_to_string("file.txt")?;
    println!("{}", x);
    Ok(())
}

The main returns Result<(), Box<dyn Error>>. The ? propagates the error to the runtime, which prints it and exits with a non-zero status.

The ? in a try block.

fn f() -> Result<i32, String> {
    let result = try {
        let a = Ok(1)?;
        let b = Ok(2)?;
        a + b
    };
    match result {
        Ok(n) => Ok(n),
        Err(e) => Err(format!("error: {}", e)),
    }
}

The try block scopes the ? to the block. The block returns a Result, and the error is handled where the block is used. The try block is experimental in current Rust, but it is the direction the language is moving.

The let else construct handles the error inline.

fn f() -> Result<i32, String> {
    let Ok(x) = Ok::<i32, String>(42) else {
        return Err("failed".into());
    };
    Ok(x)
}

The let Ok(x) = expr else { ... } extracts the Ok value or runs the else block. The else block must diverge (return, panic, or continue). The let else is the alternative to the ? when the error needs local handling.

The ? can be used with the and_then and the other combinators.

fn f(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number * 2)
}

// Equivalent with combinators
fn g(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    std::fs::read_to_string(path)
        .and_then(|c| c.trim().parse::<i32>().map_err(Into::into))
        .map(|n| n * 2)
}

The ? version is shorter and more readable. The combinator version is the alternative when the chain is more complex.


Complete Example Session

// ============================================
// PART 1: BASIC ?
// ============================================
fn read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = std::fs::read_to_string(path)?;
    Ok(contents)
}
// ============================================
// PART 2: CHAINED ?
// ============================================
fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}
// ============================================
// PART 3: DESUGARING
// ============================================
fn read_file_explicit(path: &str) -> Result<String, std::io::Error> {
    let contents = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    Ok(contents)
}
// ============================================
// PART 4: CUSTOM ERROR WITH From
// ============================================
#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self {
        AppError::Parse(e)
    }
}
// ============================================
// PART 5: ? WITH CUSTOM ERROR
// ============================================
fn read_number(path: &str) -> Result<i32, AppError> {
    let contents = std::fs::read_to_string(path)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}
// ============================================
// PART 6: ? WITH OPTION
// ============================================
fn first_word_length(s: &str) -> Option<usize> {
    let word = s.split_whitespace().next()?;
    Some(word.len())
}
// ============================================
// PART 7: ok_or CONVERSION
// ============================================
fn f() -> Result<i32, String> {
    let x = Some(42).ok_or("missing")?;
    Ok(x)
}
// ============================================
// PART 8: ? IN MAIN
// ============================================
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string("file.txt")?;
    println!("{}", contents);
    Ok(())
}
// ============================================
// PART 9: LET ELSE
// ============================================
fn f() -> Result<i32, String> {
    let Ok(x) = Ok::<i32, String>(42) else {
        return Err("failed".into());
    };
    Ok(x)
}
// ============================================
// PART 10: ? IN A METHOD
// ============================================
struct Config {
    path: String,
}

impl Config {
    fn load(&self) -> Result<String, std::io::Error> {
        let contents = std::fs::read_to_string(&self.path)?;
        Ok(contents)
    }
}

The ten parts covered the basic ?, the chained ?, the desugaring, the custom error with From, the ? with the custom error, the ? with Option, the ok_or conversion, the ? in main, let else, and the ? in a method.


Quick Reference

The ? Operator

ExpressionBehavior
expr? on Ok(v)Returns v
expr? on Err(e)Returns Err(From::from(e))
expr? on Some(v)Returns v
expr? on NoneReturns None

Error Conversion

FromToMechanism
io::ErrorBox<dyn Error>From impl
ParseIntErrorBox<dyn Error>From impl
io::ErrorAppErrorCustom From
Any ErrorBox<dyn Error>Blanket From

Contexts

ContextReturn Type
FunctionResult<T, E> or Option<T>
MethodResult<T, E> or Option<T>
mainResult<(), E>
ClosureResult<T, E> or Option<T>
try blockResult<T, E>

Conversion Methods

MethodPurpose
ok_or(e)Option<T> → Result<T, E>
ok_or_else(f)Option<T> → Result<T, E>
ok()Result<T, E> → Option<T>
map_err(f)Convert the error

Best Practices

✅ Do This:

// Use ? to propagate the errors
let contents = std::fs::read_to_string(path)?;                 // ✅

// Chain the ? for the multiple fallible calls
let n = contents.trim().parse::<i32>()?;                       // ✅

// Use From to unify the error types
impl From<io::Error> for AppError { }                          // ✅

// Use Box<dyn Error> for the simple unification
fn f() -> Result<(), Box<dyn Error>> { }                       // ✅

// Use ok_or to convert Option to Result
let x = some_option.ok_or("missing")?;                         // ✅

// Use let else when the error needs local handling
let Ok(x) = result else { return Err("failed".into()); };      // ✅

// Use ? in main with the Result return
fn main() -> Result<(), Box<dyn Error>> { }                    // ✅

❌ Don’t Do This:

// Don't use ? in a function that does not return Result or Option
fn f() {
    let x = something()?; // error                              // ❌
}

// Don't use ? on an Option in a Result function
fn f() -> Result<i32, String> {
    let x = Some(42)?; // error                                 // ❌
}

// Don't use ? on a Result in an Option function
fn f() -> Option<i32> {
    let x = Ok(42)?; // error                                   // ❌
}

// Don't use unwrap when ? would propagate
let contents = std::fs::read_to_string(path).unwrap();         // ⚠️

// Don't ignore the error conversion
let n = contents.parse::<i32>()?; // if the error type differs  // ⚠️

// Don't use ? in a closure that does not return Result
map(|s| s.parse().unwrap())                                    // ⚠️

// Don't forget the From implementation for the custom error
fn f() -> Result<i32, AppError> {
    let x = io_op()?; // if no From impl, error                  // ❌
}

Common Pitfalls

PitfallWhy It HappensFix
? in () functionNo Result returnReturn Result
Error type mismatchNo From implImplement From
? on Option in ResultDifferent typesUse ok_or
? on Result in OptionDifferent typesUse ok
? in closureClosure returns non-ResultReturn Result
? ignoredResult unusedPropagate or handle
? in main without Resultmain returns ()Return Result

Real-World Examples

1. File Read

let contents = std::fs::read_to_string("file.txt")?;

2. Parse

let n = contents.trim().parse::<i32>()?;

3. Chain

fn f() -> Result<i32, Box<dyn Error>> {
    let c = std::fs::read_to_string("f.txt")?;
    let n = c.trim().parse::<i32>()?;
    Ok(n * 2)
}

4. Custom Error

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}

5. Option to Result

let x = some_option.ok_or("missing")?;

6. Result to Option

let x = some_result.ok()?;

7. Main with Result

fn main() -> Result<(), Box<dyn Error>> { }

8. Let Else

let Ok(x) = result else { return Err("failed".into()); };

9. Method

impl Config {
    fn load(&self) -> Result<String, io::Error> {
        std::fs::read_to_string(&self.path)
    }
}

10. Collect

let numbers: Result<Vec<i32>, _> = strings.iter().map(|s| s.parse()).collect();

Visual

The ? Operator Flow

┌─────────────────────────────────────────────────────────────┐
│  let contents = std::fs::read_to_string(path)?;             │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  read_to_string                                     │    │
│  │    │                                                │    │
│  │    ├── Ok(contents) ──▶ contents is bound           │    │
│  │    │                                                │    │
│  │    └── Err(e) ──▶ return Err(From::from(e))         │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  The ? extracts the Ok or returns the Err.                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Desugaring

┌─────────────────────────────────────────────────────────────┐
│  SHORT FORM                                                 │
│  let x = expr?;                                             │
│                                                             │
│  DESUGARED                                                  │
│  let x = match expr {                                       │
│    Ok(v) => v,                                              │
│    Err(e) => return Err(From::from(e)),                     │
│  };                                                         │
│                                                             │
│  The ? is the shorthand for the match and return.           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Error Conversion

┌─────────────────────────────────────────────────────────────┐
│  fn read_number(path: &str) -> Result<i32, AppError> {      │
│    let c = fs::read_to_string(path)?;  // io::Error         │
│    let n = c.trim().parse::<i32>()?;   // ParseIntError     │
│    Ok(n)                                                    │
│  }                                                          │
│                                                             │
│  The ? converts:                                            │
│    io::Error → AppError via From<io::Error>                 │
│    ParseIntError → AppError via From<ParseIntError>         │
│                                                             │
│  Each ? uses the From implementation.                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

? with Option

┌─────────────────────────────────────────────────────────────┐
│  fn first_word_length(s: &str) -> Option<usize> {           │
│    let word = s.split_whitespace().next()?;                 │
│    Some(word.len())                                         │
│  }                                                          │
│                                                             │
│  The ? on the Option:                                       │
│    Some(word) ──▶ word is bound                             │
│    None ──▶ return None                                     │
│                                                             │
│  The ? works for both Result and Option.                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Operatorexpr?
On OkReturns the value
On ErrReturns Err(From::from(e))
On SomeReturns the value
On NoneReturns None
ContextResult or Option function
ConversionFrom trait
mainResult<(), E>
Option to Resultok_or
Result to Optionok
Local handlinglet else, try block

Key takeaways:

  • The ? operator propagates the error. It extracts the Ok value or returns the Err from the current function. It is the shorthand for the match that propagates.
  • The ? converts the error type with From. The error from the expression is converted to the function’s error type via the From trait. The Box<dyn Error> and the custom From implementations are the mechanisms.
  • The ? works with Option as well as Result. On an Option, the ? returns the Some value or the None from the function. The Option and the Result are not interchangeable; the ok_or and ok convert between them.
  • The ? requires the enclosing function to return Result or Option. The function that returns () cannot use the ?. The main can use the ? when it returns Result<(), E>.
  • The ? is the idiomatic way to propagate. The chain of the ? operators reads as a sequence of steps, and the error propagation is implicit. The match is the explicit form, and the ? is the shorthand.
  • The let else handles the error locally. The let Ok(x) = expr else { ... } extracts the Ok value or runs the else block. The block must diverge.
  • The try block scopes the ?. The try { ... } returns a Result, and the ? inside the block propagates to the block. The error is handled where the block is used. The try block is experimental in current Rust.

Remember: The ? operator is the single most important ergonomic feature in Rust’s error handling. It makes the propagation of the errors the default, and the code reads as the sequence of steps. Use it for the fallible operations, and let the From handle the error conversion. Use the ok_or and ok to convert between the Option and the Result. Use the let else when the error needs local handling. And remember that the ? is the shorthand for the match, and the match is the mechanism. The ? is the syntax that makes the mechanism usable.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!