Unlock Your Python Backend Career: Build 30 Projects in 30 Days. Join now for just $54

Rust Error Handling: 80 / 20 Guide

by Ugochukwu Chizaram Omumusinachi

.

Updated Fri May 09 2025

.
Rust Error Handling: 80 / 20 Guide

Introduction

Rust is known for safety, performance, and a robust error-handling system. It's one of Rust's greatest strengths, even though it might seem challenging at first.

Think about errors differently: they're not exceptional cases but expected outcomes of operations. When you try to read a file, two things can happen - you successfully read it, or something goes wrong (file missing, permissions issues, etc.).

The fundamental pattern in Rust error handling is: ActionResult → either Success or Error

This system makes sure your programs can respond correctly to problems rather than crashing unexpectedly. 

Let's see how it works:

Understanding Rust Enums: Result and Option

At the heart of Rust's error handling are two special enum types: Result and Option.

What is an Enum?

An enum (enumeration) in Rust represents a type that can be one of several variants, but only one variant at a time. This makes them perfect for representing operations with multiple possible outcomes.

The Result Type


enum Result<T, E> {
    Ok(T),   // Success case containing a value of type T
    Err(E),  // Error case containing a value of type E
}

When you see a function returning a Result, it's telling you: "I'll either give you the data you want (in an Ok variant) or an error explaining what went wrong (in an Err variant)."

For example, when opening a file:


use std::fs::File;

let file_result = File::open("my_file.txt");
// file_result could be Ok(file_handle) or Err(io_error)

The Option Type


enum Option<T> {
    Some(T), // Represents the presence of a value of type T
    None,    // Represents the absence of a value
}

Option is simpler than Result - it just tells you whether a value exists or not. It's perfect for situations where "nothing" is a valid outcome but doesn't necessarily indicate an error.

For example, finding an element in a collection:


let item = collection.get(5);
// item could be Some(value) or None if index 5 doesn't exist

The key difference: Result holds both success and failure with additional error information, while Option simply indicates the presence or absence of a value.

Converting Between Result and Option

Sometimes you'll need to convert between these types based on your error handling needs.

Option to Result

To convert an Option to a Result, use the ok_or or ok_or_else methods:


let my_option = Some("data");
// Convert Some(value) to Ok(value) or None to Err("value not found")
let my_result: Result<&str, &str> = my_option.ok_or("value not found");
println!("{:?}", my_result); // Ok("data")

let no_option: Option<&str> = None;
let no_result: Result<&str, &str> = no_option.ok_or("value not found");
println!("{:?}", no_result); // Err("value not found")

// With ok_or_else, you can compute the error value:
let computed_result = no_option.ok_or_else(|| {
    println!("Computing error value...");
    "computed error message"
});

Result to Option

To convert a Result to an Option, use the ok method:


let my_result: Result<&str, &str> = Ok("data");
// Convert Ok(value) to Some(value) or Err(_) to None
let my_option: Option<&str> = my_result.ok();
println!("{:?}", my_option); // Some("data")

let bad_result: Result<&str, &str> = Err("error occurred");
let bad_option: Option<&str> = bad_result.ok();
println!("{:?}", bad_option); // None (error information is discarded)

When would you use these conversions? Consider the following:

  • Use ok_or when you need to treat the absence of a value as an error

  • Use ok when you're more interested in whether you have a value than the specific error

Error Handling Techniques

Let's go through the various ways to handle errors in Rust, from most verbose to most concise.

Match Expressions - The Complete Approach

Match expressions let you handle all possible variants explicitly:


use std::fs::File;
use std::io::ErrorKind;

let file_result = File::open("config.txt");
match file_result {
    Ok(file) => {
        println!("File opened successfully!");
        // Process the file...
    },
    Err(error) => {
        println!("Failed to open file: {}", error);
        // Handle the error...
    }
}

The power of match is that it forces you to handle all possibilities, making your code robust and reducing the surface area for elusive bugs.

You can also handle different error types:


use std::fs::File;
use std::io::ErrorKind;

match file_result {
    Ok(file) => process_file(file),
    Err(error) => match error.kind() {
        ErrorKind::NotFound => {
            println!("File not found, creating it...");
            match File::create("config.txt") {
                Ok(new_file) => process_file(new_file),
                Err(e) => println!("Failed to create file: {}", e),
            }
        },
        ErrorKind::PermissionDenied => {
            println!("Permission denied, please check file permissions")
        },
        _ => println!("Unknown error: {}", error),
    }
}

If Let Conditional - For When You Care About One Case

When you only need to handle one variant and don't care about the other, if let provides a more concise approach:


let maybe_content: Option<&str> = Some("file_content");
if let Some(content) = maybe_content {
    println!("Content found: {}", content);
    // Process the content...
} else {
    println!("No content available");
    // Alternative path...
}

let operation_result: Result<&str, &str> = Ok("operation_successful");
if let Ok(message) = operation_result {
    println!("Success: {}", message);
} else {
    println!("Operation failed");
}

This is cleaner than match when you don't need the detailed pattern matching, but still want to handle both cases, especially with Option types.

Unwrap Methods

Rust provides several unwrap methods for extracting values from Result or Option types:

unwrap()


let value = Some(42).unwrap(); // Gets 42
// let crash = None.unwrap(); // Would panic!

let success = Ok("data").unwrap(); // Gets "data"
// let boom = Err("failed").unwrap(); // Would panic!

unwrap() extracts the value if present, but panics if it's None or Err. Use it only when:

  • You're prototyping and don't want to handle errors yet

  • You're certain the operation cannot fail, for example, if you have already run a check earlier

  • A failure would indicate a programming error, like in tests, where it's necessary for tracing the errors easily

expect() - Unwrap With a Message


let value = Some(42).expect("Critical value missing!"); 
// If None, panics with your message in the logs

Like unwrap() but with a custom error message to make debugging easier.

unwrap_or() - Fallback to a Default


let maybe_value: Option<&str> = None;
let value = maybe_value.unwrap_or("default_value");
println!("Value: {}", value); // Value: default_value

let operation: Result<&str, &str> = Err("resource unavailable");
let result = operation.unwrap_or("alternative_resource");
println!("Using: {}", result); // Using: alternative_resource

This is safer than unwrap() as it provides a fallback value instead of panicking.

unwrap_or_else() - Compute a Fallback


let maybe_config: Option<&str> = None;
let config = maybe_config.unwrap_or_else(|| {
    println!("Generating default config...");
    "generated_config"
});
println!("Config: {}", config); // Config: generated_config

let process: Result<&str, &str> = Err("invalid input");
let output = process.unwrap_or_else(|err| {
    println!("Error occurred: {}", err);
    "default_output"
});

Similar to unwrap_or() but the default value is computed only when needed, potentially saving resources. This could be useful if the fallback is a large value or expensive to compute.

Error Propagation with ? - The Modus Operandi

The ? operator simplifies error propagation in functions that return Result or Option:


use std::fs::File;
use std::io::{self, Read};

fn read_config() -> Result<String, io::Error> {
    let mut file = File::open("config.txt")?; // If Err, returns from function
    let mut content = String::new();
    file.read_to_string(&mut content)?; // Same here
    
    // This code only executes if both operations succeed
    Ok(content)
}

Without ?, this would require much more verbose error handling:


use std::fs::File;
use std::io::{self, Read};

fn read_config_verbose() -> Result<String, io::Error> {
    let file_result = File::open("config.txt");
    let mut file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e), // Early return on error
    };
    
    let mut content = String::new();
    let read_result = file.read_to_string(&mut content);
    match read_result {
        Ok(_) => Ok(content),
        Err(e) => Err(e),
    }
}

How the ? operator works:

  1. Returns early from the function if the Result is Err or the Option is None

  2. Unwraps the value if the Result is Ok or the Option is Some

  3. For Result, it also automatically converts error types if needed (using the From trait)

Enhancing Errors with Context

Often, you want to add context to errors as they propagate up the call stack. You can use .map_err() for this:


use std::fs::File;
use std::io;

fn read_config() -> Result<String, io::Error> {
    let file = File::open("config.txt").map_err(|e| {
        eprintln!("Failed to open config file: {}", e);
        e // Return the original error
    })?;
    
    // Continue processing...
    Ok("Config content".to_string())
}

Creating Custom Errors

For larger applications, creating custom error types improves code organization and error handling:


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

#[derive(Debug)]
enum FileError {
    NotFound,
    PermissionDenied(String), // Can include extra details
    ReadError,
    WriteError,
}

// Create a type alias for convenience - this is a Result alias with a predefined Error enum
type FileResult<T> = Result<T, FileError>;

// Implement Display trait for user-friendly error messages
impl fmt::Display for FileError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FileError::NotFound => write!(f, "File not found"),
            FileError::PermissionDenied(details) => write!(f, "Permission denied: {}", details),
            FileError::ReadError => write!(f, "Error reading file"),
            FileError::WriteError => write!(f, "Error writing to file"),
        }
    }
}

// Implement Error trait for integration with Rust's error handling
impl Error for FileError {}

// Example usage
fn process_file(path: &str) -> FileResult<String> {
    if !std::path::Path::new(path).exists() {
        return Err(FileError::NotFound);
    }
    
    Ok("File content".to_string())
}

For functions that need to handle multiple error types, you can use Box<dyn Error>:


use std::error::Error;

fn complex_operation() -> Result<(), Box<dyn Error>> {
    let content = process_file("data.txt")?;
    let parsed_data = parse_content(&content)?; // Returns a different error type
    save_to_database(parsed_data)?; // Yet another error type
    Ok(())
}

The different error types would be dynamically dispatched since they all implement the general Error trait.

When to Panic vs. Return Results

Understanding when to use panic! versus Result is crucial for robust error handling:

Scenario

Recommended Approach

Example

Logic errors or invariant violations

Use panic!

Asserting that an index is in bounds for an optimization

Invalid internal function arguments

Use panic!

A function that requires positive values

External system interactions

Use Result<T, E>

File operations, network requests

User input validation

Use Result<T, E>

Parsing user-provided data

Library code

Almost always use Result<T, E>

To allow users to decide how to handle errors

The general rule: "If the error is recoverable, return a Result; otherwise panic, unless you're writing a library, in which case also return a Result."

Error Handling Quick Reference

Method

When to use

Behavior on Error

match

Complete control over all possible outcomes

Custom handling for each case

if let

Simple handling of the success case

Falls through to else block

unwrap()

Prototyping, certain success, testing

Panics with default message

expect()

Same as unwrap(), better debug info

Panics with your custom message

unwrap_or()

Simple fallback value

Returns provided default value

unwrap_or_else()

Computed fallback

Calls closure to compute fallback value

? operator

Clean error propagation in functions

Early return with error conversion

.map_err()

Add context to errors

Transforms the error while preserving failure

Summary

In Rust, error handling may seem challenging, but it is a bedrock to ensure you write safe, robust code. By forcing you to consider failure cases explicitly, Rust helps prevent bugs and ensures your software exits gracefully when issues occur. 

Yes, you can reach for more ergonomic approaches with crates like anyhow and thiserror, however, this guide is for you to master the core 20% of techniques that will handle about 80% of your everyday error-handling tasks. 

These fundamental patterns remain essential even when you bring advanced libraries into your toolkit

Key Points to Remember:

  • Use Option when a value might be absent

  • Use Result when an operation might fail with an error

  • Use match for complete control, if let for simpler cases

  • Use the ? operator to propagate errors elegantly

  • Create custom error types for larger applications

  • Choose appropriate error handling based on whether the error is recoverable

Further Resources

If you're interested in learning Rust better, you can try out our comprehensive Rust course.

Feel free to message me on LinkedIn if you have any questions. Stay rusty!

Course image
Become a Rust Backend Engineeer today

All-in-one Rust course for learning backend engineering with Rust. This comprehensive course is designed for Rust developers seeking proficiency in Rust.

Start Learning Now

Whenever you're ready

There are 4 ways we can help you become a great backend engineer:

The MB Platform

Join 1000+ backend engineers learning backend engineering. Build real-world backend projects, learn from expert-vetted courses and roadmaps, track your learnings and set schedules, and solve backend engineering tasks, exercises, and challenges.

The MB Academy

The “MB Academy” is a 6-month intensive Advanced Backend Engineering BootCamp to produce great backend engineers.

Join Backend Weekly

If you like post like this, you will absolutely enjoy our exclusive weekly newsletter, Sharing exclusive backend engineering resources to help you become a great Backend Engineer.

Get Backend Jobs

Find over 2,000+ Tailored International Remote Backend Jobs or Reach 50,000+ backend engineers on the #1 Backend Engineering Job Board

Backend Tips, Every week

Backend Tips, Every week