The ? Operator in Rust: Error Propagation Demystified

Master the ? operator in Rust. Learn how it works, when to use it, how it converts between error types, and why it's more than just syntactic sugar for error propagation.

📚 Series: Rust Error Handling

  1. Rust Error Handling: thiserror, anyhow, and error-envelope
  2. The ? Operator in Rust: Error Propagation Demystified (current)

You see ? everywhere in Rust code. One character that somehow handles errors, converts types, and returns early from functions.

Here’s what the ? operator actually does–and why it’s more powerful than it looks.

The Problem

Without ?, error handling in Rust requires explicit matching:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fn read_username_from_file() -> Result<String, std::io::Error> {
    let file_result = File::open("username.txt");
    
    let mut file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e),
    };
    
    let mut username = String::new();
    
    let read_result = file.read_to_string(&mut username);
    
    match read_result {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

16 lines for two error checks. Every Result needs explicit handling.

The Solution

The ? operator does the match for you:

1
2
3
4
5
6
fn read_username_from_file() -> Result<String, std::io::Error> {
    let mut file = File::open("username.txt")?;
    let mut username = String::new();
    file.read_to_string(&mut username)?;
    Ok(username)
}

5 lines. Same behavior, dramatically less noise.

What ? Actually Does

The ? operator is syntactic sugar for this pattern:

1
2
3
4
5
6
7
8
// This code:
let result = some_function()?;

// Expands to:
let result = match some_function() {
    Ok(value) => value,
    Err(error) => return Err(error.into()),
};

Three operations in one character:

  1. Unwrap on success - Extract the value from Ok(value)
  2. Early return on error - Return from the enclosing function if Err
  3. Type conversion - Call .into() to convert error types
Key Insight: The ? operator doesn’t just unwrap–it also converts error types using the From trait. This is why you can use ? with functions returning different error types in the same function.

The Three Faces of ?

The ? operator works differently depending on context:

flowchart TB subgraph result["Result Type"] result_ok["Ok(value)"] result_err["Err(error)"] end subgraph option["Option Type"] option_some["Some(value)"] option_none["None"] end subgraph action["? Operator Action"] unwrap["Returns value"] early_return["Early return: Err(error.into())"] early_none["Early return: None"] end result_ok --> unwrap result_err --> early_return option_some --> unwrap option_none --> early_none style result fill:#3A4A5C,stroke:#6b7280,color:#f0f0f0 style option fill:#3A4C43,stroke:#6b7280,color:#f0f0f0 style action fill:#4C4538,stroke:#6b7280,color:#f0f0f0

Face 1: Result<T, E>

Most common usage–propagate errors:

1
2
3
4
5
6
7
fn calculate_total(invoice_id: u64) -> Result<f64, InvoiceError> {
    let invoice = fetch_invoice(invoice_id)?;  // Returns Result<Invoice, InvoiceError>
    let items = get_line_items(invoice.id)?;   // Returns Result<Vec<Item>, InvoiceError>
    
    let total = items.iter().map(|i| i.price).sum();
    Ok(total)
}

If either function returns Err, the ? immediately returns that error from calculate_total.

Face 2: Option

Works with Option in functions returning Option:

1
2
3
4
5
6
7
8
9
fn get_first_active_user(users: &[User]) -> Option<&User> {
    let active_users = users.iter().filter(|u| u.active);
    active_users.next()  // Returns Option<&User>
}

fn get_email_of_first_active(users: &[User]) -> Option<String> {
    let user = get_first_active_user(users)?;  // Returns None if no active user
    Some(user.email.clone())
}

If get_first_active_user returns None, the ? returns None from get_email_of_first_active.

Face 3: Mixed (via From trait)

Convert between compatible types:

1
2
3
4
5
6
use std::num::ParseIntError;

fn parse_user_id(input: &str) -> Result<u64, Box<dyn std::error::Error>> {
    let id: u64 = input.parse()?;  // ParseIntError → Box<dyn Error>
    Ok(id)
}

The ? converts ParseIntError to Box<dyn std::error::Error> automatically because ParseIntError implements From.


How Type Conversion Works

The magic happens through the From trait:

1
2
3
4
5
6
7
8
9
// When you write:
let file = File::open("data.txt")?;

// Rust looks for:
impl From<std::io::Error> for YourErrorType {
    fn from(err: std::io::Error) -> Self {
        // Conversion logic
    }
}

Example: Custom Error with From

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::io;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("IO error")]
    Io(#[from] io::Error),  // thiserror generates From impl
    
    #[error("Parse error")]
    Parse(#[from] ParseIntError),  // thiserror generates From impl
}

fn load_config() -> Result<Config, ConfigError> {
    // io::Error → ConfigError (automatic via From)
    let contents = std::fs::read_to_string("config.txt")?;
    
    // ParseIntError → ConfigError (automatic via From)
    let port: u16 = contents.trim().parse()?;
    
    Ok(Config { port })
}

The #[from] attribute tells thiserror to generate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> Self {
        ConfigError::Io(err)
    }
}

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

Now ? works seamlessly with both error types.


Common Patterns

Pattern 1: Chain Multiple Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn process_file(path: &str) -> Result<Summary, FileError> {
    let contents = std::fs::read_to_string(path)?;
    let lines: Vec<&str> = contents.lines().collect();
    let count = lines.len();
    let first_line = lines.first().ok_or(FileError::Empty)?;
    
    Ok(Summary {
        line_count: count,
        first_line: first_line.to_string(),
    })
}

Each ? checks for errors. If any fail, the function returns early.

Pattern 2: Convert Option to Result

1
2
3
4
5
6
fn find_user_by_id(id: u64) -> Result<User, UserError> {
    let user = database.get(id)
        .ok_or(UserError::NotFound(id))?;  // Option<User> → Result<User, UserError>
    
    Ok(user)
}

.ok_or() converts Option to Result, then ? propagates the error.

Pattern 3: Nested Results

1
2
3
4
5
6
7
fn parse_and_validate(input: &str) -> Result<ValidatedData, ValidationError> {
    // First ?: Propagate parse error
    // Second ?: Propagate validation error
    let parsed = serde_json::from_str::<RawData>(input)?;
    let validated = validate_data(parsed)?;
    Ok(validated)
}

Pattern 4: Map Before Propagating

1
2
3
4
5
6
fn get_user_age(user_id: u64) -> Result<u32, AppError> {
    let user = fetch_user(user_id)
        .map_err(|e| AppError::Database(e.to_string()))?;  // Transform error before ?
    
    Ok(user.age)
}

.map_err() transforms the error, then ? propagates the new error type.

Pattern 5: Multiple Error Types with anyhow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use anyhow::{Context, Result};

fn load_user_profile(user_id: u64) -> Result<Profile> {
    let db_row = sqlx::query("SELECT * FROM users WHERE id = ?")
        .fetch_one(&pool)
        .await
        .context(format!("Failed to fetch user {}", user_id))?;  // sqlx::Error → anyhow::Error
    
    let avatar_data = std::fs::read(&db_row.avatar_path)
        .context("Failed to read avatar file")?;  // io::Error → anyhow::Error
    
    Ok(Profile { db_row, avatar_data })
}

anyhow::Error accepts any error via ?, no manual conversion needed.


When ? Doesn’t Work

The ? operator has constraints:

Constraint 1: Return Type Mismatch

1
2
3
4
// This FAILS to compile:
fn main() {
    let contents = std::fs::read_to_string("file.txt")?;  // ERROR: main returns (), not Result
}

Fix: Change main to return Result:

1
2
3
4
5
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string("file.txt")?;  // Works now
    println!("{}", contents);
    Ok(())
}

Constraint 2: Mixing Result and Option

1
2
3
4
5
// This FAILS to compile:
fn get_config_value(key: &str) -> Result<String, ConfigError> {
    let value = config_map.get(key)?;  // ERROR: Returns Option, but function returns Result
    Ok(value.clone())
}

Fix: Convert Option to Result:

1
2
3
4
5
fn get_config_value(key: &str) -> Result<String, ConfigError> {
    let value = config_map.get(key)
        .ok_or(ConfigError::MissingKey(key.to_string()))?;  // Option → Result
    Ok(value.clone())
}

Constraint 3: No From Implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#[derive(Debug)]
struct MyError;

#[derive(Debug)]
struct OtherError;

// This FAILS to compile:
fn process() -> Result<(), MyError> {
    some_function()?  // ERROR: Returns Result<(), OtherError>, no From<OtherError> for MyError
}

Fix: Implement From or use .map_err():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Option 1: Implement From
impl From<OtherError> for MyError {
    fn from(_: OtherError) -> Self {
        MyError
    }
}

// Option 2: Transform error explicitly
fn process() -> Result<(), MyError> {
    some_function().map_err(|_| MyError)?;  // Converts OtherError → MyError
    Ok(())
}

The ? Operator vs match

Let’s see the difference side-by-side:

With match (verbose):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fn get_user_email(user_id: u64) -> Result<String, UserError> {
    let user = match fetch_user(user_id) {
        Ok(u) => u,
        Err(e) => return Err(UserError::from(e)),
    };
    
    let profile = match fetch_profile(user.profile_id) {
        Ok(p) => p,
        Err(e) => return Err(UserError::from(e)),
    };
    
    match profile.email {
        Some(email) => Ok(email),
        None => Err(UserError::MissingEmail),
    }
}

24 lines with explicit error handling everywhere.

With ? (concise):

1
2
3
4
5
fn get_user_email(user_id: u64) -> Result<String, UserError> {
    let user = fetch_user(user_id)?;
    let profile = fetch_profile(user.profile_id)?;
    profile.email.ok_or(UserError::MissingEmail)
}

5 lines. Same behavior, 80% less code.

When to Use match Instead

Use match when you need different handling per error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fn process_payment(amount: f64) -> Result<Receipt, PaymentError> {
    match charge_card(amount) {
        Ok(receipt) => Ok(receipt),
        Err(PaymentError::InsufficientFunds { needed, available }) => {
            // Special handling for this specific error
            log::warn!("Insufficient funds: need ${}, have ${}", needed, available);
            Err(PaymentError::InsufficientFunds { needed, available })
        }
        Err(PaymentError::Timeout) => {
            // Retry on timeout
            log::info!("Payment timeout, retrying...");
            charge_card(amount)  // Retry once
        }
        Err(e) => Err(e),  // Propagate other errors
    }
}

Here, ? wouldn’t work because we need custom logic for specific errors.


Advanced: The Try Trait

Under the hood, ? uses the Try trait (unstable as of Rust 1.75):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Simplified version of what ? does:
trait Try {
    type Output;
    type Residual;
    
    fn branch(self) -> ControlFlow<Self::Residual, Self::Output>;
}

// Result implements Try:
impl<T, E> Try for Result<T, E> {
    type Output = T;
    type Residual = Result<Infallible, E>;
    
    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        match self {
            Ok(v) => ControlFlow::Continue(v),
            Err(e) => ControlFlow::Break(Err(e)),
        }
    }
}

When you write let x = foo()?;, Rust calls foo().branch() and checks the ControlFlow:

  • Continue(value) → Assign value to x
  • Break(error) → Return error from the function

This is why ? can work with custom types–they just need to implement Try.


Error Flow Visualization

Here’s how errors propagate through a call stack:

sequenceDiagram participant Main participant Handler as handler() participant Service as service() participant DB as database() Main->>Handler: Call handler() Handler->>Service: Call service()? Service->>DB: Call database()? alt Database Error DB-->>Service: Err(DbError) Note over Service: ? converts and returns Service-->>Handler: Err(ServiceError) Note over Handler: ? converts and returns Handler-->>Main: Err(HandlerError) Note over Main: Handle error else Success DB-->>Service: Ok(data) Note over Service: ? unwraps to data Service-->>Handler: Ok(processed) Note over Handler: ? unwraps to processed Handler-->>Main: Ok(result) end

Each ? does two things:

  1. Success path: Unwrap the value and continue
  2. Error path: Convert error type and return early

Real-World Example: HTTP Handler

Here’s how ? simplifies a typical web handler:

Without ? (explicit):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
async fn get_user_handler(user_id: u64) -> Result<Json<User>, (StatusCode, String)> {
    let user_result = fetch_user(user_id).await;
    
    let user = match user_result {
        Ok(u) => u,
        Err(e) => {
            return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
        }
    };
    
    let profile_result = fetch_profile(user.profile_id).await;
    
    let profile = match profile_result {
        Ok(p) => p,
        Err(e) => {
            return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
        }
    };
    
    let avatar_result = load_avatar(&profile.avatar_path).await;
    
    let avatar = match avatar_result {
        Ok(a) => a,
        Err(e) => {
            return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
        }
    };
    
    Ok(Json(User { profile, avatar }))
}

30+ lines with repetitive error handling.

With ? (concise):

1
2
3
4
5
6
7
8
9
use error_envelope::Error;

async fn get_user_handler(user_id: u64) -> Result<Json<User>, Error> {
    let user = fetch_user(user_id).await?;
    let profile = fetch_profile(user.profile_id).await?;
    let avatar = load_avatar(&profile.avatar_path).await?;
    
    Ok(Json(User { profile, avatar }))
}

7 lines. Each ? automatically converts errors to Error type and returns early if needed.


Comparison: Different Error Handling Approaches

ApproachLines of CodeType SafetyFlexibilityReadability
Explicit matchHigh (3-5x more)HighHigh (custom per error)Low (noisy)
? operatorLow (baseline)HighMediumHigh (clear intent)
.unwrap()LowNone (panics)NoneHigh (but dangerous)
.expect()LowNone (panics)NoneMedium (with message)

Use ? when:

  • You want to propagate errors up the call stack
  • All errors can convert to the same return type
  • You don’t need custom handling per error type

Use match when:

  • You need different logic for different errors
  • You want to recover from specific errors
  • You need to log or transform specific error cases

Never use unwrap() in production code unless you have a proof that the operation cannot fail.


Practical Tips

Tip 1: Use ? Liberally in Application Code

1
2
3
4
5
6
7
// Good - clear and concise
fn process_order(order_id: u64) -> Result<Receipt> {
    let order = fetch_order(order_id)?;
    let payment = charge_card(&order)?;
    let shipment = create_shipment(&order)?;
    Ok(Receipt { order, payment, shipment })
}

Don’t be afraid of ?. It’s not hiding errors–it’s propagating them clearly.

Tip 2: Add Context with anyhow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use anyhow::Context;

fn load_config() -> anyhow::Result<Config> {
    let contents = std::fs::read_to_string("config.toml")
        .context("Failed to read config.toml")?;  // Add context before ?
    
    let config = toml::from_str(&contents)
        .context("Failed to parse TOML")?;  // More context
    
    Ok(config)
}

The ? propagates both the error and the context chain.

Tip 3: Convert Option to Result Early

1
2
3
4
5
// Instead of multiple .unwrap() calls:
fn get_user_name(users: &HashMap<u64, User>, id: u64) -> Result<String, UserError> {
    let user = users.get(&id).ok_or(UserError::NotFound(id))?;  // Convert to Result immediately
    Ok(user.name.clone())
}

Tip 4: Use ? with Iterator Methods

1
2
3
4
5
fn parse_all_ids(inputs: Vec<&str>) -> Result<Vec<u64>, ParseError> {
    inputs.iter()
        .map(|s| s.parse::<u64>().map_err(ParseError::from))  // Convert error type
        .collect()  // collect() propagates first error automatically
}

collect() on Iterator<Item = Result<T, E>> returns Result<Vec<T>, E>, stopping at the first error.


Common Mistakes

Mistake 1: Using ? in Functions That Don’t Return Result

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Wrong - main returns ()
fn main() {
    let config = load_config()?;  // Compile error!
}

// Right - main returns Result
fn main() -> anyhow::Result<()> {
    let config = load_config()?;
    Ok(())
}

Mistake 2: Mixing ? with unwrap()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Bad - inconsistent error handling
fn process() -> Result<Data, Error> {
    let a = fetch_a()?;           // Propagates error
    let b = fetch_b().unwrap();   // Panics on error!
    Ok(Data { a, b })
}

// Good - consistent
fn process() -> Result<Data, Error> {
    let a = fetch_a()?;
    let b = fetch_b()?;
    Ok(Data { a, b })
}

Mistake 3: Ignoring Error Type Mismatches

1
2
3
4
5
6
7
8
9
// Wrong - error types don't match
fn process() -> Result<(), MyError> {
    some_function()?  // Returns OtherError, no From impl
}

// Right - explicit conversion
fn process() -> Result<(), MyError> {
    some_function().map_err(|e| MyError::from(e))?
}

Key Takeaways

  1. The ? operator is not magic–it’s syntactic sugar for match with automatic error conversion via From.

  2. Three operations in one: Unwrap on success, return early on error, convert error type.

  3. Works with both Result and Option–but not in the same function without conversion.

  4. Requires From implementations–either manual or generated by thiserror.

  5. Makes code dramatically more readable–3-5x less code than explicit match.

  6. Use ? in application code–it’s the idiomatic way to propagate errors in Rust.

  7. Use match for custom handling–when you need different logic per error type.

The ? operator is one of Rust’s most powerful features for error handling. It makes error propagation concise without sacrificing type safety or control flow clarity. Master it, and your Rust code will be cleaner and more maintainable.


Related Posts:

References:

License: MIT

📚 Series: Rust Error Handling

  1. Rust Error Handling: thiserror, anyhow, and error-envelope
  2. The ? Operator in Rust: Error Propagation Demystified (current)