rust

Rust Error Handling: 8 Patterns Every Developer Should Know

Master Rust error handling with 8 proven patterns. Learn Result types, the ? operator, thiserror, anyhow, and more. Write reliable code with clear, actionable errors.

Rust Error Handling: 8 Patterns Every Developer Should Know

I remember the first time I tried to handle errors in Rust. I came from a world where exceptions just worked—or at least I thought they did. You’d wrap a block in a try-catch, and if something blew up, you’d catch it somewhere vaguely and hope for the best. Rust looked at me with cold, unblinking eyes and said: “No. You must be explicit.” It was frustrating at first. But now, after years of writing Rust, I can tell you that this honesty is what makes the language so reliable. Every function that can fail declares that fact in its signature. No surprises at runtime. If you feel lost, that’s okay. I’m going to walk you through eight patterns that will turn Rust’s error handling from a chore into something you actually enjoy writing. I’ll keep everything simple, with lots of code, and I’ll never use fancy words you’d need a dictionary for.

Let’s start with the most basic idea. If a function might fail, it should return a Result<T, E>. That’s the type that says “I might give you a value of type T, or I might give you an error of type E.” It’s a forced conversation between your code and the person calling it. I cannot just ignore a Result because the compiler will warn you if you don’t handle it. Here’s a concrete example. Imagine we want to read a configuration file. It could be missing, corrupted, or we might lack permissions. Instead of panicking, we return a Result:

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

fn read_config(path: &str) -> Result<String, std::io::Error> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

See that ? operator? That’s the second pattern I’ll show you in a moment. For now, notice the signature: it says plainly “this function can give you a String or an IO error.” No hidden exceptions. When I call this function, I have to do something with the Result. I could match on it, or use ? again, or handle the error with a match. The compiler will not let me forget.

But what if your program can fail in many different ways? For example, you’re parsing user input, connecting to a database, and validating fields. You don’t want a separate error type for each operation because then your functions would return different types and you’d have to convert them all over the place. Instead, define your own error type using an enum. Each variant represents a different kind of failure. I do this for almost every non-trivial Rust project. Here’s a simple one:

use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
    Validation { field: String, message: String },
}

I can then implement From for each underlying error type so that the ? operator can convert them automatically. The compiler does the heavy lifting. Now think about a function that reads a CSV line, splits it, and parses an integer. It might fail due to IO, bad integer, or wrong number of fields. My AppError handles all three:

impl From<io::Error> for AppError {
    fn from(err: io::Error) -> AppError {
        AppError::Io(err)
    }
}

impl From<ParseIntError> for AppError {
    fn from(err: ParseIntError) -> AppError {
        AppError::Parse(err)
    }
}

fn process_line(line: &str) -> Result<i32, AppError> {
    let parts: Vec<&str> = line.split(',').collect();
    if parts.len() < 2 {
        return Err(AppError::Validation {
            field: "line".to_string(),
            message: "Expected at least 2 fields".to_string(),
        });
    }
    let value: i32 = parts[1].trim().parse()?;
    Ok(value)
}

The parse() returns a Result<i32, ParseIntError>, but because we implemented From<ParseIntError>, the ? turns it into AppError::Parse. The caller only sees one error type. This pattern is clean and scales well. If I later need to handle a network error, I just add a variant and a From impl.

Now, about that ? operator. It’s the most common way to propagate errors. When you see a ? after a Result, it means: “If this is an Ok, give me the value inside. If it’s an Err, return that error from the current function immediately.” This keeps your code linear and readable. I remember when I first used it, I thought it was magic. Actually, it’s just syntactic sugar for a match that returns early. Let me show you what it expands to:

fn read_config(path: &str) -> Result<String, std::io::Error> {
    let mut file = match File::open(path) {
        Ok(f) => f,
        Err(e) => return Err(e.into()), // note the .into() for conversion
    };
    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => {},
        Err(e) => return Err(e.into()),
    }
    Ok(contents)
}

Using ? is much shorter. But note: ? works only in functions that return Result, Option, or a type that implements Try. If you try to use it in main(), you need to give main() a return type, like Result<(), Box<dyn std::error::Error>>. That’s fine.

But writing custom error types with all those From implementations can be tedious. That’s where the thiserror crate comes in. It’s a derive macro that generates the boilerplate for you. You just write the enum, annotate with #[derive(Error)], and give each variant a #[error("...")] message. It’s like having a personal assistant who writes the boring parts.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("Connection failed: {0}")]
    ConnectionFailed(String),

    #[error("Query error (code {code}): {sql}")]
    QueryError { sql: String, code: i32 },

    #[error("Transaction conflict for id {0}")]
    TransactionConflict(u64),

    #[error("Operation timed out after {0:?}")]
    Timeout(std::time::Duration),

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

Now I have a professional error type with almost no code. The #[from] attribute automatically generates the From implementation. The #[error("...")] gives a Display format. I use this in every project that is not a tiny script. It’s simple, it’s clear, and it makes your error messages consistent.

Not every error needs to be handled gracefully. Sometimes I know for sure that a value will exist, or the program cannot meaningfully continue without it. In those cases, I can use expect or unwrap. expect lets me attach a message explaining why it’s okay to crash. I think of it as a controlled explosion – I know where and why it will blow up. For example, reading an environment variable that must be set:

fn main() {
    let db_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
    println!("Connecting to {}", db_url);
}

If someone runs the program without that variable, it panics with a clear message. I would never use unwrap in production unless I was absolutely sure it cannot fail (like when parsing a hardcoded number). In tests, unwrap is fine – it’s a test failure, not a runtime crash. But in general, I prefer expect because the message helps debugging. This pattern is simple: crash early with a good reason.

But what about application code where you don’t care about the exact error type, you just want to add context and bubble it up? The anyhow crate is perfect for that. It provides a flexible Error type that can wrap any error. You can attach messages using the with_context method, forming a chain of explanations. When you print the error, you get the whole story.

use anyhow::{Context, Result};

fn read_user_data(user_id: u64) -> Result<User> {
    let path = format!("/data/users/{}.json", user_id);
    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("Failed to read user file at {}", path))?;
    let user: User = serde_json::from_str(&content)
        .with_context(|| format!("Failed to parse user data for id {}", user_id))?;
    Ok(user)
}

fn main() -> Result<()> {
    let user = read_user_data(42)
        .context("Could not load user 42")?;
    println!("User: {}", user.name);
    Ok(())
}

Notice I didn’t define any custom error type. The function returns anyhow::Result<User>, which is just Result<User, anyhow::Error>. The anyhow::Error can hold any error, and I add text at each step. If something fails, the final error message will say something like “Could not load user 42: Failed to read user file at /data/users/42.json: No such file or directory”. This is extremely helpful for debugging without cluttering your business logic. I use anyhow in tools, command-line applications, and anywhere that I don’t need to handle specific error variants differently.

Now, once your errors reach the boundaries of your system – the point where external users see them or you log them – you need to present them properly. This is the sixth pattern: log errors with structured context. Don’t just print the error message. Include key details like the user ID, request ID, timestamp, and the full chain of causes. I like to use the log crate with error! and warn! macros. Here’s an example where I process a payment:

use log::{error, warn};
use std::error::Error;

fn handle_request(user_id: u64, data: &[u8]) -> Result<Response, AppError> {
    match process_payment(user_id, data) {
        Ok(response) => Ok(response),
        Err(err) => {
            error!(
                target: "payments",
                user_id = user_id,
                "Payment processing failed: {}",
                err
            );
            // log the whole cause chain
            let mut source = err.source();
            while let Some(cause) = source {
                warn!("Caused by: {}", cause);
                source = cause.source();
            }
            Err(err)
        }
    }
}

But what about the user? They don’t need to see the full stack trace. I usually create a separate function that converts the internal error to a user-friendly message. For AppError, I might map variants to simple, helpful strings. Don’t leak internal details like file paths or database queries. Keep it kind:

fn format_user_error(err: &AppError) -> String {
    match err {
        AppError::PaymentDeclined { .. } => "Your payment was declined.".to_string(),
        AppError::RateLimited { retry_after } => {
            format!("Too many requests. Please try again in {} seconds.", retry_after)
        }
        _ => "An unexpected error occurred. We're looking into it.".to_string(),
    }
}

The last pattern is about converting between error types seamlessly. You already saw a bit of this with From and thiserror. But the power really shines when you have a function that calls multiple subroutines that return different error types. As long as each of those error types implements From for your custom type, the ? operator will convert them automatically. I gave an example earlier with StartupError – worth showing again because it’s so elegant:

fn startup() -> Result<(), StartupError> {
    let port_str = std::fs::read_to_string("port.txt")?;
    let port: u16 = port_str.trim().parse()?;
    let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port).parse()?;
    println!("Listening on {}", addr);
    Ok(())
}

Each ? produces a different error type (io::Error, ParseIntError, AddrParseError), but the compiler knows how to convert each one to StartupError. The function signature stays clean. This pattern reduces boilerplate a lot.

Now, I’ve given you eight patterns. Let’s see them all together in a small, real-world example. Imagine I’m building a simple web server that reads a JSON config file, starts listening, and handles requests. I’ll use thiserror for the custom error, anyhow in the top-level function for simplicity, and From conversions to chain them.

use thiserror::Error;
use anyhow::{Context, Result};
use std::fs;
use serde::Deserialize;
use std::net::TcpListener;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Failed to read config file: {0}")]
    Io(#[from] std::io::Error),
    #[error("Invalid JSON: {0}")]
    Json(#[from] serde_json::Error),
}

#[derive(Deserialize)]
struct Config {
    host: String,
    port: u16,
}

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let data = fs::read_to_string(path)?;
    let config: Config = serde_json::from_str(&data)?;
    Ok(config)
}

fn start_server(config: Config) -> Result<(), std::io::Error> {
    let addr = format!("{}:{}", config.host, config.port);
    let listener = TcpListener::bind(addr)?;
    println!("Listening on {}:{}", config.host, config.port);
    // accept connections...
    Ok(())
}

fn main() -> Result<()> {
    let config = load_config("server.json")
        .context("Failed to load server configuration")?;
    start_server(config)
        .context("Failed to start server")?;
    Ok(())
}

I love how each piece fits. load_config returns a Result with ConfigError which uses automatic From for IO and JSON errors. start_server returns io::Error. In main, both are wrapped in anyhow context. If something fails, the user sees a helpful message, and I can inspect the chain of errors.

So, when should you use each pattern? Use Result<T, E> for every function that can fail. Define custom enum errors with thiserror for libraries or when you need to handle different failures differently. Use ? everywhere to propagate errors. Use expect only when you are sure it’s safe or when the failure indicates a bug. Use anyhow in application code or in main() to reduce the boilerplate of defining custom errors. Log errors at the boundaries with structured context, and convert errors to user-friendly messages before showing them to end users. Implement From to make your error types interoperable.

I’ve been using Rust for years now, and I still follow these patterns. They make my code more reliable and easier to maintain. The compiler becomes my ally, catching missing error handling before the code even runs. And when something does go wrong in production, the error messages are clear and actionable. If you take these patterns to heart, you’ll find that Rust’s approach to errors is not a burden but a superpower.

Keywords: Rust error handling, Rust Result type, Rust error types, Rust error propagation, Rust anyhow crate, Rust thiserror crate, Rust custom error types, Rust question mark operator, Rust error handling patterns, Rust From trait errors, Rust error conversion, Rust unwrap vs expect, Rust error handling tutorial, Rust error handling best approach, Rust enum error types, Rust error chaining, Rust error handling for beginners, Rust error handling in production, how to handle errors in Rust, Rust Result and Option, Rust error handling 2024, Rust error handling examples, Rust io::Error handling, Rust error handling with match, Rust propagate errors, Rust error handling crates, Rust error handling guide, Rust application error handling, Rust structured error logging, Rust error message formatting, Rust error handling vs exceptions, Rust panic vs Result, Rust error handling library code, Rust error handling real world, Rust Box dyn Error, Rust error handling async, Rust thiserror vs anyhow, when to use anyhow in Rust, Rust error handling enum variants, Rust error handling web server



Similar Posts
Blog Image
Exploring the Future of Rust: How Generators Will Change Iteration Forever

Rust's generators revolutionize iteration, allowing functions to pause and resume. They simplify complex patterns, improve memory efficiency, and integrate with async code. Generators open new possibilities for library authors and resource handling.

Blog Image
The Quest for Performance: Profiling and Optimizing Rust Code Like a Pro

Rust performance optimization: Profile code, optimize algorithms, manage memory efficiently, use concurrency wisely, leverage compile-time optimizations. Focus on bottlenecks, avoid premature optimization, and continuously refine your approach.

Blog Image
7 Rust Features That Boost Code Safety and Performance

Discover Rust's 7 key features that boost code safety and performance. Learn how ownership, borrowing, and more can revolutionize your programming. Explore real-world examples now.

Blog Image
Implementing Binary Protocols in Rust: Zero-Copy Performance with Type Safety

Learn how to build efficient binary protocols in Rust with zero-copy parsing, vectored I/O, and buffer pooling. This guide covers practical techniques for building high-performance, memory-safe binary parsers with real-world code examples.

Blog Image
Mastering Rust's Procedural Macros: Boost Your Code's Power and Efficiency

Rust's procedural macros are powerful tools for code generation and manipulation at compile-time. They enable custom derive macros, attribute macros, and function-like macros. These macros can automate repetitive tasks, create domain-specific languages, and implement complex compile-time checks. While powerful, they require careful use to maintain code readability and maintainability.

Blog Image
8 Advanced Rust Macro Techniques for Building Production-Ready Systems

Learn 8 powerful Rust macro techniques to automate code patterns, eliminate boilerplate, and catch errors at compile time. Transform your development workflow today.