Error Handling (the ? operator)

Reviewed & published by Brayan K

Rust is a systems programming language focused on speed, memory safety, and fearless concurrency — and it handles recoverable errors with Result rather than exceptions, made ergonomic by the ? operator.

Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll see how the ? operator replaces verbose match boilerplate to propagate errors cleanly up the call stack.

What You'll Learn in This Lesson

1️⃣ The Verbose Way: Propagating with match

Many operations return a Result. To pass an error up to your caller, you can match on it and return Err(e) on failure. This works, but it's noisy — especially when several steps can each fail.

// Without the ? operator, propagating errors means lots of match boilerplate.
fn parse_and_double(text: &str) -> Result<i32, std::num::ParseIntError> {
    // .parse() returns Result<i32, ParseIntError>.
    let n = match text.parse::<i32>() {
        Ok(value) => value,
        Err(e) => return Err(e),   // on error, return it early
    };
    Ok(n * 2)
}

fn main() {
    println!("{:?}", parse_and_double("21"));   // -> Ok(42)
    println!("{:?}", parse_and_double("oops")); // -> Err(ParseIntError { .. })
}

Every fallible step needs its own four-line match. Imagine doing this five times in one function — the real logic gets buried. Rust has a much cleaner tool.

2️⃣ The ? Operator

The ? operator does exactly what that match did, in one character. On an Ok it unwraps the value; on an Err it returns that error from the function immediately. The only requirement is that the enclosing function returns a Result (or Option).

// The ? operator replaces that whole match. If the Result is Ok, it unwraps
// the value; if it's Err, it RETURNS the error from the function immediately.
fn parse_and_double(text: &str) -> Result<i32, std::num::ParseIntError> {
    let n = text.parse::<i32>()?;   // unwrap Ok, or return Err early
    Ok(n * 2)
}

// ? shines when chaining several fallible steps.
fn sum_two(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
    let x = a.parse::<i32>()?;
    let y = b.parse::<i32>()?;
    Ok(x + y)
}

fn main() {
    println!("{:?}", parse_and_double("21"));   // -> Ok(42)
    println!("{:?}", parse_and_double("nope")); // -> Err(...)
    println!("{:?}", sum_two("10", "5"));       // -> Ok(15)
    println!("{:?}", sum_two("10", "x"));       // -> Err(...)  (stops at the bad one)
}

In sum_two, if the first parse fails the function returns right there and never touches the second — short-circuiting on the first error. That's the power of ?: clean, linear code that still handles every failure.

Your turn. Fill in the blanks marked ___, then run it.

// 🎯 YOUR TURN — finish the function using the ? operator, then run it.

// This should parse two strings to i32 and return their PRODUCT.
// Use ? so a bad parse returns the error automatically.
fn multiply(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
    let x = a.parse::<i32>()___;   // 👉 the operator that propagates errors
    let y = b.parse::<i32>()?;
    ___(x * y)                     // 👉 wrap the success value
}

fn main() {
    println!("{:?}", multiply("6", "7"));    // expected: Ok(42)
    println!("{:?}", multiply("6", "bad"));  // expected: Err(...)
}

// ✅ Expected output:
//    Ok(42)
//    Err(ParseIntError { kind: InvalidDigit })

Mini-Challenge: A Safe Average

Parse a slice of strings to numbers with ? and compute their average, returning a Result. Run it with cargo run.

// 🎯 MINI-CHALLENGE: a safe average.
//
// Write fn average(values: &[&str]) -> Result<f64, std::num::ParseIntError>:
//   1. Start a running total of 0.
//   2. For each &str in values, parse it to i32 with ? (propagate errors).
//   3. Add each parsed number to the total.
//   4. Return Ok(total as f64 / values.len() as f64).
//
// In main, call it on ["10", "20", "30"] and on ["10", "oops"], printing
// each result with {:?}.
//
// ✅ Example output:
//    Ok(20.0)
//    Err(ParseIntError { kind: InvalidDigit })

fn main() {
    // your code here
}

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Error Handling

GoalRust Syntax
Propagate errorlet v = expr?;
Return successOk(value)
Return failureErr(e)
Handle locallymatch expr { Ok(v) => .., Err(e) => .. }
Flexible error typeBox<dyn Error>

🎉 Lesson Complete!

Practice quiz

How does Rust handle recoverable errors?

  • With exceptions
  • With null returns
  • With the Result type
  • By aborting

Answer: With the Result type. Rust uses Result<T, E> for recoverable errors rather than exceptions.

On an Ok value, what does the ? operator do?

  • Unwraps the value and continues
  • Returns early
  • Panics
  • Logs the value

Answer: Unwraps the value and continues. On Ok(value), ? evaluates to value and execution continues.

On an Err, what does the ? operator do?

  • Ignores it
  • Retries the operation
  • Converts it to Ok
  • Immediately returns the Err from the enclosing function

Answer: Immediately returns the Err from the enclosing function. On Err(e), ? returns Err(e) early from the current function.

Why must a function using ? return a Result (or Option)?

  • For speed
  • Because ? returns the error out of the function, which needs a Result return type to hold it
  • Because ? prints errors
  • It doesn't have to

Answer: Because ? returns the error out of the function, which needs a Result return type to hold it. ? short-circuits by returning the error, so there must be somewhere (a Result) for that Err to go.

What does ? replace compared to manual error handling?

  • A verbose match that returns Err(e) early
  • A loop
  • A panic
  • An if statement

Answer: A verbose match that returns Err(e) early. ? collapses a four-line match-and-return-early into a single character.

In multiply(a, b) using ?, what does multiply("6", "7") return?

  • 42
  • Err(..)
  • Ok(42)
  • 13

Answer: Ok(42). Both parse successfully, so the function returns Ok(42) (6 * 7 wrapped in Ok).

In sum_two where the first parse fails, what happens to the second parse?

  • It still runs
  • It is never reached — ? short-circuits on the first error
  • It panics
  • It returns Ok

Answer: It is never reached — ? short-circuits on the first error. ? returns on the first error, so execution never touches the second parse.

Can ? convert between different error types?

  • No
  • Only for strings
  • Only inside main
  • Yes, it calls .into() via the From trait when a conversion exists

Answer: Yes, it calls .into() via the From trait when a conversion exists. When the error type differs, ? auto-converts using From, which is why Box<dyn Error> works so smoothly.

Which is the idiomatic choice to propagate an error to the caller?

  • unwrap
  • the ? operator
  • panic!
  • expect

Answer: the ? operator. Use ? to propagate; use match/if let to handle locally; reserve unwrap/expect for impossible cases or prototypes.

For average(values) that parses each &str with ?, what does average(&["10", "oops"]) return?

  • Ok(5.0)
  • Ok(20.0)
  • Err(ParseIntError ..)
  • 0.0

Answer: Err(ParseIntError ..). "oops" fails to parse, so ? returns the Err immediately and the function yields Err(ParseIntError).

Continue this course

Frequently asked questions

What exactly does the ? operator do?

The ? operator is shorthand for handling a Result (or an Option). When you write expr?, if expr is Ok(value) it evaluates to value and execution continues; if expr is Err(e) it immediately returns Err(e) from the enclosing function. So ? unwraps the success case and short-circuits the error case, replacing a verbose match that returns early. It works only inside a function whose return type is itself a Result (or Option), because that is where the error gets returned to.

Why does the function using ? have to return a Result?

Because ? returns the error out of the current function on failure, that function must have a return type that can hold the error — namely Result<T, E> (or Option<T> when used with Options). If you tried to use ? in a function that returns a plain value, there would be nowhere for the early Err to go, so the compiler rejects it. This is why main is often written as 'fn main() -> Result<(), Box<dyn Error>>' when you want to use ? at the top level.

When should I use ? versus match versus unwrap?

Use ? when you want to propagate an error up to the caller and let them decide what to do — this is the common case in library and helper functions. Use match (or if let) when you want to handle the error right here, for example by logging it or substituting a default. Use unwrap or expect only when an error is truly impossible or in throwaway code, since they panic on failure. In well-written programs, ? and match handle almost all cases.

Can ? convert between different error types?

Yes. When the error type returned by ? differs from the function's declared error type, ? automatically calls .into() to convert it, as long as a From implementation exists between the two error types. This is what makes ? so ergonomic with a boxed error type like Box<dyn Error> or with custom error enums that implement From for the underlying errors. It lets one function cleanly propagate several different kinds of errors through a single return type.