Rust Error Handling: 8 Proven Patterns Every Developer Should Know

Learn 8 Rust error handling patterns — from custom error types to testing failure paths — that keep your code clean, expressive, and crash-free. Start writing better Rust today.

Rust Error Handling: 8 Proven Patterns Every Developer Should Know

I still remember the first time I tried to read a file in Rust and the compiler scolded me with a Result type. It felt rude, but it was actually a kindness. In many languages, you can forget to check if a file exists and your program crashes at runtime with a cryptic stack trace. Rust forces you to think about failure at compile time. This might seem like extra work, but after you get used to it, you start to see errors not as annoyances but as guides. They tell you exactly what can go wrong and what you need to do about it. Over time, I developed a set of patterns that make error handling clearer, less repetitive, and even pleasant. I want to share eight of them with you, explained in the simplest way possible. I’ll show you the code, I’ll tell you why I do it, and I’ll try not to use any big words.

The first pattern is to define your own error type. You could use the standard library’s std::io::Error for everything, but that doesn’t tell you much. When I see AppError::InvalidInput("user id is negative"), I know exactly what happened, and I can write code to handle it differently than a file not found error. A custom error type is just an enum that lists all the kinds of failures your program can have. Then you implement Display and Debug, and optionally the Error trait so that you can chain errors together. The thiserror crate can write all that boring code for you, but even if you do it by hand, it’s not hard.

use std::fmt;
use std::error::Error;

#[derive(Debug)]
pub enum MyError {
    FileOpenError(std::io::Error),
    ParseNumberError(std::num::ParseIntError),
    ConfigurationMissing(String),
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MyError::FileOpenError(e) => write!(f, "could not open file: {}", e),
            MyError::ParseNumberError(e) => write!(f, "could not parse number: {}", e),
            MyError::ConfigurationMissing(key) => write!(f, "missing config key: {}", key),
        }
    }
}

impl Error for MyError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            MyError::FileOpenError(e) => Some(e),
            MyError::ParseNumberError(e) => Some(e),
            MyError::ConfigurationMissing(_) => None,
        }
    }
}

Now when a function returns Result<i32, MyError>, you know what kind of error to expect. You can match on it, you can print it, you can even send it over the network. It becomes a contract between your function and its callers.

The second pattern is to implement From for each external error type. This is what makes the question mark operator work. If you write let s = std::fs::read_to_string(path)?; and read_to_string returns std::io::Error, but your function returns Result<Something, MyError>, the compiler will look for a From<std::io::Error> implementation for MyError. If you provide it, the conversion happens automatically. Your code stays clean and you don’t have to wrap every call with .map_err(). This is one of those small savings that adds up.

impl From<std::io::Error> for MyError {
    fn from(e: std::io::Error) -> Self {
        MyError::FileOpenError(e)
    }
}

impl From<std::num::ParseIntError> for MyError {
    fn from(e: std::num::ParseIntError) -> Self {
        MyError::ParseNumberError(e)
    }
}

fn read_number_from_file(path: &str) -> Result<i32, MyError> {
    let content = std::fs::read_to_string(path)?; // io::Error becomes FileOpenError
    let num: i32 = content.trim().parse()?;       // ParseIntError becomes ParseNumberError
    Ok(num)
}

Notice how I didn’t write any match or unwrap inside the function. The ? operator does the conversion, and if an error occurs, it returns immediately. The caller gets a clean error with the right variant.

The third pattern is adding context with anyhow when you are writing an application or a binary. In library code, you want specific error types so callers can react differently. But in the main executable, you often just want to print a nice error message and maybe exit. The anyhow crate gives you anyhow::Error and a .context() method. You can write .with_context(|| "failed to read config") and the error will contain that message plus the original cause. It becomes a chain of explanations.

use anyhow::{Context, Result};

fn load_user_data(user_id: u32) -> Result<UserProfile> {
    let path = format!("users/{}.json", user_id);
    let data = std::fs::read_to_string(&path)
        .with_context(|| format!("unable to open profile file for user {}", user_id))?;
    let profile: UserProfile = serde_json::from_str(&data)
        .with_context(|| "profile file has invalid JSON")?;
    Ok(profile)
}

When an error reaches the top of your program, you can print it using {:#} which shows the whole chain. For example, the program might print: “unable to open profile file for user 42: No such file or directory (os error 2)“. That is much more helpful than just “No such file or directory”.

The fourth pattern is knowing when to use Option vs Result. Option is for things that may be missing, like a value in a hash map. Result is for things that can fail with a reason, like opening a file. Sometimes you need to turn an Option into a Result to attach an error message. You can use .ok_or_else(|| "user not found"). Also, if you have a Result<Option<T>, E>, you can use .transpose() to swap the layers. I once spent an hour debugging a situation where I used Option to represent a missing file, but then I couldn’t tell the user why the file was missing – permission issue? does not exist? Using Result forced me to be explicit.

fn find_key_in_database(db: &Database, key: &str) -> Result<String, MyError> {
    db.lookup(key)
        .ok_or_else(|| MyError::ConfigurationMissing(format!("key '{}' not present", key)))
}

Now if the key is missing, we get a proper error that tells exactly which key. Our caller can either handle that or propagate it.

The fifth pattern is handling multiple error types without losing information. I’ve already shown the enum approach, which is my favourite because it keeps every error variant typed. But sometimes you are writing a quick script or a prototype and you don’t want to define an enum for every possible error from different libraries. In that case, you can use Box<dyn Error + Send + Sync>. This is a bit like throwing everything into a bag – you lose the ability to match on specific errors, but you gain simplicity. Use it only in top-level functions, not in library APIs.

fn run_migration() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let sql = std::fs::read_to_string("migration.sql")?;
    let conn = rusqlite::Connection::open("data.db")?;
    conn.execute_batch(&sql)?;
    Ok(())
}

Here, std::fs::read_to_string gives an io::Error, rusqlite::Connection::open gives a rusqlite::Error, but they all get boxed into the same error type. The caller can still print the error, but cannot branch on the cause.

The sixth pattern is about panics. Panics are for bugs, not for recoverable failures. If you write unwrap() on a Result that might fail because of user input, you are creating a time bomb. I learned this the hard way when my web server crashed because someone sent a malformed request and I had used unwrap() on a JSON parser. Now I only panic when I am sure the situation is impossible, like indexing into a vector that I know is non-empty because I just checked its length. Even then, I prefer expect("message") over unwrap() because the message documents the assumption. And for truly unreachable states, I use unreachable!().

fn safe_divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        panic!("cannot divide by zero – this is a bug in the caller");
    }
    a / b
}

If b could be zero because of user input, then the function should return a Result. The panic is only appropriate if the caller guarantees b != 0.

The seventh pattern is using .map_err() when you need to transform an error in the middle of a chain of operations. The question mark operator is great, but sometimes you want to add context or change the error type without early returning. For example, when iterating over a collection and collecting results, you might want to convert errors at the same time.

fn parse_numbers(strings: &[&str]) -> Vec<Result<i32, String>> {
    strings.iter()
        .map(|s| {
            s.parse::<i32>()
                .map_err(|e| format!("could not parse '{}': {}", s, e))
        })
        .collect()
}

fn main() {
    let inputs = vec!["10", "twenty", "30"];
    for result in parse_numbers(&inputs) {
        match result {
            Ok(n) => println!("Parsed: {}", n),
            Err(msg) => eprintln!("Error: {}", msg),
        }
    }
}

Notice that the map_err turns the ParseIntError into a String. This is a quick way to add a human-readable message without defining a custom type. It is not suitable for library code because the caller cannot programmatically inspect the error, but for one-off scripts it works fine.

The eighth and final pattern is testing error paths. Your error handling code is only as good as your test suite. I always write tests that verify the correct error variant is returned for specific failure conditions. I also test that context messages contain the expected text. For functions that should panic, I use #[should_panic(expected = "...")] sparingly – only for true invariants. Most of the time, I test Result returns.

#[test]
fn test_read_number_from_file_missing() {
    let result = read_number_from_file("nonexistent.txt");
    assert!(result.is_err());
    match result.unwrap_err() {
        MyError::FileOpenError(_) => {} // correct variant
        other => panic!("expected FileOpenError, got {:?}", other),
    }
}

#[test]
fn test_safe_divide_by_zero() {
    // If safe_divide were to panic for zero, we'd test like this:
    // But better: make it return Result.
    // This test shows how to check a panic.
    #[should_panic(expected = "cannot divide by zero")]
    fn panics_on_zero() {
        safe_divide(1.0, 0.0);
    }
    panics_on_zero();
}

Testing error paths gives you confidence that when something goes wrong, your program will respond in a predictable, documented way. And when you refactor your error types, the tests catch the breakage immediately.

I have been using these eight patterns for years. They evolved as I built small CLI tools, web services, and data processing pipelines. Each pattern solves a specific pain point: custom types give you clarity, From conversions keep code clean, anyhow makes application error messages friendly, Option vs Result forces you to think about missing vs failing, boxing errors is a pragmatic shortcut, panics are reserved for bugs, map_err gives you fine-grained control in iterator chains, and tests lock in the behavior.

If you are new to Rust, start with the first pattern. Define a little enum with two or three variants. Then add From implementations for the errors you encounter the most. That alone will make your code more readable and robust. Over time, you will find yourself reaching for the other patterns naturally. The compiler will catch the mistakes you didn’t know you were making, and the ? operator will make your functions read like a story of success and failure. I promise you, after a few weeks, you will start missing this explicitness when you go back to other languages. Errors become your friends.


// Keep Reading

Similar Articles