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:
- Overusing
.unwrap()during rapid prototyping and forgetting to remove it. - Using
panic!for recoverable errors. - Creating overly generic error types (like
Box<dyn Error>) that make it impossible for the caller to programmatically react to specific failures.
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.
Pitfalls to Avoid
- Ignoring Results: Never ignore a
Result. If you intentionally want to ignore it, uselet _ = ...to signal to the compiler (and other devs) that this was a choice. - Over-wrapping: Don’t wrap every single single-line call in a new error type. Only create new variants when the caller can actually do something different based on that error.
- Using panic! in Libraries: This is a cardinal sin in Rust. Libraries should always return
Result; the application developer should decide if a failure warrants a panic.