The Rust Expression Guide
Writing Straight-Line Fallible Code with ?
Last Updated: 2026-04-04
What this page is about
The ? operator is one of the most important tools for writing readable Rust because it lets fallible code stay visually straight.
Without it, functions that parse, validate, read files, or call other fallible operations often become dominated by match blocks or nested conditionals. With it, the main path of the function remains visible from top to bottom.
This page explains what ? does, where it works, how it interacts with return types, and why it is so central to expression-oriented Rust. The emphasis is not only on mechanics. It is on shape: ? makes it possible to keep fallibility explicit without forcing the whole function to bend around error plumbing.
The core mental model
A good way to think about ? is this: it says "unwrap the success value here, or return early with the failure."
That description is informal, but it captures the effect that matters most when reading code.
For a Result<T, E>, ? does two things:
- if the value is
Ok(t), it yieldst - if the value is
Err(e), it returns early from the function with that error
For an Option<T>, ? works similarly:
- if the value is
Some(t), it yieldst - if the value is
None, it returns early withNone
That means ? is not magic shorthand for ignoring errors. It is explicit propagation, embedded directly at the point where a fallible step occurs.
A first contrast: `match` versus `?`
The easiest way to see the value of ? is to compare the same function written both ways.
Without ?:
fn parse_port(input: &str) -> Result<u16, std::num::ParseIntError> {
match input.trim().parse::<u16>() {
Ok(port) => Ok(port),
Err(err) => Err(err),
}
}With ?:
fn parse_port(input: &str) -> Result<u16, std::num::ParseIntError> {
let port = input.trim().parse::<u16>()?;
Ok(port)
}The second version makes the flow obvious:
- parse the port
- if that fails, return the error
- otherwise continue
The improvement is not merely brevity. The second version removes wrapper handling that does not add meaning.
How `?` changes function shape
The most important effect of ? is structural. It keeps the main path linear.
Consider a function that reads a file and parses a number from it.
Without ?:
use std::fs;
fn load_count(path: &str) -> Result<u32, String> {
match fs::read_to_string(path) {
Ok(text) => match text.trim().parse::<u32>() {
Ok(count) => Ok(count),
Err(e) => Err(format!("invalid number in {path}: {e}")),
},
Err(e) => Err(format!("failed to read {path}: {e}")),
}
}With ?:
use std::fs;
fn load_count(path: &str) -> Result<u32, String> {
let text = fs::read_to_string(path)
.map_err(|e| format!("failed to read {path}: {e}"))?;
let count = text
.trim()
.parse::<u32>()
.map_err(|e| format!("invalid number in {path}: {e}"))?;
Ok(count)
}The second version is easier to scan because each fallible step appears in sequence. The error shaping stays local to each step. The function reads top to bottom instead of inside out.
What `?` does operationally
Operationally, ? evaluates an expression and checks whether it represents success or failure.
For a Result, you can think of this rough expansion:
let value = match fallible_expression() {
Ok(value) => value,
Err(err) => return Err(err),
};That is not the full language-level implementation, but it is close enough to build intuition.
The important part is that ? does not swallow failure. It makes the early return part of the expression itself.
That means code like this:
let config = load_config()?;
let port = parse_port(&config.port)?;
let server = build_server(port)?;should be read as a sequence of steps where each one either yields a usable value or exits the function immediately.
Where `?` works
? works in places where the surrounding function, closure, or block returns a compatible type that can represent the same kind of early exit.
Most commonly, that means:
- functions returning
Result<_, _>can use?onResult - functions returning
Option<_>can use?onOption
For example:
fn first_number(input: Option<&str>) -> Option<u32> {
let raw = input?;
raw.trim().parse::<u32>().ok()
}Here input? means: if the option is None, return None from the function immediately.
And with Result:
fn first_arg(args: &[String]) -> Result<&str, String> {
let arg = args.get(1).ok_or_else(|| "missing first argument".to_string())?;
Ok(arg.as_str())
}The main principle is that the surrounding context must know how to propagate the failure.
How `?` relates to return types
The return type of the function determines what kind of failure can be propagated.
If a function returns Result<T, E>, then using ? on a Result<U, F> requires that the error be convertible into the function's error type.
A simple example where the types already line up:
fn parse_count(input: &str) -> Result<u32, std::num::ParseIntError> {
let count = input.parse::<u32>()?;
Ok(count)
}And an example where the code reshapes the error first:
fn parse_count(input: &str) -> Result<u32, String> {
let count = input
.parse::<u32>()
.map_err(|e| format!("invalid count: {e}"))?;
Ok(count)
}This is one of the most common real patterns in Rust: use map_err to shape the error into the function's outward error type, then use ? to keep the code straight.
Using `?` with `ok_or_else`
One of the most useful combinations in everyday Rust is ok_or_else(...)?.
This marks the point where an optional value becomes a required one in a fallible function.
fn required_name(raw: Option<&str>) -> Result<&str, String> {
let name = raw.ok_or_else(|| "name is required".to_string())?;
Ok(name)
}A more realistic version often includes normalization first:
fn required_name(raw: Option<&str>) -> Result<String, String> {
let name = raw
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| "name is required".to_string())?;
Ok(name.to_string())
}This is a powerful pattern because it keeps the boundary explicit:
- before this point, absence is modeled as
Option - at this point, absence becomes a caller-visible error
- after this point, the code continues with a real value
Using `?` with `map_err`
Another very common pattern is map_err(...)?.
This lets the code add local context or normalize the error right where the fallible step occurs.
fn parse_age(input: &str) -> Result<u8, String> {
let age = input
.trim()
.parse::<u8>()
.map_err(|e| format!("invalid age: {e}"))?;
Ok(age)
}The code remains linear:
- parse
- reshape error if needed
- continue
This is one of the reasons ? is so readable. It works well with local combinators that shape success or failure without forcing explicit branching.
Meaningful examples
Example 1: parse a required port number.
fn parse_port(raw: Option<&str>) -> Result<u16, String> {
let raw = raw.ok_or_else(|| "PORT is required".to_string())?;
let port = raw
.trim()
.parse::<u16>()
.map_err(|e| format!("PORT is invalid: {e}"))?;
if port == 0 {
Err("PORT must be non-zero".to_string())
} else {
Ok(port)
}
}Example 2: load a username from a file.
use std::fs;
fn load_username(path: &str) -> Result<String, String> {
let text = fs::read_to_string(path)
.map_err(|e| format!("failed to read {path}: {e}"))?;
let name = text.trim();
if name.is_empty() {
Err("username file was empty".to_string())
} else {
Ok(name.to_string())
}
}Example 3: extract and validate a required field.
struct Request {
email: Option<String>,
}
fn validated_email(req: &Request) -> Result<&str, String> {
let email = req
.email
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| "email is required".to_string())?;
Ok(email)
}Using `?` with `Option`
Although ? is often taught through Result, it is also useful with Option when a function is naturally optional rather than erroneous.
fn first_word_length(input: Option<&str>) -> Option<usize> {
let text = input?;
let first = text.split_whitespace().next()?;
Some(first.len())
}This reads cleanly:
- get the input, or return
None - get the first word, or return
None - return its length
Without ?, the same function would often need nested match expressions or repeated early returns. The operator gives optional computations the same straight-line shape that it gives fallible ones.
A configuration-loading example
Configuration code is one of the clearest places to see the value of ? because startup logic is often a sequence of required steps.
use std::env;
fn worker_count() -> Result<usize, String> {
let raw = env::var("WORKER_COUNT")
.map_err(|e| format!("WORKER_COUNT is unavailable: {e}"))?;
let count = raw
.trim()
.parse::<usize>()
.map_err(|e| format!("WORKER_COUNT must be a number: {e}"))?;
if count == 0 {
Err("WORKER_COUNT must be non-zero".to_string())
} else {
Ok(count)
}
}Each step either produces the next usable value or exits. That is exactly the sort of shape ? supports well.
A request-validation example
Request validation code often mixes optional fields, parsing, and domain checks. ? helps keep those layers from turning into indentation.
struct CreateUserRequest {
age: Option<String>,
}
fn validated_age(req: &CreateUserRequest) -> Result<u8, String> {
let raw = req
.age
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| "age is required".to_string())?;
let age = raw
.parse::<u8>()
.map_err(|e| format!("age is invalid: {e}"))?;
if age < 13 {
Err("age must be at least 13".to_string())
} else {
Ok(age)
}
}This is readable because the three concerns are visible in order:
- extract required input
- parse it
- validate the parsed value
When `?` is clearer than `match`
? is usually clearer when a function consists of several fallible steps that should all propagate failure upward in the same general way.
It works especially well when:
- each step yields a value needed by the next step
- error handling is local and small
- the main path should read top to bottom
For example:
use std::fs;
fn first_line(path: &str) -> Result<String, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {path}: {e}"))?;
let line = text
.lines()
.next()
.ok_or_else(|| format!("{path} was empty"))?;
Ok(line.to_string())
}Writing this with explicit match blocks would usually make the structure harder to see, not easier.
When explicit branching is better
Like all powerful tools, ? can be overused or placed where it hides important logic.
Explicit branching is often better when:
- different kinds of errors need substantially different handling
- recovery is part of the main story
- the function needs to inspect errors before deciding what to do
- the fallible step is conceptually secondary to a larger branching structure
For example, if a function needs to retry, fall back, or classify several error cases differently, a match may say more clearly what is going on.
A good heuristic is this: ? is excellent when propagation is the boring part. If error-handling decisions are the interesting part, write them explicitly.
A small CLI example
Here is a small command-line example that shows the usual ? style in a compact form.
use std::env;
fn main() -> Result<(), String> {
let level = env::args()
.nth(1)
.ok_or_else(|| "usage: provide a log level from 0 to 5".to_string())?
.trim()
.parse::<u8>()
.map_err(|e| format!("log level must be a number: {e}"))?;
if level > 5 {
return Err("log level must be between 0 and 5".to_string());
}
println!("log level: {level}");
Ok(())
}You can try it with:
cargo run -- 3
cargo run -- 9
cargo run -- nope
cargo runThis example shows a common pattern:
- convert missing input into an error
- parse with local error shaping
- apply one domain check
- keep the main path visually straight
A small project file for experimentation
You can experiment with the examples in a small project like this:
[package]
name = "writing-straight-line-fallible-code-with-question-mark"
version = "0.1.0"
edition = "2024"Then place one example at a time in src/main.rs and run:
cargo run
cargo fmt
cargo clippy
cargo testcargo fmt helps reveal the structure of straight-line fallible code. cargo clippy is useful for spotting manual patterns that can often be simplified with ?.
Common mistakes
There are a few recurring mistakes around ?.
First, treating it as if it were silently ignoring errors. It is not. It is explicit propagation with early return.
Second, using it before the surrounding function has an appropriate return type. If the function cannot represent the propagated failure, the code will not type-check.
Third, forgetting that optionality and fallibility are different stories. A function returning Result often needs an Option to be converted first with ok_or_else.
Fourth, combining too much work on one line so that ? disappears inside a dense expression rather than clarifying the structure.
Fifth, forcing everything into ? when the function really needs to inspect or recover from the error explicitly.
Refactoring patterns to watch for
When reviewing code, these are strong signals that ? may help:
- nested
matchblocks that only propagate errors upward - repeated
if let Err(e) = ... { return Err(e); }patterns - functions with several fallible steps where the happy path is hard to see
- optional values that should become required at a boundary with
ok_or_else(...)? - lower-level errors that need local shaping with
map_err(...)?
Typical before-and-after examples look like this:
fn before(input: &str) -> Result<u32, String> {
match input.parse::<u32>() {
Ok(n) => Ok(n),
Err(e) => Err(format!("invalid number: {e}")),
}
}
fn after(input: &str) -> Result<u32, String> {
let n = input.parse::<u32>().map_err(|e| format!("invalid number: {e}"))?;
Ok(n)
}And with multiple steps:
use std::fs;
fn before(path: &str) -> Result<u32, String> {
match fs::read_to_string(path) {
Ok(text) => match text.trim().parse::<u32>() {
Ok(n) => Ok(n),
Err(e) => Err(format!("invalid number in {path}: {e}")),
},
Err(e) => Err(format!("failed to read {path}: {e}")),
}
}
fn after(path: &str) -> Result<u32, String> {
let text = fs::read_to_string(path)
.map_err(|e| format!("failed to read {path}: {e}"))?;
let n = text
.trim()
.parse::<u32>()
.map_err(|e| format!("invalid number in {path}: {e}"))?;
Ok(n)
}Key takeaways
The ? operator is foundational to readable Rust because it keeps fallible code straight.
The main ideas from this page are:
?yields the success value or returns early with the failure- it works best when the surrounding function can represent the propagated failure
- its biggest value is structural: it keeps the main path linear and reduces indentation
- it pairs naturally with
ok_or_elsewhen optional values become required - it pairs naturally with
map_errwhen lower-level failures need local shaping - it is not the right tool when error inspection, recovery, or branching is the main story
Good Rust often feels calm to read because the success path remains visible even in fallible code. ? is one of the main reasons that is possible.
