← Back home

Error handling in Rust without losing your mind

Every Rust prototype starts the same way: unwrap() everywhere, because you just want to see the thing run. That is fine. The trouble is the slow drift from prototype to production, where each unwrap() becomes a 3 a.m. panic with a backtrace that tells you where it failed but never why. This is the path I take to climb out of that, one step at a time.

Step zero: understand what Result is asking

Rust has no exceptions. A function that can fail returns Result<T, E>, and the compiler refuses to let you ignore the error case. That is the entire safety story. The question is never "should I handle this" — it is "how do I propagate it without writing a match arm for every call."

fn read_config(path: &str) -> Result<Config, std::io::Error> {
    let text = std::fs::read_to_string(path)?; // ? returns the error early
    let cfg = parse(&text)?;                    // ...but only if E lines up
    Ok(cfg)
}

The ? operator is the workhorse: on Ok it unwraps the value, on Err it returns early from the function. The catch is that all those errors must convert into the function's declared error type. When read_to_string returns io::Error but parse returns ParseError, the two ? lines stop compiling. That friction is where people give up and reach for unwrap(). Do not. There are two clean answers.

For applications: anyhow

In a binary — a CLI, a service, anything that is the top of the call stack — you usually do not need to match on specific error variants. You need a single error type that anything can convert into, plus context for the logs. That is exactly what anyhow gives you.

use anyhow::{Context, Result};

fn load(path: &str) -> Result<Config> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("reading config at {path}"))?;
    let cfg = parse(&text)
        .context("parsing config")?;
    Ok(cfg)
}

Now any error type flows through one ?, and with_context turns the eventual log line from "No such file or directory" into "reading config at /etc/app.toml: No such file or directory." That context chain is the difference between a five-minute fix and an hour of guessing. The cost is that callers cannot programmatically distinguish error kinds — which is fine, because in an application they rarely need to.

For libraries: thiserror

A library is different. Your callers do want to match on what went wrong — retry on a timeout, give up on a parse failure. So a library should expose a real, typed error enum. Hand-writing the Display and From impls is tedious; thiserror generates them from attributes:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum StoreError {
    #[error("record {0} not found")]
    NotFound(u64),

    #[error("connection failed")]
    Connection(#[from] std::io::Error),

    #[error("invalid record: {0}")]
    Invalid(String),
}

The #[from] attribute is the quiet hero. It auto-generates From<io::Error> for StoreError, so a plain ? on any io::Error converts into StoreError::Connection for free. Your callers get a clean enum to match on; you get the same ergonomic ? you had with anyhow.

match store.fetch(id) {
    Ok(rec)                        => handle(rec),
    Err(StoreError::NotFound(_))   => default_record(),
    Err(StoreError::Connection(_)) => retry_later(),
    Err(e)                         => return Err(e),
}

The rule of thumb

Applications collapse errors into one type and add context. Libraries preserve error types so callers can decide. Use anyhow at the top, thiserror at the boundary.

When unwrap is actually fine

I am not against unwrap() on principle. It is correct when the failure genuinely cannot happen and you want a loud crash if your assumption is wrong. The honest move is to say so:

// A regex literal that is part of the program; if it does not
// compile, that is a bug to crash on at startup, not handle.
let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$")
    .expect("date regex is a compile-time constant");

Use expect over unwrap here so the panic message states the invariant you were relying on. A panic that says "date regex is a compile-time constant" is a code review note to your future self. A bare unwrap() is just a shrug.

Where I landed

  • Prototype freely with unwrap(), then pay it down before anything ships.
  • Reach for anyhow in binaries; add .context() at every I/O boundary.
  • Reach for thiserror in libraries; let #[from] do the conversions.
  • Keep expect for true invariants, and write the reason in the message.

None of this is clever. It is just consistent, and consistency is what keeps the 3 a.m. backtraces legible.


Filed under Rust. Spotted an error? Mail me — details on the about page.