Skip to content
rusthacks.dev
Rust

Error Handling in Rust With Result Option and Question Mark

A working guide to error handling in Rust: Result, the question mark, custom error types, anyhow and thiserror, and the line between a panic and an error.

17 min read
Summarize in AI
Flat illustration for the post “Error Handling in Rust With Result Option and Question Mark”, composed around errors.

Rust has no exceptions. A function that can fail says so in its return type, and the compiler will not let a caller pretend otherwise. The pattern looks restrictive for about a week, then it becomes the thing you miss in other languages: you can read a signature and know where the failures are. This guide covers the whole path from Result and the ? operator, through the two compiler errors you will certainly hit, to designing error types for a library, using anyhow in an application, and deciding when a panic is the correct answer. Every example was compiled and run on Rust 1.99 (edition 2024), with thiserror 2.0 and anyhow 1.0, and the output shown is what the programs printed.

Result and Option are ordinary enums

Result<T, E> has two variants, Ok(T) carrying the value and Err(E) carrying the reason. Option<T> has Some(T) and None. Nothing about them is magic: you can write match on them like any enum, and everything else (map, and_then, unwrap_or) is a method in the standard library. The difference in meaning is what matters. Use Option when absence is a normal outcome and needs no explanation, such as looking up a key in a map. Use Result when the caller needs to know why something failed. Option explained and Result explained cover the combinators; this guide is about the design decisions around them.

Converting between the two is a common move. Option::ok_or turns a missing value into an error of your choice, and Result::ok throws the reason away when you do not need it. The first program below uses a small function that finds the port in a file. It reads the file, parses the text, and fails in two different ways:

src/main.rsrust
use std::fs;
use std::io;
use std::process::ExitCode;

fn read_port(path: &str) -> Result<u16, io::Error> {
    let text = fs::read_to_string(path)?;
    text.trim()
        .parse::<u16>()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

fn main() -> ExitCode {
    fs::write("port.txt", "8080\n").expect("cwd is writable");
    fs::write("bad.txt", "http\n").expect("cwd is writable");
    for path in ["port.txt", "bad.txt", "missing.txt"] {
        match read_port(path) {
            Ok(port) => println!("{path}: port {port}"),
            Err(e) => println!("{path}: {:?}: {e}", e.kind()),
        }
    }
    ExitCode::SUCCESS
}

Running it against a good file, a file that contains http, and a file that does not exist prints:

Text
port.txt: port 8080
bad.txt: InvalidData: invalid digit found in string
missing.txt: NotFound: No such file or directory (os error 2)

Walk through read_port. fs::read_to_string(path)? does the read, and the ? asks: is this an Ok? If so, unwrap it and continue. If it is an Err, stop here and return that error from read_port, after converting it to the function's error type with From::from. Here the types already match (io::Error on both sides), so the conversion does nothing. The parse is different. parse::<u16>() returns a ParseIntError, which is not an io::Error, so map_err wraps it with io::Error::new(ErrorKind::InvalidData, e). That is how the second line of output got the kind InvalidData. The loop in main pattern-matches on each result and prints the error's kind with {:?} and its message with {}. The function returns ExitCode::SUCCESS because main here handles its own errors; the next section covers what happens when you let them escape.

The wrapping in map_err is the first design pressure. It works, but it lies: a number that fails to parse is not an I/O failure, and a caller cannot tell the two apart without inspecting the message. We will fix that with a real error type. First, the two mistakes that every newcomer makes with ?.

The mistake: question mark in a function that returns nothing

The ? operator returns early, and returning early needs somewhere to return to. Put it in main with no return type:

src/main.rsrust
use std::fs;

fn main() {
    let text = fs::read_to_string("port.txt")?;
    println!("{}", text.trim());
}

The compiler stops with E0277:

Text
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:46
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let text = fs::read_to_string("port.txt")?;
  |                                              ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let text = fs::read_to_string("port.txt")?;
5 |     println!("{}", text.trim());
6 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.

The message is accurate and the suggestion is the standard answer. A function whose return type is () has no way to hand an error to its caller, so ? has nothing to desugar to. Either handle the error in place with match or if let, or change the signature. For main there is a third option people overlook: main may return a Result. If it returns Err, the runtime prints the error with its Debug format to standard error and exits with status 1. A one-line demonstration:

src/main.rsrust
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let port: u16 = "http".parse()?;
    println!("{port}");
    Ok(())
}
Text
Error: ParseIntError { kind: InvalidDigit }

That is an acceptable end state for a script and a poor one for a tool anyone else will run, because the Debug output of a library error is written for programmers and carries no context about which input was bad. Fixing that is what the rest of this guide is for.

The mistake: question mark that cannot convert

The second error comes up the moment a function has two different failure types. Remove the map_err from the earlier example and let ? do all the work:

src/main.rsrust
use std::fs;

fn read_port(path: &str) -> Result<u16, std::io::Error> {
    let text = fs::read_to_string(path)?;
    let port = text.trim().parse::<u16>()?;
    Ok(port)
}

fn main() {
    println!("{:?}", read_port("port.txt"));
}

The result is another E0277, with a different explanation:

Text
error[E0277]: `?` couldn't convert the error to `std::io::Error`
 --> src/main.rs:5:42
  |
3 | fn read_port(path: &str) -> Result<u16, std::io::Error> {
  |                             --------------------------- expected `std::io::Error` because of this
4 |     let text = fs::read_to_string(path)?;
5 |     let port = text.trim().parse::<u16>()?;
  |                            --------------^ the trait `From<ParseIntError>` is not implemented for `std::io::Error`
  |                            |
  |                            this can't be annotated with `?` because it has type `Result<_, ParseIntError>`
  |
  = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
  = help: `std::io::Error` implements trait `From<T>`:
            From<ErrorKind>
            From<IntoInnerError<W>>
            From<NulError>
            From<TryReserveError>
            From<std::fs::TryLockError>

For more information about this error, try `rustc --explain E0277`.

This message is where the rule behind ? becomes visible. On the error path, expr? is roughly return Err(From::from(e)). The function's declared error type must implement From for every error type that a ? inside it can produce. io::Error has no From<ParseIntError>, and you cannot add one yourself, since neither type is yours (the orphan rule, in From and Into). The fix is to stop returning io::Error and return a type that you control, which can hold either failure. There are three ways to build that type, and they are the real subject of error design in Rust.

  • Return Box<dyn std::error::Error>. Every error type converts into it, so ? works with anything. You lose the ability to match on the cause.
  • Define an enum with a variant per failure and implement From for each. This is what a library should do, and thiserror writes the boilerplate.
  • Return anyhow::Error, which is the boxed approach with context, a backtrace and a nicer display, and is aimed at applications.

A typed error with thiserror

When callers need to react differently to different failures, give them an enum. thiserror is a derive macro that generates the Display and Error impls from attributes, and it adds nothing to your public API: the generated code is the code you would have written by hand.

Cargo.tomlTOML
[dependencies]
thiserror = "2"
anyhow = "1"
src/main.rsrust
use std::error::Error;
use std::fs;
use std::num::ParseIntError;

#[derive(Debug, thiserror::Error)]
enum ConfigError {
    #[error("cannot read {path}")]
    Read {
        path: String,
        #[source]
        source: std::io::Error,
    },
    #[error("port is not a number")]
    Port(#[from] ParseIntError),
    #[error("port {0} is reserved")]
    Reserved(u16),
}

fn read_port(path: &str) -> Result<u16, ConfigError> {
    let text = fs::read_to_string(path).map_err(|source| ConfigError::Read {
        path: path.to_string(),
        source,
    })?;
    let port: u16 = text.trim().parse()?;
    if port < 1024 {
        return Err(ConfigError::Reserved(port));
    }
    Ok(port)
}

fn main() {
    fs::write("low.txt", "80\n").expect("cwd is writable");
    for path in ["port.txt", "bad.txt", "low.txt", "missing.txt"] {
        match read_port(path) {
            Ok(port) => println!("{path}: ok {port}"),
            Err(ConfigError::Reserved(p)) => println!("{path}: retry with a port above 1023, not {p}"),
            Err(e) => {
                print!("{path}: {e}");
                let mut cause = e.source();
                while let Some(c) = cause {
                    print!(" <- {c}");
                    cause = c.source();
                }
                println!();
            }
        }
    }
}

This program reads four paths and prints one line for each:

Text
port.txt: ok 8080
bad.txt: port is not a number <- invalid digit found in string
low.txt: retry with a port above 1023, not 80
missing.txt: cannot read missing.txt <- No such file or directory (os error 2)

Taking the enum apart. #[derive(Debug, thiserror::Error)] asks the macro for an impl std::error::Error and an impl Display. Each variant's #[error("...")] string is its display text, and fields can be interpolated: "port {0} is reserved" uses the tuple field, and "cannot read {path}" uses the named one. The Read variant has a named field path for context and a field marked #[source], which tells the macro that this is the underlying cause and should be returned from Error::source(). The Port variant uses #[from] on its single field, which does two jobs at once: it marks the field as the source and it generates impl From<ParseIntError> for ConfigError. That generated impl is exactly what the compiler asked for in the previous section, and it is why text.trim().parse()? compiles without a map_err.

The Read variant has no #[from] on purpose. An io::Error alone says "No such file" without saying which file, and #[from] would have made the bare ? convert it with no room for the path. Instead read_port uses map_err with a closure that builds the variant, so the path travels with the error. When a From impl would lose information that the caller has at hand, skip it and attach the information explicitly.

In main, the match shows what the typed error buys. The Reserved case is handled by value, with a message that uses the number that was rejected. Everything else falls through to the general arm, which prints the error and then walks the chain with source(), producing the <- separated causes you see in the output. Without a typed error you would have to parse the message to know a port was reserved. This is what "libraries should expose a typed error" means: the caller's code branches on a variant and not on a string.

There are costs. Each variant that wraps a foreign type with #[from] puts that type into your public API, so changing which parsing crate you use becomes a breaking change for your users. Keep variants about your domain (InvalidPort), and keep the foreign error behind #[source] where callers can read it but not match on its type. Enums with a variant per upstream failure also grow without bound, which is the failure mode that sends people to the next approach.

Application errors with anyhow

In a binary, no one matches on the variants. The error travels to main, a human reads it, and the process exits. What that human needs is context: not "No such file or directory" but "reading missing.txt". anyhow::Error is a wrapper around any std::error::Error that carries a stack of context messages, and anyhow::Result<T> is Result<T, anyhow::Error>.

src/main.rsrust
use anyhow::{Context, Result, bail};

fn load(path: &str) -> Result<u16> {
    let text = std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?;
    let port: u16 = text.trim().parse().context("parsing the port")?;
    if port == 0 {
        bail!("port 0 is not allowed");
    }
    Ok(port)
}

fn main() -> Result<()> {
    println!("{}", load("port.txt")?);
    println!("{}", load("missing.txt")?);
    Ok(())
}
Text
8080
Error: reading missing.txt

Caused by:
    No such file or directory (os error 2)

Three things are happening in load. with_context attaches a message built from a closure, which runs only on the error path, so the format! costs nothing when the file exists. context attaches a fixed string. bail! returns an error from a message, which is the shorthand for return Err(anyhow!(...)). Because every error converts into anyhow::Error, the ? works on both the I/O call and the parse without any From impls. main returns anyhow::Result<()>, so the failure on the second load call prints the outermost context as Error: reading missing.txt and the original error under Caused by:. The first line, 8080, is the successful call, and the program exits with status 1 after the failure.

Compare that output with Error: ParseIntError { kind: InvalidDigit } from the earlier program. Same mechanism, one extra method call per fallible line, and the failure tells you which input to fix. The choice between this and the typed enum is the subject of anyhow versus thiserror, and a good rule is: the code that decides what to do about an error wants a type, and the code that only reports it wants context.

You can still get information back out of an anyhow::Error. err.downcast_ref::<ConfigError>() returns the original error if that is what it holds, and err.chain() iterates the whole context stack. So the two crates compose: a library returns a thiserror enum, and an application wraps it in anyhow with context at the call site. Using anyhow inside a library is the mistake to avoid, because it removes the caller's ability to match without a downcast.

What an error costs

People worry that rich errors are heavy. The size of a Result is the size of its larger variant plus a tag, so the question is how large the E is. Printing the sizes is quick:

src/main.rsrust
use std::mem::size_of;

#[derive(Debug)]
#[allow(dead_code)]
enum Small { Read, Parse, Reserved(u16) }

fn main() {
    println!("Result<u16, Small>               {} bytes", size_of::<Result<u16, Small>>());
    println!("Result<u16, std::io::Error>      {} bytes", size_of::<Result<u16, std::io::Error>>());
    println!("Result<u16, Box<dyn Error>>      {} bytes", size_of::<Result<u16, Box<dyn std::error::Error>>>());
    println!("Result<u16, anyhow::Error>       {} bytes", size_of::<Result<u16, anyhow::Error>>());
    println!("Option<anyhow::Error>            {} bytes", size_of::<Option<anyhow::Error>>());
    println!("Result<[u8; 1024], anyhow::Error> {} bytes", size_of::<Result<[u8; 1024], anyhow::Error>>());
}
Text
Result<u16, Small>               4 bytes
Result<u16, std::io::Error>      16 bytes
Result<u16, Box<dyn Error>>      16 bytes
Result<u16, anyhow::Error>       16 bytes
Option<anyhow::Error>            8 bytes
Result<[u8; 1024], anyhow::Error> 1032 bytes

A small enum with no payload larger than a u16 keeps the whole Result at 4 bytes. io::Error, Box<dyn Error> and anyhow::Error are each one pointer wide in the common case, so the Result is 16 bytes: the pointer plus a discriminant and the u16, padded. anyhow::Error is a thin pointer, so Option<anyhow::Error> fits in 8 bytes using the null-pointer niche. The last line shows the other direction: when the success type is large, the error costs nothing extra, because the Result is the payload plus a small tag.

The cases to watch are error enums that embed large fields by value, a String plus a path plus a Vec in a variant. The size of the Result follows the largest variant everywhere, including on the success path of every call. Clippy's result_large_err lint flags this, and the fix is to box the large variant's contents. The allocation happens only when an error occurs, which is the rare path. In the measurements above there is no case where choosing a richer error type made the successful path bigger than 16 bytes, which means "Rust errors are expensive" is not true of the representation. What costs time is building an error: allocating the box, formatting a message, capturing a backtrace if RUST_BACKTRACE is set. Do not construct errors in a hot loop that fails routinely, such as a parser that returns Err for every non-matching input while scanning. Return an Option or a small Copy enum there.

Panic or return

A panic is not an exception to catch. It is the process saying that the program is wrong. Return a Result for anything the environment can cause, and panic for broken internal invariants. The distinction in practice:

SituationChoose
File, network, user input, parseResult
A value you just proved is present (a bounds check one line above)expect("why this cannot fail")
A broken internal invariant, a branch the type system cannot rule outpanic! or unreachable!
TestsEither; a panic fails the test, which is the point

The next program shows what a panic does on a worker thread and on the main thread. It also uses ok_or to turn a missing map entry into an error:

src/main.rsrust
use std::collections::HashMap;
use std::thread;

fn first_even(v: &[i32]) -> Option<i32> {
    v.iter().copied().find(|n| n % 2 == 0)
}

fn main() {
    let ages: HashMap<&str, u32> = HashMap::from([("ada", 36)]);
    let age = ages.get("grace").copied().ok_or("no such user");
    println!("{age:?}");
    println!("{:?}", first_even(&[1, 3, 4]));

    let handle = thread::spawn(|| {
        let v: Vec<i32> = vec![1, 3, 5];
        first_even(&v).unwrap()
    });
    match handle.join() {
        Ok(n) => println!("got {n}"),
        Err(_) => println!("worker panicked, main is still running"),
    }
    let v: Vec<i32> = Vec::new();
    println!("{}", v[0]);
}
Text
Err("no such user")
Some(4)

thread '<unnamed>' (10328562) panicked at src/main.rs:16:24:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
worker panicked, main is still running

thread 'main' (10328554) panicked at src/main.rs:23:21:
index out of bounds: the len is 0 but the index is 0

The spawned thread calls unwrap() on a None and panics. The message is the one you will meet most often, and called Option unwrap on a None value explains how to trace it. The panic unwinds that thread only, and join() returns an Err carrying the panic payload, so the main thread prints that the worker died and carries on. Then v[0] on an empty vector panics on the main thread and ends the process, with the exit code 101, after the message about the index. Thread IDs and the line numbers will differ on your machine; the form does not.

Two things to take from that. A panic in a worker is recoverable at the thread boundary through join, and some servers use that to survive a bad request, but a panic that unwinds through a lock leaves the mutex poisoned and every later lock().unwrap() will panic too. That is a good argument against using panics as control flow. And panics are not guaranteed to unwind: building with panic = "abort" in the release profile makes any panic terminate the process at once, which cuts binary size and means catch_unwind and thread-level recovery stop working. Code that must survive a bad input should return a Result.

The simplest policy that holds up: in a library, never panic on input the caller can supply, and document the preconditions where you do. In an application, treat unwrap in non-test code as a claim that needs a reason, and write that reason in expect. If the reason is "this cannot happen", it is a candidate for restructuring so that it really cannot, for example by parsing a string into a validated type once at the edge and passing the type around.

Where errors cross layers

The errors that bite in a service are the ones that cross a boundary: a database error that becomes an HTTP 500, a validation failure that becomes a 400. Each layer should own its error type and convert at the seam, and not leak the layer below. The pattern, with an example, is in error boundaries between layers. For web services, Axum error handling shows how to implement IntoResponse for an application error so that ? works in handlers, and reqwest error handling covers the client side, where timeouts, status codes and body decoding are three different failures. Test the failure paths too, including panics that are supposed to happen; see testing panics.

When this advice does not apply

The split of typed errors for libraries and anyhow for applications is a default and has edges. A large application with several teams often wants typed errors inside its own crates too, because a module boundary there is a library boundary, and a worker pool that retries only transient failures needs to know which failures are transient. If you find yourself downcasting an anyhow::Error in several places to decide what to do, you have found a missing enum.

On the other side, a small command-line tool does not need an error enum at all, and writing one is time spent on a distinction nobody will use. Use anyhow from the first line and move on.

Code with no allocator, such as firmware, can use neither Box<dyn Error> nor anyhow by default; there the enum with no heap data is the only option, and the 4-byte result above is the reason it works well. And this guide does not cover retry policies, error reporting to services such as Sentry, or the unstable try blocks. The primary reference is the error handling chapter of the Rust book, with the details of ? in the Rust reference.

In this series

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.