Rust has no exceptions. A function that can fail says so in its return type, Result<T, E> or Option<T>, and the compiler will not let the caller ignore it. That single design choice removes the class of production incidents where an unhandled exception you never knew about takes down a service.
This guide covers the whole system as it stands in 2026: Option and Result, the ? operator, typed errors with thiserror, application errors with anyhow, and the patterns and mistakes that separate beginner code from production code.
By Max Wells, updated September 2026
TL;DR: Rust has no exceptions. Errors are values: either
Option<T>(value or nothing) orResult<T, E>(success or error). The?operator propagates errors up the call stack without boilerplate. For applications useanyhow; for libraries usethiserror. This system makes error handling explicit, composable, and impossible to accidentally ignore.
Option<T>: represents a value that might not exist:Some(value)orNoneResult<T, E>: represents success (Ok(value)) or failure (Err(error))?operator: returns early with the error ifErr, or unwrapsOk. It replaces try/catch boilerplate- Libraries:
thiserrorfor defining error types;anyhowfor application-level error handling
Who Should Read This?
This guide is for developers learning Rust who are coming from Python, JavaScript, Java, or Go: languages where error handling is handled via exceptions or return values: and want to understand how Rust's approach differs, why it's better, and how to apply it in production code.
Error handling is one of the first places where Rust feels genuinely different. The absence of exceptions is not a missing feature: it's a deliberate design that forces errors into the type system where they cannot be accidentally ignored. If you've ever been paged at 3am because an unhandled exception crashed a production service, Rust's approach will feel like a revelation.
This guide covers everything from the basic Option and Result types through the ? operator, custom error types with thiserror, and application-level error management with anyhow. By the end, you'll have the complete picture of how idiomatic Rust error handling works in 2026.
Why Does Rust Have No Exceptions?
Rust chose values over exceptions because exceptions are invisible in type signatures: you cannot tell by looking at a function whether it throws, what it throws, or when: and invisible errors cause production incidents.
Most languages use exceptions for error handling: a function throws, the runtime unwinds the stack, and a catch block somewhere handles it: or doesn't, causing an unhandled exception crash.
The problem: exceptions are invisible in function signatures. You can't tell from reading a function's type signature whether it can throw, what it throws, or under what conditions. This leads to uncaught exceptions in production. Java's checked exceptions tried to solve this but created so much boilerplate that engineers routinely suppress them. Python and JavaScript have no mechanism to enforce exception handling at all.
Rust's approach: errors are types. A function that can fail says so explicitly in its return type: Result<T, E>. The caller is forced to handle the error or explicitly propagate it. Nothing is hidden; everything is in the type system. If a function returns Result<User, DbError>, you know exactly what can go wrong and you must address it: the code won't compile if you don't.
This approach also makes error handling composable. You can map, chain, and transform errors the same way you transform any other value. This is more expressive than try/catch blocks, which can only be nested or re-thrown.
What Is Option?
Option<T> represents a value that might not exist: it replaces null, undefined, and None from other languages with a type that forces explicit handling at compile time.
Option<T> represents a value that might not exist:
enum Option<T> {
Some(T), // the value exists
None, // no value
}Use Option wherever you'd use null in other languages:
fn find_user(id: u32) -> Option<User> {
// returns Some(user) if found, None if not
}
// Using Option:
match find_user(42) {
Some(user) => println!("Found: {}", user.name),
None => println!("User not found"),
}
// Common Option methods:
let name = find_user(42).map(|u| u.name); // Some(name) or None
let name = find_user(42).unwrap_or("unknown"); // value or default
let name = find_user(42)?; // return None if None (in Option-returning fn)The crucial difference from null: Rust's type system tracks whether a value might be None. If a function returns Option<User>, you cannot access .name on it without first checking whether it's Some. There is no null pointer exception: the compiler won't let you accidentally dereference None.
This eliminates an entire category of production bugs. Tony Hoare, who invented null references in 1965, called it his "billion dollar mistake." Rust eliminates the mistake at the language level.
What Is Result?
Result<T, E> represents either success or failure: it's the primary tool for fallible operations like I/O, parsing, network calls, and database queries.
Result<T, E> represents either success or failure:
enum Result<T, E> {
Ok(T), // success with value
Err(E), // failure with error
}Every fallible operation returns Result:
use std::fs;
fn read_config() -> Result<String, std::io::Error> {
let content = fs::read_to_string("config.toml")?;
Ok(content)
}
// Using Result:
match read_config() {
Ok(content) => println!("Config: {}", content),
Err(e) => eprintln!("Failed to read config: {}", e),
}The type parameter E is the error type: which means different operations can have different error types, and those types carry structured information about what went wrong. A std::io::Error tells you the OS error code. A sqlx::Error tells you the database error kind. A custom AuthError tells you exactly which authentication step failed.
Compared to exceptions: the error type is visible in the function signature, the caller knows what can go wrong, and the compiler ensures every error path is addressed.
What Is the ? Operator?
The ? operator is what makes Rust error handling ergonomic: it propagates errors with a single character instead of verbose match blocks.
The ? operator is the key to ergonomic Rust error handling. It:
- If the value is
Ok(x)orSome(x), unwraps it and continues - If the value is
Err(e)orNone, returns early from the current function with that error
// Without ? : verbose
fn process_file() -> Result<String, std::io::Error> {
let content = match fs::read_to_string("data.txt") {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(content.to_uppercase())
}
// With ?: clean and idiomatic
fn process_file() -> Result<String, std::io::Error> {
let content = fs::read_to_string("data.txt")?;
Ok(content.to_uppercase())
}? can only be used in functions that return Result or Option. This is intentional: error propagation is explicit and traceable. You can always follow the ? chain up the call stack to find where errors are handled.
? also performs automatic error type conversion via the From trait. If fs::read_to_string returns std::io::Error but your function returns AppError, Rust automatically converts the error type if a From<std::io::Error> for AppError implementation exists: which thiserror generates automatically with the #[from] attribute.
3 spots open this month â Check if you are eligible.
We help experienced developers transition into Rust roles at âĴ80KââĴ150K+ in Europe or $130Kâ$200K+ in the US.
How Do You Define Custom Error Types with thiserror?
For library code, thiserror lets you define expressive, typed error enums with minimal boilerplate: giving callers structured errors they can match on.
For library code, define specific error types using thiserror:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("User not found: {id}")]
UserNotFound { id: u32 },
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Invalid input: {0}")]
Validation(String),
}
fn get_user(id: u32) -> Result<User, AppError> {
let user = db_query(id)?; // sqlx::Error auto-converts to AppError::Database
user.ok_or(AppError::UserNotFound { id })
}The #[from] attribute tells thiserror to auto-implement From<sqlx::Error> for AppError, so ? automatically converts the error type.
thiserror is appropriate for library code because callers need to match on specific error variants. If a downstream function receives an AppError, it can distinguish between UserNotFound and Database variants and handle each differently. This is only possible with typed errors: not with anyhow's dynamic approach.
How Do You Handle Application Errors with anyhow?
For application code (binaries, not libraries), anyhow provides a single dynamic error type that accepts any error: making it simple to compose different error types without manual conversion code.
For application code (binaries, not libraries), anyhow provides a simpler approach: a single dynamic error type that accepts any error:
use anyhow::{Context, Result};
fn run() -> Result<()> {
let config = fs::read_to_string("config.toml")
.context("Failed to read config file")?;
let port: u16 = config.parse()
.context("Config must contain a valid port number")?;
println!("Starting on port {}", port);
Ok(())
}anyhow::Result<T> is Result<T, anyhow::Error>. The .context() method adds human-readable context to errors, making them easier to debug. When an error propagates to the top of your application, anyhow prints it with the full context chain: each .context() call appears as a line in the error message, building a clear narrative of what failed and why.
Rule of thumb:
- Use
thiserrorin libraries: callers need to match on specific error variants - Use
anyhowin applications: you're displaying errors to the user or logging them
Bottom line: The library vs application distinction is the single most important decision in Rust error handling:
thiserrorfor crates others depend on,anyhowfor everything else.
What Are the Common Error Handling Patterns?
These patterns cover 95% of real-world error handling needs: master them and you'll write idiomatic Rust error handling from day one.
Combining Multiple Error Types
use anyhow::Result;
async fn fetch_and_save(url: &str) -> Result<()> {
let response = reqwest::get(url).await?; // reqwest::Error
let text = response.text().await?; // reqwest::Error
fs::write("output.txt", text)?; // io::Error
Ok(())
// anyhow converts all of these: no manual From impls needed
}Mapping Errors
// Convert one error type to another
let result = some_operation()
.map_err(|e| AppError::Validation(e.to_string()))?;
// Provide a default value on error
let value = risky_operation().unwrap_or_default();
// Panic on error (only in tests or when truly impossible)
let value = risky_operation().expect("this should never fail because X");The ? in main
fn main() -> anyhow::Result<()> {
let config = load_config()?;
run(config)?;
Ok(())
}Using anyhow::Result as the return type of main lets you use ? throughout: errors are printed automatically if they bubble to the top.
Conditional Error Creation
fn validate_age(age: u32) -> Result<(), AppError> {
if age > 150 {
return Err(AppError::Validation(format!("Age {} is unrealistic", age)));
}
Ok(())
}Transforming Options to Results
let user = find_user(id)
.ok_or_else(|| AppError::UserNotFound { id })?;What Are Common Mistakes Developers Make with Rust Error Handling?
The errors developers make with Rust's error handling system are consistent: and understanding them before you encounter them saves real debugging time in production.
-
Using
.unwrap()in production code. The fastest way to write Rust error handling is to.unwrap()everything: it compiles and works until it doesn't. In production,.unwrap()on anErrpanics the thread. Panics in web servers terminate request handling. Panics in async tasks may silently drop work. Use?to propagate errors properly; use.expect("reason")only where you can truly prove the value is always valid, and document why. -
Using
anyhowin library crates. If you publish a library that returnsanyhow::Error, callers cannot programmatically distinguish between error kinds: they can only print the error message. This is unhelpful for library users who need to handle different errors differently. Always use typed errors (viathiserroror manualErrorimplementations) in library code. -
Not adding context to errors. Bare errors like "permission denied" or "connection refused" are hard to debug in production.
anyhow's.context()method adds the "what was I trying to do" layer that makes errors actionable. Every?in an application should ideally be preceded by.context("what this operation was doing"). This is the equivalent of a stack trace annotation. -
Matching on error strings instead of error variants. Beginners sometimes write code like
if err.to_string().contains("not found"). This is fragile: error messages can change. Instead, define a typed error enum and match on variants:if let AppError::UserNotFound { id } = err. This is whatthiserror's enum variants are for. -
Ignoring the return type of
?in closures. The?operator does not work inside closures that return()or other non-Result types. This causes confusing compiler errors. The fix is usually to either use explicitmatchinside the closure, or restructure the code so the fallible operation happens outside the closure. -
Conflating
panic!andErr.panic!terminates the program for unrecoverable programming errors: array out of bounds, invariant violations.Erris for expected failures: file not found, network timeout, invalid user input. Usingpanic!for expected failures (orunwrap()on user-provided data) is a design mistake that produces applications that crash instead of returning meaningful error messages.
Bottom line: Replacing every
.unwrap()with?and adding.context()on every fallible call is the fastest way to turn beginner Rust error handling into production-grade error handling.
Where Does Structured Error Handling Lead?
Mastering Result and Option is not just a Rust skill: it changes how you think about failure in any language, because once errors are values in a signature you start noticing everywhere else that they are not. It also shows up in interviews. Designing a clean error model across an async, multi-crate service (typed errors at the boundaries, anyhow with context in the binary, HTTP status mapping at the edge) is one of the things that consistently separates mid-level from senior Rust candidates, and senior backend Rust roles in the US sit around $185Kâ$230K.
Rustify's 9-week Backend Rust bootcamp covers this error-handling design alongside ownership, async, and real service work, with 1:1 coaching and code review on your own projects. Book a call if you want a straight answer on whether it fits where you are now.
Frequently Asked Questions
Use ? anywhere the code can be reached by user input, file contents, network responses, or database rows, which is almost all of an application. Reserve unwrap() for tests, quick examples, and the rare spot where you can prove the value is always Ok or Some, and even there prefer .expect("reason") with a comment. Never use unwrap() in a published library: it panics the caller's thread instead of handing them an error they can handle, and in a web server that means a dropped request or a killed worker.
panic! unwinds the current thread and is for unrecoverable programming bugs: a violated invariant, an index out of bounds, an impossible state. Err is for expected, recoverable failures: a missing file, a timed-out request, invalid input. The practical test is whether a correct program could ever hit the condition at runtime; if yes, it is an Err. Library code should almost never panic! on anything a caller can trigger, and unwrap() on user-provided data is the same mistake wearing a different name.
Exactly the same way as in synchronous code: async fn returns Result<T, E>, ? propagates, and thiserror and anyhow work unchanged. The executor (Tokio, async-std) only drives the Future; it has nothing to do with the error path. The one real caveat is tokio::spawn: a panic inside a spawned task is captured in its JoinHandle rather than propagated, so handle the Result the task returns explicitly or that failure disappears silently.
Because ? converts the called function's error type into your function's error type through the From trait, and no such conversion exists yet. You have three fixes: call .map_err(|e| ...) to convert by hand at that line, add #[from] on a variant of your thiserror enum so the impl is generated, or return anyhow::Error, which absorbs any error implementing std::error::Error. The message shows up most often when a function mixes errors from two crates, for example reqwest::Error and std::io::Error.
For application code use anyhow::Result, not Result<(), Box<dyn Error>>: it carries a backtrace, chains .context() messages, and prints a readable cause chain, none of which bare Box<dyn Error> gives you. Box<dyn Error> still turns up in the standard library docs and older examples and it works, but it is strictly less useful than anyhow in 2026. For library code, use neither: define a typed thiserror enum so downstream callers can match on variants.
Both make errors ordinary values and neither uses exceptions, so the mental model transfers. The differences are ergonomics and expressiveness: Rust's ? collapses the if err != nil { return err } that Go repeats after every call, and a typed Result<T, E> enum carries more structure than Go's single error interface. anyhow's context chaining lines up closely with Go's fmt.Errorf and %w wrapping. Engineers who have used both tend to prefer Rust's expressiveness while granting that Go's version is simpler to teach.
Yes, and it is routine. opt.ok_or(err) and opt.ok_or_else(|| err) turn None into Err and Some(x) into Ok(x); the _else form avoids building the error value unless it is actually needed. Going the other way, res.ok() turns Ok(x) into Some(x) and discards the error on Err, while res.err() keeps the error and discards the value. These are the joints where code that returns Option meets code that returns Result.
If main returns Result<(), E> where E: Debug (in practice anyhow::Result<()>), Rust prints the error with its Debug representation and exits with a non-zero status. That is the standard shape for CLI tools and long-running servers. Web services usually do not let errors travel that far: an HTTP handler maps the error to a status code, and you log the full chain first with tracing::error!("{:?}", err) so observability keeps the root cause even though the client only sees a 500.
Sources
- The Rust Book: Error Handling: Official guide
- thiserror crate: Library error type derivation
- anyhow crate: Application error handling
- Rust by Example: Error Handling: Practical examples
- Rust API Guidelines: Error Types: Library design best practices
Related Glossary Terms
- Result:
Result<T, E>and the?operator explained - Option: Rust's null-safety type, the companion to
Result - thiserror: Derive macro for defining typed custom error enums
- anyhow: Single dynamic error type for application-level propagation
- Try Operator: The
?operator that powers ergonomic error propagation - From / Into: The
Fromtrait that?uses to convert between error types - Display / Debug: The formatting traits required by
std::error::Error - Enum: Both
ResultandOptionare enums - Trait:
std::error::Errortrait and how error types implement it - Pattern Matching: How
matchonOk/ErrandSome/Noneworks - Panic: When to panic vs when to return
Result - Error Handling: Rust's broader error handling philosophy

