Result in Rust Explained and How to Handle Both Outcomes
Result is an enum with Ok and Err. Learn the handful of methods that cover most uses and when to match instead of chaining.

Result<T, E> is how Rust says a function can fail. It is an ordinary enum with two variants, Ok(T) and Err(E), and the compiler will not let you read the value inside without deciding what happens to the other case. There are no exceptions, no hidden control flow and no null. This post shows the five ways to handle a result (match, ?, combinators, defaults, and unwrapping), what each is for, how main can return one, and the mistakes that turn a result into a panic or silently swallow an error. Everything compiled on Rust 1.99, edition 2024.
What Result is
The definition in the standard library is two lines:
enum Result<T, E> {
Ok(T),
Err(E),
}A function that reads a number from text returns Result<i32, ParseIntError>: either an integer, or the specific reason it could not make one. The error type is part of the signature, so a caller can see in the type what can go wrong. A result is also marked #[must_use]: ignoring it produces a compiler warning, and that warning is the one to treat as an error in CI.
fn main() {
let good = "42".parse::<i32>();
let bad = "4x2".parse::<i32>();
println!("{good:?}");
println!("{bad:?}");
"7".parse::<i32>();
}Ok(42)
Err(ParseIntError { kind: InvalidDigit })The last line compiles, and the compiler says what is wrong with it:
warning: unused `Result` that must be used
--> src/main.rs:2:5
|
2 | "7".parse::<i32>();
| ^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting valueThe parse could have failed and the program carried on without looking. If ignoring the result is genuinely intended, let _ = ... says so in the code, and a reviewer can ask why. Deny the lint in CI with #![deny(unused_must_use)] so that nobody forgets a result in a hurry.
Way 1: match, to handle both outcomes explicitly
When each case needs its own handling, match. The compiler checks that both are covered:
use std::fs;
use std::io::ErrorKind;
fn read_setting(path: &str) -> String {
match fs::read_to_string(path) {
Ok(text) => text.trim().to_string(),
Err(e) if e.kind() == ErrorKind::NotFound => "default".to_string(),
Err(e) => format!("unreadable: {e}"),
}
}
fn main() {
println!("{}", read_setting("/definitely/not/here.conf"));
println!("{}", read_setting("/etc"));
}default
unreadable: Is a directory (os error 21)The guard if e.kind() == ErrorKind::NotFound distinguishes the error you expect (no file yet) from the ones you do not (a directory where a file should be). Handling the expected case and passing the rest on is the normal shape of real error handling.
Way 2: the question mark, to pass the error up
Most functions cannot do anything useful with an error except tell their caller. The ? operator does exactly that: on Ok(v) it gives you v; on Err(e) it returns early with the error, converted by From into the function's error type.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Missing(&'static str),
BadNumber(ParseIntError),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::Missing(k) => write!(f, "missing key {k}"),
ConfigError::BadNumber(e) => write!(f, "bad number: {e}"),
}
}
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::BadNumber(e)
}
}
fn lookup<'a>(pairs: &'a [(&str, &str)], key: &'static str) -> Result<&'a str, ConfigError> {
pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| *v).ok_or(ConfigError::Missing(key))
}
fn port(pairs: &[(&str, &str)]) -> Result<u16, ConfigError> {
let raw = lookup(pairs, "port")?;
let n: u16 = raw.parse()?;
Ok(n)
}
fn main() {
println!("{:?}", port(&[("port", "8080")]));
for case in [vec![("host", "x")], vec![("port", "http")], vec![("port", "70000")]] {
match port(&case) {
Ok(p) => println!("port {p}"),
Err(e) => println!("error: {e}"),
}
}
}Ok(8080)
error: missing key port
error: bad number: invalid digit found in string
error: bad number: number too large to fit in target typeTwo different failures (a missing key and a malformed number) arrive at the caller as one error type. The one-line ? after raw.parse() relies on From<ParseIntError> for ConfigError; the machinery is explained in From and Into. Writing those enums by hand is routine work that thiserror removes, and the choice between it and anyhow is in anyhow versus thiserror.
Way 3: combinators, to transform without a match
A result has methods that map over either side, which turn four-line matches into one line. The ones that earn their place:
| Method | What it does | Typical use |
|---|---|---|
map(f) | Transforms the Ok value | Parse, then wrap in a struct |
map_err(f) | Transforms the Err value | Add context or change the error type |
and_then(f) | Chains a step that can itself fail | Parse, then validate range |
or_else(f) | Recovers from an error with another attempt | Try a second file location |
ok() | Turns Result into Option, discarding the error | "Not found" is fine |
unwrap_or(d) / unwrap_or_else(f) / unwrap_or_default() | Replaces the error with a value | A fallback that is always right |
fn workers(raw: &str) -> Result<usize, String> {
raw.trim()
.parse::<usize>()
.map_err(|e| format!("workers must be a number, got {raw:?}: {e}"))
.and_then(|n| if (1..=64).contains(&n) { Ok(n) } else { Err(format!("{n} is outside 1..=64")) })
}
fn main() {
for raw in ["8", " 16 ", "0", "many"] {
println!("{raw:?} -> {:?}", workers(raw));
}
println!("{}", workers("many").unwrap_or(4));
println!("{:?}", workers("99").ok());
}"8" -> Ok(8)
" 16 " -> Ok(16)
"0" -> Err("0 is outside 1..=64")
"many" -> Err("workers must be a number, got \"many\": invalid digit found in string")
4
NoneChains like this read as a pipeline of steps where the first failure short-circuits the rest. Past three or four links, a function with ? on separate lines is easier to read and to put a breakpoint in.
Way 4: collecting many results
An iterator of results can be collected into one result. The whole collection fails at the first error, or you split them, depending on what you want:
fn main() {
let lines = ["10", "20", "x30", "40"];
let all: Result<Vec<i32>, _> = lines.iter().map(|s| s.parse::<i32>()).collect();
println!("{all:?}");
let good: Vec<i32> = lines.iter().filter_map(|s| s.parse().ok()).collect();
println!("{good:?}");
let (oks, errs): (Vec<_>, Vec<_>) = lines.iter().map(|s| s.parse::<i32>()).partition(|r| r.is_ok());
println!("{} ok, {} failed", oks.len(), errs.len());
let total: Result<i32, _> = ["1", "2", "3"].iter().map(|s| s.parse::<i32>()).sum();
println!("{total:?}");
}Err(ParseIntError { kind: InvalidDigit })
[10, 20, 40]
3 ok, 1 failed
Ok(6)The first form is all-or-nothing and is right for a request body. filter_map(... .ok()) silently drops the bad lines, which is a deliberate decision that should be visible in a comment, not a default. Dropping errors without a trace is the most common way a data pipeline loses rows.
Way 5: unwrapping, and when a panic is right
unwrap() returns the value or panics with the error's debug form. expect("reason") does the same with your message. Both turn a recoverable error into a crash, which is correct in tests, in examples and in one more case, when an error would mean a bug in the program itself rather than bad input:
use std::net::IpAddr;
fn main() {
let loopback: IpAddr = "127.0.0.1".parse().expect("a literal IP address always parses");
println!("{loopback}");
let from_user: Result<IpAddr, _> = "300.1.1.1".parse::<IpAddr>();
println!("{from_user:?}");
let n: i32 = "oops".parse().expect("config port must be a number");
println!("{n}");
}127.0.0.1
Err(AddrParseError(Ip))
thread 'main' panicked at src/main.rs:10:33:
config port must be a number: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe first expect documents an invariant (the literal is valid); if it ever fires, the program is wrong. The last one is the misuse: a user can supply that string, and a panic is the wrong response to bad input. Review rule: every unwrap or expect in non-test code needs a reason the failure is impossible, written in the expect message. Panic behaviour and what the exit code looks like are in the unwrap-on-None panic.
A main that returns a Result
Since main may return Result<(), E> where E: Debug, an uncaught error ends the program with exit code 1 and prints the error. That is enough for small tools:
use std::error::Error;
use std::fs;
fn main() -> Result<(), Box<dyn Error>> {
let text = fs::read_to_string("/definitely/not/here.conf")?;
println!("{} bytes", text.len());
Ok(())
}Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }Notice the output is the debug form, Error: Os { code: 2, ... }, not the friendly message. For a user-facing tool, print the Display form yourself and set an exit code, as in CLI exit codes.
Where Result is not the right type
If the absence of a value is not an error, use Option: looking up a key that may not be in a map is Option, and calling it a failure invents an error type with nothing to say. If failure is a bug in the program (an index out of range, an arithmetic overflow in debug), the language panics. If there is more than one value worth reporting at the same time, such as the lines in a file that all failed validation, collect them into a vector of errors inside a single error, not one at a time. And across a thread or task boundary, the result of a panic is a JoinError or Err(Box<dyn Any>), not your own error type. The broader strategy for how errors move through a program is in error handling in Rust.


