How to handle database errors in Rust

Handle database errors in Rust by returning Result types and using the ? operator to propagate failures gracefully.

When the database says no

You are building a CLI tool that pulls user stats from a database. You run it, and the terminal explodes with a panic: `thread 'main' panicked at 'called Result::unwrap() on an Err value: Connection refused'. Your user sees a wall of text and assumes your app is broken. The database was just down for a second. You need a way to catch that failure, log it, and tell the user "Try again later" without crashing the whole process.

Rust handles this by making errors explicit values. You cannot accidentally ignore a failure. The compiler forces you to decide what happens when things go wrong. This design eliminates entire classes of runtime crashes that plague other languages.

The Result envelope

Rust represents fallible operations with the Result<T, E> enum. Think of a function that might fail as a sealed envelope. The envelope contains either the data you asked for, or a note explaining why it couldn't deliver. You can't get the data without opening the envelope. You can't ignore the note without explicitly saying you don't care.

The Result type has two variants:

  • Ok(T) holds the success value.
  • Err(E) holds the error value.

The compiler tracks whether a value is inside a Result or not. If a function returns Result, you must handle both cases before you can use the inner value. This prevents you from using garbage data or proceeding when a dependency has failed.

Minimal example

Start with a function that returns Result. Use match to handle both outcomes.

/// Attempts to fetch a user record, returning a Result.
fn get_user(id: u32) -> Result<String, String> {
    // Return Err if the ID is invalid.
    if id == 0 {
        Err("User not found".to_string())
    } else {
        // Return Ok with the data for valid IDs.
        Ok(format!("User {}", id))
    }
}

fn main() {
    // Match on the Result to handle both cases.
    match get_user(1) {
        // Extract the value from Ok.
        Ok(user) => println!("Found: {}", user),
        // Extract the error message from Err.
        Err(e) => eprintln!("Error: {}", e),
    }
}

The match expression checks which variant the Result holds. The Ok arm binds the inner string to user. The Err arm binds the error string to e. You can only access the data after the match confirms it exists.

Don't unwrap in production. The compiler gives you a better tool.

Real-world error types

Returning String as an error works for toys, but it loses structure. You cannot programmatically check if an error is a "not found" versus a "connection timeout". Production code uses custom error enums.

Define an enum with variants for each failure mode. Implement std::fmt::Display for user-facing messages and std::error::Error to integrate with the ecosystem.

use std::fmt;
use std::error::Error;

/// Errors that can occur during database operations.
#[derive(Debug)]
enum DbError {
    ConnectionFailed(String),
    QueryError(String),
    NotFound(u32),
}

impl fmt::Display for DbError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            DbError::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg),
            DbError::QueryError(msg) => write!(f, "Query error: {}", msg),
            DbError::NotFound(id) => write!(f, "Record not found: id={}", id),
        }
    }
}

impl Error for DbError {}

/// Fetches a user, simulating a database call.
fn fetch_user(id: u32) -> Result<String, DbError> {
    if id == 0 {
        return Err(DbError::NotFound(id));
    }
    Ok(format!("User data for {}", id))
}

The #[derive(Debug)] attribute adds a debug representation. The Display impl controls what users see when you print the error. The Error trait marks this as a proper error type, enabling compatibility with crates like anyhow.

Convention aside: In production libraries, you rarely write impl Display by hand. The community standard is the thiserror crate. It lets you derive error traits with attributes, reducing boilerplate. For application code, anyhow is the go-to because it wraps any error type without defining a custom enum.

Propagating errors with ?

Writing match blocks for every call gets verbose. Rust provides the ? operator to propagate errors up the call stack. The ? operator checks the Result. If it is Ok, it extracts the value. If it is Err, it returns immediately from the function.

/// Processes user data, propagating errors up.
fn process_user(id: u32) -> Result<String, DbError> {
    // The ? operator returns early if fetch_user returns Err.
    let raw_data = fetch_user(id)?;
    
    // Only reached if fetch_user succeeded.
    Ok(format!("Processed: {}", raw_data))
}

fn main() {
    match process_user(0) {
        Ok(data) => println!("{}", data),
        Err(e) => eprintln!("Failed: {}", e),
    }
}

The ? operator is syntactic sugar for a match that returns Err immediately. It keeps your code readable by removing nested error handling. The function signature must return a Result (or implement FromResidual) for ? to work.

Treat ? as your error-handling workhorse. It keeps your code readable and your stack traces clean.

Error conversion and From

The ? operator can convert error types automatically. If fetch_user returns DbError but your function returns Result<T, AppError>, the compiler looks for an implementation of From<DbError> for AppError. If it exists, ? converts the error and returns it.

/// A generic application error type.
#[derive(Debug)]
struct AppError(String);

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "App error: {}", self.0)
    }
}

impl Error for AppError {}

/// Converts DbError into AppError.
impl From<DbError> for AppError {
    fn from(err: DbError) -> Self {
        AppError(err.to_string())
    }
}

/// Uses ? to convert DbError to AppError automatically.
fn handle_request(id: u32) -> Result<String, AppError> {
    // ? converts DbError to AppError via the From impl.
    let data = fetch_user(id)?;
    Ok(data)
}

When From is not implemented, you must convert manually using map_err. This method transforms the error value inside the Result without affecting the success case.

/// Uses map_err when From is not available.
fn load_config() -> Result<String, AppError> {
    std::fs::read_to_string("config.txt")
        // Convert io::Error to AppError explicitly.
        .map_err(|e| AppError(e.to_string()))?
}

The map_err call takes a closure that receives the error and returns a new error type. The ? then propagates the converted error.

If you try to use ? with mismatched error types and no conversion, the compiler rejects the code with E0277 (the trait bound std::convert::From<SourceError> is not satisfied). You must add a From impl or use map_err.

Async errors

Async functions return futures that resolve to values. An async function that can fail returns a future resolving to Result<T, E>. The ? operator works inside async functions exactly the same way.

/// Async version of fetch_user.
async fn async_fetch_user(id: u32) -> Result<String, DbError> {
    // Simulate async work.
    // In real code, this would be a driver call like sqlx::query.
    Ok(format!("Async User {}", id))
}

/// Async handler that propagates errors.
async fn async_process(id: u32) -> Result<String, DbError> {
    // ? works inside async fn to propagate errors.
    let data = async_fetch_user(id).await?;
    Ok(data)
}

The .await keyword suspends execution until the future resolves. The ? operator then checks the resolved Result. If it is Err, the function returns early with that error.

Pitfalls and compiler errors

Unwrap abuse

New Rust developers often reach for .unwrap() to extract values. This panics if the Result is Err. In production code, panics crash the thread. Use expect() instead of unwrap() to provide a context message.

fn main() {
    // Bad: No context if this panics.
    // let data = fetch_user(0).unwrap();
    
    // Good: Tells you what failed.
    let data = fetch_user(0).expect("Failed to fetch user in main");
}

Convention aside: The community convention is expect("context") over unwrap(). unwrap() gives a blank panic message. expect tells you what went wrong. Reserve unwrap for tests where failure indicates a bug in the test setup, not runtime data.

Type mismatch

If a function has branches returning different error types, the compiler complains. Every branch must return the same error type.

fn mixed_errors(id: u32) -> Result<String, DbError> {
    if id == 0 {
        // Returns Result<String, String>.
        Err("Not found".to_string())
    } else {
        // Returns Result<String, DbError>.
        Ok("Ok".to_string())
    }
}

The compiler rejects this with E0308 (mismatched types). The first branch returns Result<String, String> while the second returns Result<String, DbError>. You must convert one error type to the other, or use a common error type.

Ignoring errors

You cannot silently ignore a Result. If you call a function returning Result and don't use the value, the compiler warns you. If you use the value without handling the error, the compiler errors.

fn main() {
    // Warning: unused Result.
    fetch_user(1);
}

The compiler emits a warning: unused Result. You must handle the value, assign it to _, or use let _ = fetch_user(1); to signal intentional discard.

Resist the urge to unwrap. Write the error handling.

Decision matrix

Use Result<T, E> for any function that can fail. Use ? to propagate errors up the stack when the current function cannot handle the failure. Use thiserror when building a library and you need to define specific error variants for consumers. Use anyhow when writing application code and you want to wrap heterogeneous errors without defining a custom enum. Use unwrap() only in tests or main functions where failure is truly unrecoverable and crashing is the correct response. Use expect() instead of unwrap() to provide a context message when you do panic. Use map_err when you need to convert error types and From is not implemented.

Pick anyhow for apps, thiserror for libs. The ecosystem agrees on this split.

Where to go next