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
- Propagating errors manually with match
- How the ? operator unwraps or returns early
- Why ? needs a Result-returning function
- Chaining several fallible steps with ?
- When to use ? vs match vs unwrap
- How ? converts error types via From
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)
- "the `?` operator can only be used in a function that returns `Result`" — Change the function's return type to a Result (or Option).
- Forgetting to wrap the success — After your last step, return Ok(value), not a bare value.
- "`?` couldn't convert the error" — The error types differ and no From conversion exists. Use a common error type like Box<dyn Error>.
- Using ? on a non-Result — ? only works on Result and Option values.
Pro Tips
- 💡 Make main return a Result: fn main() -> Result<(), Box<dyn std::error::Error>> lets you use ? at the top level.
- 💡 Box errors to mix types: Box<dyn Error> lets ? combine different error kinds easily.
- 💡 Reserve unwrap for prototypes: in real code, prefer ? or match so failures don't panic.
📋 Quick Reference — Error Handling
| Goal | Rust Syntax |
|---|---|
| Propagate error | let v = expr?; |
| Return success | Ok(value) |
| Return failure | Err(e) |
| Handle locally | match expr { Ok(v) => .., Err(e) => .. } |
| Flexible error type | Box<dyn Error> |
🎉 Lesson Complete!
- ✅ Rust handles recoverable errors with Result, not exceptions
- ✅ expr? unwraps Ok or returns Err early
- ✅ ? requires a Result-returning function and converts errors via From
- ✅ Use ? to propagate, match to handle, unwrap sparingly
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
- Previous: HashMaps
- Next: Modules & Crates — Organise code with modules, paths, and external crates from crates.io
- Quick reference: Rust cheat sheet › Error Handling
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.