When I first started with Rust, my code was littered with .unwrap() and .expect("this should never happen"). I thought I was being efficient, but in reality, I was building a house of cards. Every unwrap() is a potential crash waiting for a production edge case to trigger it.

Implementing rust error handling best practices isn’t just about stopping the crashes; it’s about creating a contract between your functions and the caller. In Rust, errors are not exceptions—they are values. This fundamental shift allows us to handle failure explicitly and predictably.

The Challenge: The ‘Panic’ Temptation

The biggest challenge for developers migrating from Java, Python, or TypeScript is the absence of try-catch blocks. In those languages, you can throw an error and hope something higher up the stack catches it. In Rust, the compiler forces you to acknowledge the possibility of failure.

I’ve found that the ‘Panic Temptation’ usually manifests in three ways:

Solution Overview: The Rust Error Hierarchy

To implement professional error handling, I divide errors into two categories: unrecoverable and recoverable. For unrecoverable errors (like a corrupted internal state), panic! is appropriate. For everything else, we use the Result<T, E> enum.

The goal is to move from a fragile state to a resilient one by utilizing the ? operator and specialized crates. This approach aligns with broader rust design patterns best practices, emphasizing explicitness over magic.

Techniques for Robust Error Handling

1. Leveraging the Question Mark Operator

The ? operator is the crown jewel of Rust error handling. It allows you to return an error to the caller immediately if a operation fails, without writing verbose match statements. Here is how I typically structure a database call using rust sqlx tutorial postgres patterns:

async fn get_user_email(pool: &PgPool, id: i32) -> Result<String, MyError> {
    let user = sqlx::query!("SELECT email FROM users WHERE id = $1", id)
        .fetch_one(pool)
        .await? // Propagates sqlx::Error and converts it to MyError
        .map(|row| row.email);

    Ok(user)
}

2. Distinguishing between ‘anyhow’ and ‘thiserror’

One of the most confusing parts for beginners is knowing which crate to use. In my experience, the rule of thumb is: thiserror for libraries, anyhow for applications.

thiserror allows you to define custom, strongly-typed errors. This is critical for libraries where the user needs to know why something failed (e.g., Error::NetworkTimeout vs Error::InvalidInput).

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataError {
    #[error("io error occurred: {0}")]
    Io(#[from] std::io::Error),
    #[error("invalid header expected {expected}, found {found}")]
    InvalidHeader { expected: String, found: String },
    #[error("unknown data error")]
    Unknown,
}

anyhow, on the other hand, provides a flexible, type-erased error type. It’s perfect for the main() function or high-level application logic where you just want to report the error and provide context without defining 20 different enum variants.

Implementation: A Production-Ready Pattern

When building a production service, I combine these techniques. I define domain-specific errors with thiserror in my core logic and use anyhow::Context at the top level to add human-readable breadcrumbs to the error chain.

As shown in the diagram below, the error bubbles up from the low-level IO, gets wrapped in a domain error, and finally receives application context before being logged.

Sequence diagram showing the propagation of an error from a Postgres DB call through thiserror wrappers to an anyhow context in main()
Sequence diagram showing the propagation of an error from a Postgres DB call through thiserror wrappers to an anyhow context in main()

Pitfalls to Avoid