Back

Schrödinger's Result

Error handling in Rust.

In Schrödinger's thought experiment,1 Wikipedia, Schrödinger's cat: the thought experiment, from 1935. a cat sealed in a box is both alive and dead until someone opens it to look.

Rust's Result<T, E>2 The Rust book, Recoverable Errors with Result: the chapter this page follows, in Brown University's edition. is the same box. A function that can fail returns either Ok, holding a value, or Err, holding an error. Calling .unwrap() bets that the cat is alive. If it is not, the program crashes.

Schrödinger's cat as a meme about Rust's unwrap(). Schrödinger says: I don't know if Result T, E is T or E until I unwrap. The box answers: thread main panicked at src/main.rs:10:7: called Result::unwrap() on an Err value. Schrödinger: Shut up.

1 unwrap and expect

Every way of opening the box gets the value out when there is one. What sets them apart is what they do when there is none. With unwrap there is no plan B.3 The Rust book, Shortcuts for Panic on Error: unwrap and expect. On an Err the program panics and stops, printing the error and the line that called unwrap.

Flip a bit by clicking it, or drag to flip several.

The example below parses a cat's lives from a string into a u8. parse4 Rust's standard library, str::parse: turning a string into a number. returns a Result, explained in the next section. "9" gives Ok(9), but "nine" gives an Err, so the second line panics:

let lives: u8 = "9".parse().unwrap();// succeeds
let lives: u8 = "nine".parse().unwrap();// panics

expect panics the same way, but with a message of your choosing in place of the generic one:

let lives: u8 = "nine".parse().expect("lives should be a number");
unwrap
expect
The message from expect replaces the generic one.

Errors that cannot happen sometimes do. In November 2025 a file that feeds Cloudflare's bot detection grew past its limit of 200 features, and the code that read it called unwrap on the result. The unwrap panicked, and many sites behind Cloudflare went down.5 Cloudflare, Cloudflare outage on November 18, 2025: the incident report.

2 Result and Option

A Result is always one of two things:6 Rust's standard library, Result: the methods on Result, with examples. Ok with the value, or Err with the error. A type like that is called an enum, and Ok and Err are its variants.

Parsing a cat's lives gives a Result<u8, ParseIntError>: Ok with a u8 if it worked, Err with a ParseIntError if it failed:

unwrap, from the previous section, takes the value out of the Ok:

let lives: Result<u8, ParseIntError> = "9".parse();// Ok(9)
let n: u8 = lives.unwrap();// 9

The first type in the angle brackets is the value's, the second the error's. They can be any types,7 The Rust book, Data Types: the types Rust has built in. so different functions can fail with different errors. For example, reading a file as text gives a Result<String, io::Error>.

Option8 Rust's standard library, Option: the methods on Option, with examples. is the box without an error: Some holding a value, or None holding nothing. For example, find returns Some with the position of a letter in a word, counting from 0, or None when the letter is missing:

let at: Option<usize> = "nine".find('e');// Some(3)
let at: Option<usize> = "nine".find('x');// None
Pick a string to parse. Each cell is a bit.

In memory, a Result<u8, ParseIntError> takes two bytes. The first byte, the tag, says which variant it is: 0 for Ok, 1 for Err. The second byte holds what that variant carries. For Ok it is the number. For Err it is a code for the kind of error, and an empty string is code 0. So "0" and "" leave the same second byte, and only the tag tells them apart.

ok turns the Result into an Option<u8>, which also takes two bytes. It keeps the number and drops the error. Here the tag is 1 for Some and 0 for None. A None carries nothing, so its second byte goes unused.

3 match, if let and let-else

A match9 The Rust book, The match Control Flow Construct: on why every case must be covered. opens the box, and Rust forces you to handle every outcome. The ::<u8> below is a turbofish. The footnote links to something beautiful. Do follow it.10 The Bastion of the Turbofish: a test in Rust's compiler that guards the syntax.

match input.parse::<u8>() {
    Ok(n) => println!("parsed {n}"),
    Err(e) => println!("error: {e}"),
}

When only one outcome matters, if let handles it and ignores the other. let-else does the reverse: it keeps the value, and returns from the function when there is none.

if let Ok(lives) = input.parse::<u8>() {
    println!("{lives} lives left");
}

let Ok(lives) = input.parse::<u8>() else {
    return;
};

4 unwrap_or and its variants

There is often a sensible value to fall back on when parsing fails.11 Rust's standard library, Extracting contained values: every way to take the value out. unwrap_or takes the fallback itself. unwrap_or_default uses the type's default, which for a number is 0. unwrap_or_else takes a small function that runs only on an error, with the error as its input.

let lives = input.parse::<u8>().unwrap_or(9);
let lives = input.parse::<u8>().unwrap_or_default();
let lives = input.parse::<u8>().unwrap_or_else(|e| {
    eprintln!("{e}, assuming nine");
    9
});

The first and the last differ in when the fallback is computed. unwrap_or's argument is evaluated before the call, whether it is needed or not. A fallback that is costly to compute belongs in unwrap_or_else, which computes it only on an error.

5 map, map_err, and_then and ok

The value can be worked on without taking it out of the box. map changes the value in an Ok and leaves an Err alone. map_err changes the error in an Err and leaves an Ok alone. and_then runs a step that can itself fail. ok turns a Result into an Option, dropping the error.

The function below spends one of a cat's lives. It turns a parse error into text and refuses a cat with no lives left. Then it subtracts one:

fn spend_a_life(input: &str) -> Result<u8, String> {
    input
        .parse::<u8>()
        .map_err(|e| e.to_string())
        .and_then(|n| match n {
            0 => Err("no lives left".to_string()),
            _ => Ok(n),
        })
        .map(|n| n - 1)
}

A chain like this can be drawn as a railway with two tracks.12 Scott Wlaschin, Railway Oriented Programming: the picture, first drawn for F#. Each step works on one track and lets the other pass. Once a value has switched to the error track, it stays there.

spend_a_life() gives

6 The ? operator

Often an error should be handled by whoever called the function, not by the function itself. The ? operator,13 The Rust book, The ? Operator Shortcut: on passing errors up. written after a Result, passes it up. On Ok it takes the value out and carries on. On Err it returns the error from the function right away.

fn lives(path: &str) -> Result<u8, LivesError> {
    let text = fs::read_to_string(path)?;
    let lives = text.trim().parse()?;
    Ok(lives)
}

Each ? stands for a match:14 The Rust Reference, The try propagation expression: what ? means, exactly.

let text = match fs::read_to_string(path) {
    Ok(text) => text,
    Err(e) => return Err(From::from(e)),
};
lives
fn lives(path: &str) -> Result<u8, LivesError> {
    let text = fs::read_to_string(path)?;
    let lives = text.trim().parse()?;
    Ok(lives)
}
main
fn main() {
    match lives("cat.txt") {
        Ok(n) => println!("{n} lives left"),
        Err(e) => println!("no cat: {e}"),
    }
}

From::from lets one function return two kinds of error. Reading the file fails with an io::Error, parsing with a ParseIntError, and both are converted into the function's own LivesError on the way out.

Even main can return a Result. Its error goes up to the operating system as exit code 1.

fn main() -> Result<(), ParseIntError> {
    let lives: u8 = "nine".parse()?;
    println!("{lives} lives left");
    Ok(())
}

Every program hands back such a code when it ends. 0 means success and anything else a failure. On Linux, echo $? shows the last one:

jani@janix ~/lives % ./lives
Error: ParseIntError { kind: InvalidDigit }
jani@janix ~/lives % echo $?
1
Common exit codes on Linux.
0success
1a general error, which is what an Err from main gives
2the command was used wrongly, by convention
101a Rust panic
126the file cannot be run
127no such command
128 + nstopped by signal n, so 130 after Ctrl+C

7 Custom error types

LivesError is an enum with a variant for each way the function can fail, so a caller can match on what went wrong. The From impls are what ? calls:

#[derive(Debug)]
enum LivesError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for LivesError {
    fn from(e: io::Error) -> Self {
        LivesError::Io(e)
    }
}

A second From impl does the same for ParseIntError. A Display impl lets the error be printed, and the Error trait15 Rust's standard library, Error: the trait every error type shares. lets it work with other libraries' errors.

This is a lot of boilerplate,16 Wikipedia, Boilerplate code: code repeated with little change. which the anyhow17 anyhow: one error type for any error. crate lets you skip. Any error fits into its one error type, and context adds a line saying what failed:

fn lives(path: &str) -> anyhow::Result<u8> {
    let text = fs::read_to_string(path).context("can't open the box")?;
    let lives = text.trim().parse().context("can't count the lives")?;
    Ok(lives)
}

8 When to panic

The Rust book's rule of thumb:18 The Rust book, To panic! or Not to panic!: guidelines for choosing. panic when continuing would be a bug. Return a Result when failure is something the caller can expect and handle, like a missing file or a typo in the input.

Type any value to see what each method does when parsing it into a u8.