Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 012 / 100

Rust error handling with Result: lessons from production

Errors as values.

Expona Admin6 min read

Most Rust developers initially see error handling as a burden—all those Result types and unwrap calls that feel verbose compared to exceptions. Once you've debugged a production issue where an error was silently swallowed, or traced a panic back to an unchecked assumption, you start to understand why Rust treats errors as values. This article explores Rust error handling with Result: lessons from production, drawing from real systems we've built in payment processing, data pipelines, and API services.

Understanding Result<T, E> — Ok(value) or Err(error)

At its core, Result<T, E> is an enum with two variants: Ok(value) and Err(error). Unlike exceptions that break the call stack and require try-catch blocks, Result forces you to acknowledge that an operation might fail—right there in the type signature. This makes failure paths visible and composable.

Consider a typical scenario from a ride-hailing service where we need to parse a user ID from an HTTP request parameter:

rust
use std::num::ParseIntError;

fn parse_user_id(param: &str) -> Result {
    param.parse()
}

fn main() {
    match parse_user_id("12345") {
        Ok(id) => println!("User ID: {}", id),
        Err(e) => eprintln!("Invalid ID: {}", e),
    }
}

The match expression forces us to handle both branches. Yes, this is more explicit than a try-catch, but that explicitness catches bugs. We've seen production incidents where a parsing error was ignored because the developer forgot an edge case—in Rust, the compiler won't let you forget.

In practice, we often stack Results. A request might need to parse an ID, look up a record in the database, and then serialize a response. Each step can fail independently:

rust
fn handle_request(req: &Request) -> Result> {
    let user_id: u64 = req.param("id")?.parse()?;
    let user = db.find_user(user_id)?;
    Ok(Response::ok(json!(user)))
}

Notice that we don't have three separate error checks—we'll see why in the next section.

The ? operator — Propagate errors concisely

The question mark operator is syntactic sugar for "if this is an error, return it immediately; otherwise, extract the value." It lets us chain fallible operations without nesting match expressions or if-let blocks.

Here's what the ? operator expands to:

rust
// This:
let text = std::fs::read_to_string(path)?;

// Is roughly equivalent to:
let text = match std::fs::read_to_string(path) {
    Ok(t) => t,
    Err(e) => return Err(e),
};

From our experience in a real-time trading platform, we used this pattern extensively when processing market data feeds:

rust
fn process_market_update(raw_data: &[u8]) -> Result> {
    let json: serde_json::Value = serde_json::from_slice(raw_data)?;
    let symbol = json["symbol"].as_str().ok_or("missing symbol")?;
    let price: f64 = json["price"].as_f64().ok_or("invalid price")?;
    let quantity: i32 = json["quantity"].as_i64().ok_or("invalid quantity")? as i32;
    
    validate_price(price)?;
    validate_quantity(quantity)?;
    
    Ok(Order { symbol, price, quantity })
}

Without the ? operator, this function would be unreadable—we'd have a pyramid of nested matches. The ? lets us focus on the happy path while guaranteeing that any error bubbles up to the caller.

One subtle point: the ? operator calls From::from() on the error, allowing automatic error type conversion. This is powerful for aggregating different error types. However, it requires that the error types implement the From trait for each other, which brings us to purposeful error definitions.

thiserror — Define library error types

When building a library—whether a database driver, message queue client, or utility crate—you want to define your own error types. This gives callers a clear contract about what can go wrong. The thiserror crate (by the author of serde) makes this ergonomic.

Imagine we're writing a customer relationship management (CRM) library that reads and validates contact records:

rust
use thiserror::Error;

#[derive(Error, Debug)]
pub enum CrmError {
    #[error("database connection failed: {0}")]
    DatabaseError(String),
    
    #[error("validation failed: missing field {field}")]
    ValidationError { field: String },
    
    #[error("contact not found with id {0}")]
    NotFound(u64),
    
    #[error("serialization error: {0}")]
    SerdeError(#[from] serde_json::Error),
    
    #[error("io error: {0}")]
    IoError(#[from] std::io::Error),
}

pub type CrmResult = Result;

fn fetch_contact(id: u64) -> CrmResult {
    let raw = fetch_from_db(id)
        .map_err(|e| CrmError::DatabaseError(e.to_string()))?;
    let contact: Contact = serde_json::from_str(&raw)?;
    Ok(contact)
}

The #[error] macro generates a Display impl, making error messages user-friendly. The #[from] attribute auto-implements the From trait, so when serde_json errors occur, they're automatically converted to CrmError. This means our callers see a uniform error type and can match on specific variants.

In production e-commerce systems we've worked on, this pattern kept the contract between services clear. A payment API could document exactly which errors its methods return, and integrating teams knew exactly how to handle them.

anyhow — Easy error handling in applications

Libraries should define precise error types with thiserror, but applications often don't need that ceremony. You're not publishing an API; you're solving a problem. The anyhow crate provides a lightweight wrapper around any error type with convenient methods like .context() for adding context as errors propagate.

Here's how we might use anyhow in a command-line tool that imports user data:

rust
use anyhow::{Context, Result};
use std::fs;

fn import_users(file_path: &str) -> Result> {
    let text = fs::read_to_string(file_path)
        .context("failed to read user file")?;
    
    let users: Vec = serde_json::from_str(&text)
        .context("user file is not valid JSON")?;
    
    for user in &users {
        validate_email(&user.email)
            .context(format!("invalid email for user {}", user.id))?;
    }
    
    Ok(users)
}

fn main() {
    match import_users("users.json") {
        Ok(users) => println!("Imported {} users", users.len()),
        Err(e) => eprintln!("Error: {:?}", e),
    }
}

The .context() method chains error messages, creating a readable stack trace without defining custom error types. When something fails, you see the full chain: "Error: failed to read user file | IO error: No such file or directory." This is invaluable when debugging in production.

We've used anyhow extensively in data pipeline scripts and internal tools. It keeps the code lean while providing enough context when things go wrong. The tradeoff is that you lose the ability to pattern-match on specific errors—but in application code, that's rarely necessary.

Common mistakes

  • Calling unwrap() in production code. .unwrap() and .expect() panic on error. They're useful in tests or when you're 100% certain a value is Ok, but in production services, a panic crashes your thread or the entire process. Use explicit error handling instead.
  • Creating error types without context. An error message of "invalid input" isn't helpful when it occurs in a 10,000-line codebase. Use .context() or #[error] to explain what operation failed and why.
  • Ignoring error types when choosing between thiserror and anyhow. If you're writing a library, use thiserror so callers can depend on your error contract. If you're writing an application or internal tool, anyhow is usually faster.
  • Converting errors too eagerly. Converting all errors to Box<dyn Error> early loses type information and makes it hard for callers to handle specific cases. Preserve error types as long as possible.
  • Forgetting to handle errors from .ok_or(). When you need to convert Option to Result, always provide a meaningful error message in .ok_or() or .ok_or_else().

Key takeaways

  • Result types make error handling explicit and composable—failures are part of the function signature.
  • The ? operator lets you chain operations cleanly without nested matches, while forcing you to handle all paths.
  • Use thiserror for libraries to define precise, reusable error types that clients can pattern-match on.
  • Use anyhow for applications to add context as errors propagate without the overhead of custom types.
  • Avoid .unwrap() and .expect() in production code—they panic, and panics crash services.
  • Error handling in Rust feels stricter initially but prevents entire categories of bugs that plague production systems in other languages.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles