Error Handling in Rust: anyhow and thiserror

Author

Caroline Morton

Date

January 25, 2026

This is part two of my error handling series for Women in Rust (here’s part one). It stands alone as a reference, but we’ll build on examples from the previous post.

Last time, we hit a wall: the ? operator made our code clean but stripped out our context. This post fixes that with two crates - anyhow for quick, contextual errors, and thiserror for when you need callers to handle errors differently.

Hitting a wall

Using anyhow

Anyhow is a great crate that I’d highly recommend for regaining the benefits of ? (shorter, more ergonomic code) without sacrificing contextual error messages.

It’s a bit of a pain to write function signatures like this:

fn my_function(filename: &str) -> Result<String, Box<dyn std::error::Error>> {
    // ...
}

If you remember from the previous post, we use Box<dyn std::error::Error> to allow any error type generated within the function to be returned. This means our function can do multiple things that return different error types without running into type mismatches.

Let’s look at the tension we had in our last post. We started with this:

use std::fs::File;
use std::io::Read;

fn read_number_from_file(filename: &str) -> Result<i32, String> {
    let mut file = match File::open(filename) {
        Ok(f) => f,
        Err(e) => return Err(format!("could not open file '{}': {}", filename, e)),
    };

    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => {},
        Err(e) => return Err(format!("could not read file '{}': {}", filename, e)),
    };

    match contents.trim().parse::<i32>() {
        Ok(n) => Ok(n),
        Err(e) => Err(format!("could not parse '{}' as integer: {}", contents.trim(), e)),
    }
}

This was great because we had contextual error messages for what had gone wrong at each stage. But the problem was it got very verbose quickly, and the multiple match statements were not very ergonomic.

We moved to use ?:

use std::fs::File;
use std::io::Read;

fn read_number_from_file(filename: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}

More ergonomic but we lost the context around the errors. This is where anyhow comes in. We can have our cake and eat it too.

First we import anyhow by adding it to our project in the Cargo.toml:

anyhow = "1.0"

We can then swap out standard Result for anyhow::Result. This allows us to drop the Box<dyn std::error::Error>.

use std::fs::File;
use std::io::Read;

use anyhow::Result;

fn read_number_from_file(filename: &str) -> Result<i32> {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}

You can think of anyhow::Result<T> as filling the same role as Result<T, Box<dyn std::error::Error>>, but with much better ergonomics and built-in context support. It’s a type alias for Result<T, anyhow::Error> - but you rarely need to think about that, unless you want to!

To add context back in, we import the Context trait. This gives us two methods:

  • .context("something went wrong") - for simple, static messages
  • .with_context(|| format!("could not open '{}'", filename)) - for dynamic messages that include variables (it takes a closure, so the message is only constructed if there’s actually an error).
use std::fs::File;
use std::io::Read;
use anyhow::{Context, Result};

fn read_number_from_file(filename: &str) -> Result<i32> {
    let mut file = File::open(filename)
        .with_context(|| format!("could not open file '{}'", filename))?;
    
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .with_context(|| format!("could not read file '{}'", filename))?;
        
    let number = contents.trim().parse::<i32>()
        .context("could not parse file contents as i32")?;
    
    Ok(number)
}

Making a one-off anyhow error

So far, we’ve been wrapping errors that came from somewhere else - file operations, parsing. But what if the code succeeds and the result just doesn’t make sense for our program?

The anyhow! macro lets us create an error from scratch. Say the number represents a user ID, so it must be positive:

if number < 1 {
    return Err(anyhow!("expected a number over 0, but got {}", number));
}

Now our final function looks much more sensible. We’ve made use of static error messages with .context(), dynamic error messages with .with_context(), and our own custom errors with anyhow!:

use std::fs::File;
use std::io::Read;
use anyhow::{anyhow, Context, Result};

fn read_number_from_file(filename: &str) -> Result<i32> {
    let mut file = File::open(filename)
        .with_context(|| format!("could not open file '{}'", filename))?;
    
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .with_context(|| format!("could not read file '{}'", filename))?;
    
    let number = contents.trim().parse::<i32>()
        .context("could not parse file contents as i32")?;

    if number < 1 {
        return Err(anyhow!("expected a number over 0, but got {}", number));
    }
    
    Ok(number)
}

Mixing expect with Result

One final discussion before we move on: should we ever combine expect with Result?

In an ideal world, no. I like to use Clippy with this explicitly forbidden, and I believe that is quite common.

That said, it’s your choice. In the previous post, I recommended only using them in specific circumstances - like in tests, or on the very rare occasions when you know more than the compiler. For example:

let localhost: Ipv4Addr = "127.0.0.1"
    .parse()
    .expect("hardcoded IP address should be valid");

We know this will never fail. The string is hardcoded and valid, but the compiler can’t verify that, so we use expect with a message explaining our reasoning. It is worth emphasising here that you should very, very rarely (if ever!) consider that you “know more than the compiler”! Usually the compiler is completely correct and it is worth getting into the habit of working with it, rather than against it. Here be dragons if you think you know better than the compiler.

Here are dragons

Let’s imagine our function is reading a port number from a file. (An i32 is overkill for a port, but let’s keep it for simplicity so we’re only changing one thing at a time.)

use std::fs::File;
use std::io::Read;
use std::net::Ipv4Addr;
use anyhow::{anyhow, Context, Result};

fn read_addr_from_file(filename: &str) -> Result<String> {
    let mut file = File::open(filename)
        .with_context(|| format!("could not open file '{}'", filename))?;
    
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .with_context(|| format!("could not read file '{}'", filename))?;
    
    let port = contents.trim().parse::<i32>()
        .context("could not parse file contents as i32")?;

    if port < 1000 {
        return Err(anyhow!("expected a port over 1000, but got {}", port));
    }

    let localhost: Ipv4Addr = "127.0.0.1"
        .parse()
        .expect("hardcoded IP address should be valid");
    
    let addr = format!("{localhost}:{port}");
    
    Ok(addr)
}

Equally, we could have used .context() or the anyhow! macro here instead:

let localhost: Ipv4Addr = "127.0.0.1"
    .parse()
    .context("hardcoded IP address should be valid")?;

The choice is yours. Maybe you always parse addresses this way in your codebase and don’t want to change the pattern for one function. Maybe you prefer the clarity of expect for “this literally cannot fail” situations.

We are now ready to move on to typed errors with thiserror. Just worth noting at this point that we are going to use the same example function as above, but this is a toy example to illustrate the concepts. In real life, you would not normally be reading a port number from a file like this!

Using thiserror

We have just seen how to create useful, low-effort contextual error messages using anyhow. But anyhow has a limitation: all errors become the same type. The caller can’t easily distinguish between “file not found” and “invalid port number” - they just get an error to display. Sometimes that’s fine. Often, actually. But sometimes callers need to handle different errors differently and for that you want typed errors they can match on.

The traditional approach is to define an enum with a variant for each error case:

pub enum AddressError {
    FileOpenError,
    FileUnreadableError,
    PortParseError,
    PortTooLowError,
    LocalHostError,
}

But this loses all the underlying error information. For example, for the error opening the file to read it - was it that the file was not found, or was it a permissions issue? We don’t know.

We can wrap the original errors to preserve them:

pub enum AddressError {
    FileOpenError { filename: String, source: std::io::Error },
    FileUnreadableError { filename: String, source: std::io::Error },
    PortParseError(ParseIntError),
    PortTooLowError(i32),
    LocalHostError(AddrParseError),
}

Now we need to implement the Display trait so that we can actually show the error to the caller. We also need to implement the std::error::Error trait so that rust knows our custom error type is a proper error. This makes it compatible with both ? and Box<dyn Error>.

impl fmt::Display for AddressError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AddressError::FileOpenError { filename, source } => {
                write!(f, "could not open file '{}': {}", filename, source)
            }
            AddressError::FileUnreadableError { filename, source } => {
                write!(f, "could not read file '{}': {}", filename, source)
            }
            AddressError::PortParseError(e) => {
                write!(f, "could not parse port number: {}", e)
            }
            AddressError::PortTooLowError(port) => {
                write!(f, "expected a port over 1000, but got {}", port)
            }
            AddressError::LocalHostError(e) => {
                write!(f, "invalid localhost address: {}", e)
            }
        }
    }
}

impl std::error::Error for AddressError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AddressError::FileOpenError { source, .. } => Some(source),
            AddressError::FileUnreadableError { source, .. } => Some(source),
            AddressError::PortParseError(e) => Some(e),
            AddressError::PortTooLowError(_) => None,
            AddressError::LocalHostError(e) => Some(e),
        }
    }
}

fn read_addr_from_file(filename: &str) -> Result<String, AddressError> {
    let mut file = File::open(filename)
        .map_err(|e| AddressError::FileOpenError {
            filename: filename.to_string(),
            source: e,
        })?;
    
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .map_err(|e| AddressError::FileUnreadableError {
            filename: filename.to_string(),
            source: e,
        })?;
    
    let port: i32 = contents.trim().parse()
        .map_err(AddressError::PortParseError)?;

    if port < 1000 {
        return Err(AddressError::PortTooLowError(port));
    }

    let localhost: Ipv4Addr = "127.0.0.1".parse()
        .map_err(AddressError::LocalHostError)?;
    
    Ok(format!("{localhost}:{port}"))
}

This works but it is a lot of boilerplate code and we have to keep it all in sync as we add or change variants. This is where thiserror comes in.

Adding thiserror

You will need to add thiserror to your Cargo.toml. We can then use it to essentially derive most of that boilerplate code.

use std::{
    fs::File,
    io::Read,
    net::{AddrParseError, Ipv4Addr},
    num::ParseIntError,
};
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AddressError {
    #[error("could not open file '{filename}'")]
    FileOpenError {
        filename: String,
        #[source]
        source: std::io::Error,
    },
    
    #[error("could not read file '{filename}'")]
    FileUnreadableError {
        filename: String,
        #[source]
        source: std::io::Error,
    },
    
    #[error("could not parse port number")]
    PortParseError(#[from] ParseIntError),
    
    #[error("expected a port over 1000, but got {0}")]
    PortTooLowError(i32),
    
    #[error("invalid localhost address")]
    LocalHostError(#[from] AddrParseError),
}

fn read_addr_from_file(filename: &str) -> Result<String, AddressError> {
    let mut file = File::open(filename)
        .map_err(|e| AddressError::FileOpenError {
            filename: filename.to_string(),
            source: e,
        })?;
    
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .map_err(|e| AddressError::FileUnreadableError {
            filename: filename.to_string(),
            source: e,
        })?;
    
    let port: i32 = contents.trim().parse()?;

    if port < 1000 {
        return Err(AddressError::PortTooLowError(port));
    }

    let localhost: Ipv4Addr = "127.0.0.1".parse()?;
    
    Ok(format!("{localhost}:{port}"))
}

This is much shorter! Let’s look at what it is actually doing:

  • #[derive(Error, Debug)] - derives std::error::Error (via thiserror) and Debug (via the standard derive)
  • #[error("...")] - generates the Display implementation. You can reference fields by name ({filename}) or by position ({0}). To implement std::error::Error, a type must also implement Display and Debug. We are getting the Debug implementation for free via the derive, and Display via this attribute.
  • #[source] - tells thiserror which field contains the underlying error, so it can implement source() correctly
  • #[from] - this one’s magic. It implements From<ThatError> for your enum, which means ? works automatically without needing .map_err(). Notice how we just write .parse()? for the port and localhost lines now (it also marks the wrapped error as the source)

I have chosen not to use #[from] for the file errors because I want to capture the filename as extra context. But for simpler cases where the wrapped error is all you need, #[from] saves a lot of noise.

All in all this is pretty cool.

Adding your own functionality

tools

One final thing about using enums for errors: they’re yours to extend. Unlike panics or wrapped errors (like with anyhow), enum errors are just regular Rust types - which means you can add whatever methods you need to your toolbox.

For example, maybe our code is in a web app and we want to return an HTTP status code with our error. We can add a method that assigns each enum variant its own status code:

use http::StatusCode;

impl AddressError {
    pub fn status_code(&self) -> StatusCode {
        match self {
            AddressError::FileOpenError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
            AddressError::FileUnreadableError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
            AddressError::PortParseError(_) => StatusCode::BAD_REQUEST,
            AddressError::PortTooLowError(_) => StatusCode::BAD_REQUEST,
            AddressError::LocalHostError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

Now anywhere we handle an AddressError, we can call .status_code() to get the appropriate HTTP response. This pattern is really useful in web applications - your error types carry both the human-readable message (via Display) and the machine-readable status code.

Wrapping up

In this two-part series, we’ve worked from the fundamentals of Rust error handling all the way to professional-grade error types.

In Part One, we covered the distinction between recoverable and unrecoverable errors, when to reach for panic!, and how Result gives us explicit control over error handling. We saw how ? makes error propagation ergonomic - but at the cost of losing our contextual messages.

In this post, we resolved that tension. anyhow gave us the best of both worlds: the clean syntax of ? with rich, contextual error messages. Then thiserror took us further, letting us define typed error enums that callers can match on - without drowning in boilerplate.

People online have strong opinions about which error approach to use where. I won’t prescribe a single answer - but I will say the most important thing is consistency. Agree on a system with your team, write it down, and stick to it. That matters more than which specific crate you choose. Consistency keeps code readable and avoids multiple, conflicting error definitions scattered across a project.

In my own projects, I have reached for these loose rules:

  • Quick prototyping - anyhow
  • Libraries where callers need to do something different with different errors - thiserror
  • CLI tools - anyhow
  • Mixed library internal and a public api - thiserror for public errors with status codes usually, anyhow internally

The Rust error handling story can feel overwhelming at first, but it really comes down to two questions: does the caller need to distinguish between error types, and how much context do I want to preserve? Once you’ve answered those, the right tool becomes clear.

Happy error handling!

References

Related Posts

errors blue

Error Handling in Rust: Fundamentals

A clear, practical guide to Rust error handling: panic, Result, ?, unwrap, and expect - written for Rust developers who want clarity without jargon.

Read More
padlock green

What is Synthetic Data and Why Does it Matter?

This blog is the first in a series exploring synthetic data, its benefits, and its applications in various fields.

Read More
graph_1 yellow

A PhD in generating synthetic health data

This is an introduction to my PhD project and what I am hoping to achieve with it, which is to develop methods for generating realistic synthetic health data. This project is generously sponsored by SurrealDB, a multi-model database entirely written in Rust. I am using SurrealDB for a number of reasons, including its ability to do complex queries, vector searching and embedding functions that are useful for generating synthetic data.

Read More