Rust: Rust Error Handling: `Result` and the `?` Operator
Last updated: 2026-08-26
Error handling is one of Rust’s most remarkable design features—it doesn’t rely on exceptions, but instead uses the type system to encode “potential failure” into the return type, and the compiler ensures that you never overlook any errors.
Rust does not have try-catch or throw. Instead, it uses the Result<T, E> enumeration and the ? operator—which separate error handling from normal business logic, resulting in code that is both safe and concise.
1. A Story of Handling Issues from Ordering Takeout to Picking It Up
(1) The Real-Life Food Delivery Process
Xiao Ming is working overtime today and has decided to order takeout. There are several points in the process where things could go wrong:
| Step | Normal Process | Possible Errors |
|---|---|---|
| 1. Find a restaurant | Open the food delivery app and search for a restaurant | Can't find a restaurant |
| 2. Load Menu | Browse Menu Items | Menu Load Failed (Network Timeout) |
| 3. Submit Payment | Pay | Payment Failed (Insufficient Balance) |
| 4. Awaiting Preparation | Wait 30 minutes | Store Cancels Order |
| 5. Pick Up | Receive Takeout | Discover the Order Has Been Canceled Upon Pickup |
Each step may succeed (Ok) or fail (Err). Let's model this using Rust's approach:
// ============================================
// Use Result to simulate every step of ordering takeout
// ============================================
// Defining Possible Errors
#[derive(Debug)]
enum OrderError {
RestaurantNotFound,
MenuLoadFailed,
PaymentFailed(String),
OrderCancelled,
}
// Simulate Store Search
fn find_restaurant(name: &str) -> Result<String, OrderError> {
let available = vec!["PizzaHouse", "SushiBar", "NoodleShop"];
if available.contains(&name) {
Ok(format!("Found: {}", name))
} else {
Err(OrderError::RestaurantNotFound)
}
}
// Simulate the Load Menu
fn load_menu(restaurant: &str) -> Result<Vec<&str>, OrderError> {
if restaurant.contains("Pizza") {
Ok(vec!["Margherita", "Pepperoni", "Hawaiian"])
} else {
Err(OrderError::MenuLoadFailed)
}
}
// Simulated Payment
fn process_payment(amount: f64) -> Result<String, OrderError> {
if amount < 100.0 {
Ok(format!("Paid: ${:.2}", amount))
} else {
Err(OrderError::PaymentFailed("Insufficient balance".into()))
}
}
fn main() {
// Go through the entire process from start to finish
let restaurant = find_restaurant("PizzaHouse");
match restaurant {
Ok(msg) => println!("Step 1: {}", msg),
Err(e) => println!("Step 1 failed: {:?}", e),
}
}
At each step, a return value of
Result<T, E>—Ok(T)indicates success, whileErr(E)indicates failure. The caller must explicitly handle both possibilities; it is not possible to "forget to handle errors."
2. Conceptual Diagrams
The following Mermaid flowchart illustrates the four main paths for handling Result<T, E> errors: detailed processing via match, operator propagation via ?, fast evaluation via unwrap/expect, and a panic:
graph TB
A["Result<T, E>"] --> B["Ok(T)<br/>Success"]
A --> C["Err(E)<br/>Failure"]
B --> D["Continue execution<br/>Standard Procedure"]
C --> E["match Processing<br/>Fine-Grained Branch Control"]
C --> F["? Operators<br/>Propagation Error"]
C --> G["unwrap / expect<br/>Quick Value Retrieval"]
E --> H["For different errors<br/>Handle them separately"]
F --> I["Caller Function<br/>Receive Err"]
G --> J["panic!<br/>Program Crash"]
H --> K["Restore Default Values<br/>Or retry logic"]
I --> L["match in main<br/>Centralized Processing"]
style A fill:#e1f5fe,stroke:#0288d1
style B fill:#c8e6c9,stroke:#388e3c
style C fill:#ffcdd2,stroke:#d32f2f
style J fill:#ffcdd2,stroke:#d32f2f
3. What You'll Learn
Result<T, E>Enumeration: The core type for error handling in Rust, with two variants:Ok(T)andErr(E)unwrap/expect: Methods for Quickly Obtaining Values and Their Risks?Operator: A concise syntax for propagating errors between functionsmatchError Handling: Granular handling of different errors- Custom Error Types: Implement
Display+Debugto make error messages clearer panic!vs. Error Return Value Selection Strategies: Which Approach to Use in Which Scenarios
4. Core Concepts
graph TB
A[Error Handling Strategies] --> B[Recoverable errors<br>Recoverable]
A --> C[Unrecoverable error<br>Unrecoverable]
B --> D["Result<T, E>"]
D --> E["Ok(T) Success Score"]
D --> F["Err(E) Error value"]
F --> G["match Precision Processing"]
F --> H["? Top-down communication"]
F --> I["unwrap/expect Quick Value Retrieval<br>(There are risks)"]
C --> J["panic!"]
J --> K["The program crashed and exited"]
J --> L["Applicable:Bug/Irreversible state"]
B -.-> M["Custom Error Types"]
M --> N["Implementation Display + Debug"]
M --> O["From trait Convert"]
(1) Comparison of Four Error-Handling Strategies
| Strategy | Use Case | Advantages | Disadvantages |
|---|---|---|---|
panic! |
Unrecoverable errors (such as array out-of-bounds or assertion failures) | Fast failure to expose the problem | The program crashes immediately |
unwrap / expect |
Prototype development / Guaranteed not to fail | Concise code | Panics immediately when an error occurs—not very elegant |
match / if let |
Requires different handling for different errors | Fine-grained control over error-handling logic | Verbose code |
? Operator |
Propagates errors between functions; handled uniformly at the top level | Most concise; keeps the main logic clear | Must be used in functions that return a Result |
(2) Result<T, E> Quick Reference Guide
| Method | Signature | Purpose | Behavior on Failure |
|---|---|---|---|
unwrap() |
Result<T,E> -> T |
Extract the value from Ok | panic! |
expect(msg) |
Result<T,E> -> T |
Retrieve the value from "Ok" and customize the panic message | panic!(msg) |
unwrap_or(default) |
Result<T,E> -> T |
Returns a value on success; returns a default value on failure | Returns a default value |
unwrap_or_else(fn) |
Result<T,E> -> T |
Return a value on success; execute the closure on failure | Execute the closure |
is_ok() |
Result<T,E> -> bool |
Check if successful | — |
is_err() |
Result<T,E> -> bool |
Check for failure | — |
ok() |
Result<T,E> -> Option<T> |
Convert to Option |
None |
err() |
Result<T,E> -> Option<E> |
To Option |
None |
map(fn) |
Result<T,E> -> Result<U,E> |
Convert success value | Unchanged |
map_err(fn) |
Result<T,E> -> Result<T,F> |
Convert incorrect values | Convert error types |
and_then(fn) |
Result<T,E> -> Result<U,E> |
Follow-up Operations in Chained Calls | Short-Circuit Evaluation |
5. Examples
▶ Example 1: Basic “Result” and “match” Handling (Difficulty ⭐)
Output:
=== match Processing ===
10 / 2 = <result>
Error: <msg>
10 / 0 = <result>
Error: <msg>
=== unwrap_or Default value ===
10 / 2 = <result1>
10 / 0 = <result2> (default)
=== unwrap_or_else Closure ===
Warning: <e>, using default
Result: <result3>
Will not reach here
// ============================================
// Use Result to handle division-by-zero errors
// ============================================
fn safe_divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Division by zero".to_string())
} else {
Ok(a / b)
}
}
fn main() {
// Use match to handle both success and failure scenarios
println!("=== match Processing ===");
match safe_divide(10.0, 2.0) {
Ok(result) => println!("10 / 2 = {}", result),
Err(msg) => println!("Error: {}", msg),
}
match safe_divide(10.0, 0.0) {
Ok(result) => println!("10 / 0 = {}", result),
Err(msg) => println!("Error: {}", msg),
}
// Use unwrap_or to provide a default value
println!("\n=== unwrap_or Default value ===");
let result1 = safe_divide(10.0, 2.0).unwrap_or(0.0);
let result2 = safe_divide(10.0, 0.0).unwrap_or(0.0);
println!("10 / 2 = {}", result1);
println!("10 / 0 = {} (default)", result2);
// Use unwrap_or_else to execute a closure
println!("\n=== unwrap_or_else Closure ===");
let result3 = safe_divide(10.0, 2.0).unwrap_or_else(|e| {
eprintln!("Warning: {}, using default", e);
0.0
});
println!("Result: {}", result3);
// ⚠️ unwrap will panic (Uncomment the code below to try it out)
// let crash = safe_divide(10.0, 0.0).unwrap();
// println!("Will not reach here");
}
Output:
=== match Processing ===
10 / 2 = 5
Error: Division by zero
=== unwrap_or Default value ===
10 / 2 = 5
10 / 0 = 0 (default)
=== unwrap_or_else Closure ===
Result: 5
matchOffers the most comprehensive handling—you can write separate handling logic forOkandErr.unwrap_orandunwrap_or_elseare shortcuts: they provide default values in case of failure.unwrap()is the riskiest—it assumes success is guaranteed, and crashes immediately if it fails.
▶ Example 2: ? Operator—Chained Propagation Error (Difficulty ⭐⭐)
Output:
--- <file> (0 bytes) ---
<&content[..content.len().min(80)]>
Failed to read '<file>': <e>
// ============================================
// ? Operators: Error propagation with Result
// Only when returning Result can only be used within the function ?
// ============================================
use std::fs::File;
use std::io::{self, Read};
// Read the entire contents of the file
// ? indicates:If File::open Failure,Return Now Err
// If read_to_string Failure,Return Now Err
fn read_file(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?; // Open the file,If it fails, return
let mut content = String::new();
file.read_to_string(&mut content)?; // Read the content,If it fails, return
Ok(content)
}
// Do not use ? An equivalent way of writing——The amount of code has doubled
fn read_file_without_question(path: &str) -> Result<String, io::Error> {
let mut file = match File::open(path) {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut content = String::new();
match file.read_to_string(&mut content) {
Ok(_) => Ok(content),
Err(e) => Err(e),
}
}
// Chain Call ?——More concise
fn read_file_chain(path: &str) -> Result<String, io::Error> {
let mut content = String::new();
File::open(path)?.read_to_string(&mut content)?;
Ok(content)
}
fn main() {
// Test files that can and cannot be read, respectively
let files = vec!["Cargo.toml", "nonexistent.txt"];
for file in &files {
match read_file(file) {
Ok(content) => {
println!("--- {} ({} bytes) ---", file, content.len());
println!("{}", &content[..content.len().min(80)]);
}
Err(e) => {
println!("Failed to read '{}': {}", file, e);
}
}
}
}
Output:
--- Cargo.toml (42 bytes) ---
[package]
name = "demo"
version = "0.1.0"
edition = "2021"
Failed to read 'nonexistent.txt': The system cannot find the file specified. (os error 2)
The
?operator is the essence of error handling in Rust: it serves as a shorthand for "return early on failure."expr?is equivalent tomatch expr { Ok(v) => v, Err(e) => return Err(e.into()) }. Note that?automatically callsFrom::fromto perform error type conversion—this is key to its ability to propagate across different error types.
▶ Example 3: Custom Error Types—Implementing Display + Debug (Difficulty ⭐⭐)
Output:
[<description>] OK: <result>
[<description>] Error: <e>
// ============================================
// Custom Error Types:Make Your Error Messages More Informative
// To be implemented std::fmt::Display + std::fmt::Debug
// ============================================
use std::fmt;
use std::num::ParseIntError;
// Custom Error Enumeration
#[derive(Debug)]
enum AppError {
/// Input is empty
EmptyInput,
/// Failed to parse the number,Includes the original string
ParseFailed(String),
/// The value is outside the allowed range
OutOfRange { value: i32, min: i32, max: i32 },
/// Division by Zero
DivisionByZero,
}
// Implementation Display——Control the error messages users see
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::EmptyInput => {
write!(f, "Input cannot be empty")
}
AppError::ParseFailed(input) => {
write!(f, "Failed to parse '{}' as a number", input)
}
AppError::OutOfRange { value, min, max } => {
write!(f, "Value {} is out of range [{}, {}]", value, min, max)
}
AppError::DivisionByZero => {
write!(f, "Division by zero is not allowed")
}
}
}
}
// Implement From<ParseIntError> — let ? operator convert automatically
impl From<ParseIntError> for AppError {
fn from(_: ParseIntError) -> Self {
AppError::ParseFailed("unknown".into())
}
}
// Processing User Input:Analyze and Verify
fn process_input(input: &str, divisor: i32) -> Result<i32, AppError> {
if input.is_empty() {
return Err(AppError::EmptyInput);
}
let value: i32 = input.parse().map_err(|_| {
// Manually Convert Error Types
AppError::ParseFailed(input.to_string())
})?;
if value < -100 || value > 100 {
return Err(AppError::OutOfRange {
value,
min: -100,
max: 100,
});
}
if divisor == 0 {
return Err(AppError::DivisionByZero);
}
Ok(value / divisor)
}
fn main() {
let test_cases = vec![
("42", 2, "Normal case"),
("", 1, "Empty input"),
("abc", 1, "Parse error"),
("999", 1, "Out of range"),
("50", 0, "Division by zero"),
];
for (input, divisor, description) in test_cases {
match process_input(input, divisor) {
Ok(result) => println!("[{}] OK: {}", description, result),
Err(e) => println!("[{}] Error: {}", description, e),
}
}
}
Output:
[Normal case] OK: 21
[Empty input] Error: Input cannot be empty
[Parse error] Error: Failed to parse 'abc' as a number
[Out of range] Error: Value 999 is out of range [-100, 100]
[Division by zero] Error: Division by zero is not allowed
To define a custom error type, you need to implement
Display(the error message displayed to the user) andDebug(debug output for{:?}). Implementing theFrom<T>trait allows the?operator to automatically convert a specific error type to your custom type—this is the core mechanism that enables?to propagate across types.
▶ Example 4: panic! vs. Strategies for Handling Error Return Values (Difficulty ⭐⭐⭐)
Output:
Default port: <get_default_port()>
sqrt(16) = <sqrt_unchecked(value)>
Parsed: <n>
Parse failed: <e>
BMI: <bmi>
BMI error: <e>
First element: <first>
// ============================================
// Demo panic! Scenarios Where Error Return Values Are Appropriate
// panic! → Unrecoverable error(Bug/Assertion Failed)
// Result → Recoverable errors(User Input/IOFailure)
// ============================================
// --- Suitable for panic! the scene ---
/// Read the port number from the configuration file
/// If the configuration file is missing,This is part of the program Bug,panic That makes sense.
fn get_default_port() -> u16 {
// This value is hard-coded in the code.,It's impossible for the resolution to fail.
"8080"
.parse()
.expect("Hardcoded port number is invalid")
}
/// A function that accepts only positive integers
/// Passing a negative number indicates that the caller has Bug,panic Quickly Identify Problems
fn sqrt_unchecked(x: i32) -> f64 {
if x < 0 {
panic!("sqrt_unchecked called with negative value: {}", x);
}
(x as f64).sqrt()
}
// --- Suitable for Result the scene ---
/// Parsing Numbers from User Input
/// It is normal for users to enter the wrong format.,Should be returned Result
fn parse_user_input(input: &str) -> Result<i32, String> {
input
.parse()
.map_err(|_| format!("'{}' is not a valid integer", input))
}
/// Calculate Body Mass Index(BMI)
/// Weight is 0 A negative number may indicate a data error.,Not a program Bug
fn calculate_bmi(weight_kg: f64, height_m: f64) -> Result<f64, String> {
if weight_kg <= 0.0 {
return Err("Weight must be positive".to_string());
}
if height_m <= 0.0 {
return Err("Height must be positive".to_string());
}
Ok(weight_kg / (height_m * height_m))
}
fn main() {
// Scene 1:panic Used for unrecoverable errors
println!("Default port: {}", get_default_port());
// Scene 2:panic Used for assertions——Invalid parameter
let value = 16;
println!("sqrt({}) = {}", value, sqrt_unchecked(value));
// Scene 3:Result Used for recoverable errors——User Input
let inputs = vec!["42", "hello", "-5"];
for input in inputs {
match parse_user_input(input) {
Ok(n) => println!("Parsed: {}", n),
Err(e) => println!("Parse failed: {}", e),
}
}
// Scene 4:Result Used for business logic validation
let bmi_cases = vec![
(70.0, 1.75),
(0.0, 1.70),
(65.0, -0.5),
];
for (weight, height) in bmi_cases {
match calculate_bmi(weight, height) {
Ok(bmi) => println!("BMI: {:.1}", bmi),
Err(e) => println!("BMI error: {}", e),
}
}
// Scene 5: expect's panic message helps with debugging
let numbers = vec![10, 20, 30];
let first = numbers.first().expect("Vector should not be empty");
println!("First element: {}", first);
}
Output:
Default port: 8080
sqrt(16) = 4
Parsed: 42
Parse failed: 'hello' is not a valid integer
Parsed: -5
BMI: 22.9
BMI error: Weight must be positive
BMI error: Height must be positive
First element: 10
Core Principles for Choosing a Strategy:
panic!is used for "bugs in the program itself" (invalid hard-coded data, parameters that violate conventions, array index out-of-bounds);Resultis used for "exceptions caused by the external environment or user input" (I/O errors, parsing failures, business validation failures). Simply put: UseResultfor issues you can fix, and usepanicfor those you can’t!
6. An Introduction to the Concepts of anyhow and thiserror
In production-grade Rust projects, two community crates are widely used to simplify error handling:
| Crate | Primary Use | Use Cases | Key Features |
|---|---|---|---|
| anyhow | Error propagation (caller's perspective) | Application main function, CLI tools, scripts | anyhow::Result<T>, .context() Provide context for the error |
| thiserror | Error definition (from the library author's perspective) | Error types in the library's public API | Implemented using the derive macro to automatically generate Display + Error |
// anyhow Style(Concept Examples,No execution required)
// use anyhow::{Context, Result};
//
// fn read_config() -> Result<String> {
// let content = std::fs::read_to_string("config.toml")
// .context("Failed to read config file")?;
// Ok(content)
// }
// thiserror Style(Concept Examples,No execution required)
// use thiserror::Error;
//
// #[derive(Error, Debug)]
// enum MyError {
// #[error("IO error: {0}")]
// Io(#[from] std::io::Error),
//
// #[error("Parse error: {0}")]
// Parse(#[from] std::num::ParseIntError),
// }
anyhowEncourages you to focus on "how to handle errors" rather than "how to define errors";thiserrorEncourages you to use annotations instead of hand-codingDisplay+Fromimplementations. The two are typically used in conjunction: libraries usethiserrorto define fine-grained error types, while applications useanyhowto propagate them uniformly.
▶ Example 5: Comprehensive Exercise—Multi-Layer Error Propagation and Recovery (Difficulty ⭐⭐⭐)
Output:
=== User Processing Test ===
Success: <result>
Failure: <e>
=== Batch Processing (Fault-Tolerant) ===
OK: <result>
Skip: <e>
Success: 0/<inputs.len()>
// ============================================
// Comprehensive Example:Custom Errors + ? Dissemination + Recovery Strategy
// ============================================
use std::fmt;
#[derive(Debug)]
enum AppError {
ParseError(String),
ValidationError(String),
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::ParseError(msg) => write!(f, "Parsing error: {}", msg),
AppError::ValidationError(msg) => write!(f, "Validation Error: {}", msg),
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
}
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::ParseError(e.to_string())
}
}
fn parse_age(input: &str) -> Result<u8, AppError> {
let age: u8 = input.parse().map_err(|_| AppError::ParseError(format!("'{}' Not a valid number", input)))?;
if age > 150 {
return Err(AppError::ValidationError(format!("Age {} Unreasonable", age)));
}
Ok(age)
}
fn find_user(id: u32) -> Result<String, AppError> {
let users = [(1, "Alice"), (2, "Bob"), (3, "Charlie")];
users.iter()
.find(|(uid, _)| *uid == id)
.map(|(_, name)| name.to_string())
.ok_or_else(|| AppError::NotFound(format!("User ID={}", id)))
}
fn process_user(id_str: &str, age_str: &str) -> Result<String, AppError> {
let id: u32 = id_str.parse().map_err(|_| AppError::ParseError(format!("Invalid ID: '{}'", id_str)))?;
let age = parse_age(age_str)?;
let name = find_user(id)?;
Ok(format!("User: {}, Age: {}", name, age))
}
fn main() {
let test_cases = [
("1", "30"),
("2", "200"),
("5", "25"),
("abc", "30"),
("3", "abc"),
];
println!("=== User Processing Test ===");
for (id, age) in &test_cases {
match process_user(id, age) {
Ok(result) => println!("Success: {}", result),
Err(e) => println!("Failure: {}", e),
}
}
println!("\n=== Batch Processing (Fault-Tolerant) ===");
let inputs = [("1", "30"), ("5", "25"), ("2", "abc"), ("3", "20")];
let mut success_count = 0;
for (id, age) in &inputs {
match process_user(id, age) {
Ok(result) => { println!("OK: {}", result); success_count += 1; }
Err(e) => println!("Skip: {}", e),
}
}
println!("Success: {}/{}", success_count, inputs.len());
}
Output:
=== User Processing Test ===
Success: User: Alice, Age: 30
Failure: Validation Error: Age 200 Unreasonable
Failure: Not found: User ID=5
Failure: Parsing error: Invalid ID: 'abc'
Failure: Parsing error: 'abc' Not a valid number
=== Batch Processing (Fault-Tolerant) ===
OK: User: Alice, Age: 30
Skip: Not found: User ID=5
Skip: Parsing error: 'abc' Not a valid number
OK: User: Charlie, Age: 20
Success: 2/4
Custom
AppErrorenumeration +Fromconversion +?propagation make the error-handling chain clear and concise.map_errconverts underlying errors into custom types. For batch processing, usematchfor fault tolerance—it does not interrupt the loop, but instead logs failures and continues.
❓ FAQ
unwrap() considered a bad practice?? operator vs. match error handling—which should take precedence?? for error propagation, and use match only where fine-grained handling is required.? propagate them uniformly?From trait.panic! be caught?Display and Debug?Error trait requires both.📖 Summary
Result<T, E>is at the heart of Rust's error handling—Ok(T)indicates success,Err(E)indicates failure, and the compiler requires you to handle both cases- The
?operator is syntactic sugar for error propagation—it automatically returnsErron failure, retrieves the value fromOkon success, and automatically performs error type conversion. match/unwrap_or/unwrap_or_elseprovide error handling at different levels of granularity—from fine-grained branching to fast fallback- Custom Error Types: Make error messages clear and readable by implementing
Display + Debug, and useFrom traitto perform type conversion. panic!is used for unrecoverable errors (program bugs),Resultis used for recoverable errors (external environment exceptions) — this is the basic error-handling strategy in Rustanyhowandthiserrorare standard tools for production-level error handling—the former simplifies propagation, while the latter simplifies definition
📝 Exercises
-
Difficulty ⭐: Write a function
fn parse_age(input: &str) -> Result<u8, String>that parses a string into an age (0–150). If parsing fails or the value is out of range, return the corresponding error message. Inmain, usematchto handle three scenarios: a valid age, a non-numeric input, and a value out of range. -
Difficulty ⭐⭐: Write a scenario with nested function calls—function A calls function B, function B calls function C, and each level may fail. Use the
?operator to propagate errors. Scenario:read_user_file()->parse_user_data()->validate_age(). Each level returns the same custom error typeUserDataError(defined using an enumeration), which includes three variants:FileNotFound,ParseFailed(String), andInvalidAge(i32). -
Difficulty ⭐⭐⭐: Design a mini calculator that supports four operations:
add,subtract,multiply, anddivide. All operations propagate errors through?. Requirements:- Define
CalcErrorenum (DivideByZero,Overflow,InvalidOperator(String)) - Implement
Display+Debug - Use
?to chain multiple operations together: string expressions such ascalculate("10 + 5 * 2") - Tip: First split the string by spaces, then process each part individually using
foldor a loop.
- Define