3 min read
On this page

Error Handling Patterns

Knowing the mechanics of Result and ? is step one. Knowing when to recover, when to propagate, when to panic, and how to produce useful error messages is what separates production-grade Rust from tutorial Rust. Error handling is a design decision, not just syntax.

Recoverable vs Unrecoverable Errors

The fundamental split in Rust error handling:

  • Recoverable: The operation failed but the program can continue. A file was not found, a network request timed out, a user provided invalid input. Use Result<T, E>.
  • Unrecoverable: The program is in an invalid state and cannot continue safely. An invariant was violated, an index was out of bounds on a vector the code controls, a programmer made an error. Use panic!.
use std::fs;

fn load_required_config() -> String {
    // If the config shipped with the binary is missing,
    // the installation is broken. Panic is appropriate.
    fs::read_to_string("/opt/myapp/default.conf")
        .expect("default config must exist in installation directory")
}

fn load_user_config(path: &str) -> Result<String, std::io::Error> {
    // User-specified config might not exist. That is recoverable.
    fs::read_to_string(path)
}

fn main() {
    let defaults = load_required_config();

    match load_user_config("~/.myapp/config.toml") {
        Ok(overrides) => println!("Loaded user config ({} bytes)", overrides.len()),
        Err(_) => println!("No user config, using defaults"),
    }

    println!("Default config: {} bytes", defaults.len());
}

panic! Is for Bugs, Not Errors

panic! terminates the current thread (or the program, depending on configuration). Reserve it for situations that indicate a bug in your code:

fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        // Bad: this is user input. Return an error.
        // panic!("division by zero");

        // Good: caller decides what to do
        return f64::NAN;
    }
    a / b
}

struct Matrix {
    data: Vec<Vec<f64>>,
}

impl Matrix {
    fn get(&self, row: usize, col: usize) -> f64 {
        // This panic is acceptable: if the caller passes an
        // out-of-bounds index, it is a bug in the calling code.
        assert!(row < self.data.len(), "row {} out of bounds", row);
        assert!(col < self.data[row].len(), "col {} out of bounds", col);
        self.data[row][col]
    }
}

fn main() {
    println!("{}", divide(10.0, 3.0));
    println!("{}", divide(10.0, 0.0));

    let m = Matrix {
        data: vec![vec![1.0, 2.0], vec![3.0, 4.0]],
    };
    println!("{}", m.get(0, 1));
}
3.3333333333333335
NaN
2

Use assert!, assert_eq!, and debug_assert! for invariant checks. debug_assert! is removed in release builds — use it for expensive checks that catch bugs during development.

Error Context: Make Errors Useful

Bad error messages waste hours. Good error messages tell you what happened, where, and what to do about it.

use std::fs;
use std::path::Path;

#[derive(Debug)]
struct AppError {
    message: String,
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

// Bad: "failed to read config"
// Why is this bad? Which config? What error?

// Good: "failed to read /etc/app/config.toml: permission denied"
// Now you know the file, the problem, and can fix it.

fn load_database_url(config_path: &Path) -> Result<String, AppError> {
    let contents = fs::read_to_string(config_path).map_err(|e| AppError {
        message: format!(
            "failed to read config from {}: {}",
            config_path.display(),
            e
        ),
    })?;

    let url = contents
        .lines()
        .find(|l| l.starts_with("database_url="))
        .map(|l| l.trim_start_matches("database_url=").to_string())
        .ok_or_else(|| AppError {
            message: format!(
                "config at {} is missing required field 'database_url'",
                config_path.display()
            ),
        })?;

    if !url.starts_with("postgres://") && !url.starts_with("mysql://") {
        return Err(AppError {
            message: format!(
                "invalid database_url in {}: expected postgres:// or mysql:// scheme, got '{}'",
                config_path.display(),
                &url[..url.find(':').unwrap_or(url.len())]
            ),
        });
    }

    Ok(url)
}

fn main() {
    let path = Path::new("/etc/myapp/config.toml");
    match load_database_url(path) {
        Ok(url) => println!("Database: {}", url),
        Err(e) => eprintln!("Error: {}", e),
    }
}
Error: failed to read config from /etc/myapp/config.toml: No such file or directory (os error 2)

Logging Errors vs Returning Them

Not every error should bubble up. Sometimes the right response is to log it, apply a default, and continue:

use std::collections::HashMap;

fn load_feature_flags(path: &str) -> HashMap<String, bool> {
    match std::fs::read_to_string(path) {
        Ok(contents) => {
            let mut flags = HashMap::new();
            for line in contents.lines() {
                if let Some((key, value)) = line.split_once('=') {
                    if let Ok(enabled) = value.trim().parse::<bool>() {
                        flags.insert(key.trim().to_string(), enabled);
                    } else {
                        eprintln!("warning: invalid flag value for '{}': '{}'", key.trim(), value.trim());
                    }
                }
            }
            flags
        }
        Err(e) => {
            eprintln!("warning: could not load feature flags from {}: {}", path, e);
            eprintln!("warning: using default flags");
            HashMap::new()
        }
    }
}

fn main() {
    let flags = load_feature_flags("features.conf");
    let dark_mode = flags.get("dark_mode").copied().unwrap_or(false);
    println!("Dark mode: {}", dark_mode);
}
warning: could not load feature flags from features.conf: No such file or directory (os error 2)
warning: using default flags
Dark mode: false

The decision framework:

  • Return the error when the caller has context to decide how to handle it
  • Log and continue when the failure is non-critical and a sensible default exists
  • Panic when the error indicates a bug that makes further execution unsafe

The Early Return Pattern

Structure functions so errors are handled early and the main logic is unindented:

use std::collections::HashMap;

struct Request {
    headers: HashMap<String, String>,
    body: String,
}

#[derive(Debug)]
struct Response {
    status: u16,
    body: String,
}

fn handle_request(req: &Request) -> Response {
    // Validate early, return errors immediately
    let auth = match req.headers.get("Authorization") {
        Some(token) => token,
        None => return Response {
            status: 401,
            body: "missing Authorization header".to_string(),
        },
    };

    if !auth.starts_with("Bearer ") {
        return Response {
            status: 401,
            body: "invalid Authorization format".to_string(),
        };
    }

    let token = &auth[7..];
    if token.is_empty() {
        return Response {
            status: 401,
            body: "empty token".to_string(),
        };
    }

    // Happy path — all validation passed
    let data = process_with_token(token, &req.body);
    Response {
        status: 200,
        body: data,
    }
}

fn process_with_token(token: &str, body: &str) -> String {
    format!("processed {} bytes for token {}", body.len(), &token[..8.min(token.len())])
}

fn main() {
    let mut headers = HashMap::new();
    headers.insert("Authorization".to_string(), "Bearer abc12345xyz".to_string());

    let req = Request {
        headers,
        body: "hello world".to_string(),
    };

    let resp = handle_request(&req);
    println!("{}: {}", resp.status, resp.body);
}
200: processed 11 bytes for token abc12345

Retry Pattern

For transient failures, a simple retry with backoff:

use std::thread;
use std::time::Duration;

#[derive(Debug)]
enum FetchError {
    Timeout,
    ServerError(u16),
    NotFound,
}

fn fetch_data(url: &str) -> Result<String, FetchError> {
    // Simulating an unreliable service
    static mut CALLS: u32 = 0;
    unsafe {
        CALLS += 1;
        if CALLS < 3 {
            return Err(FetchError::Timeout);
        }
    }
    Ok(format!("data from {}", url))
}

fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, FetchError> {
    let mut last_error = FetchError::Timeout;

    for attempt in 0..=max_retries {
        match fetch_data(url) {
            Ok(data) => return Ok(data),
            Err(FetchError::NotFound) => return Err(FetchError::NotFound), // don't retry 404
            Err(e) => {
                eprintln!("attempt {}: {:?}, retrying...", attempt + 1, e);
                last_error = e;
                if attempt < max_retries {
                    thread::sleep(Duration::from_millis(100 * 2u64.pow(attempt)));
                }
            }
        }
    }

    Err(last_error)
}

fn main() {
    match fetch_with_retry("https://api.example.com/data", 3) {
        Ok(data) => println!("Success: {}", data),
        Err(e) => eprintln!("Failed after retries: {:?}", e),
    }
}
attempt 1: Timeout, retrying...
attempt 2: Timeout, retrying...
Success: data from https://api.example.com/data

Notice that NotFound is not retried — retrying a 404 is pointless. The error type determines the retry strategy.

Converting Between Option & Result

fn find_port_in_config(config: &str) -> Result<u16, String> {
    config
        .lines()
        .find(|l| l.starts_with("port="))
        .ok_or_else(|| "missing 'port' in config".to_string())?  // Option -> Result
        .trim_start_matches("port=")
        .parse::<u16>()
        .map_err(|e| format!("invalid port: {}", e))
}

fn find_user(id: u64) -> Option<String> {
    let result: Result<String, String> = lookup_database(id);
    result.ok()  // Result -> Option (discards the error)
}

fn lookup_database(id: u64) -> Result<String, String> {
    if id == 1 {
        Ok("Alice".to_string())
    } else {
        Err("not found".to_string())
    }
}

fn main() {
    println!("{:?}", find_port_in_config("host=localhost\nport=8080"));
    println!("{:?}", find_port_in_config("host=localhost"));
    println!("{:?}", find_user(1));
    println!("{:?}", find_user(99));
}
Ok(8080)
Err("missing 'port' in config")
Some("Alice")
None

ok_or / ok_or_else converts Option to Result. .ok() converts Result to Option (discarding the error).

Common Pitfalls

  • Catching panics with std::panic::catch_unwind. This is for FFI boundaries and test harnesses, not error handling. If you are catching panics in business logic, redesign your error handling.
  • Logging the same error at multiple levels. If each layer logs the error and then propagates it, you get the same error printed 5 times. Log at the point where you handle the error, not where you propagate it.
  • Treating all errors the same. A timeout is different from a permission error is different from invalid input. Your error handling should distinguish them when the response differs.
  • Swallowing errors silently. let _ = risky_operation(); suppresses the result. If you intentionally ignore an error, add a comment explaining why. Otherwise, handle it.
  • Not testing error paths. Write tests that trigger every error variant. The happy path is easy. The edge cases are where bugs live.
  • Excessive wrapping. Not every function needs its own error type. If you are just passing errors through, a single application-level error type (or anyhow) is fine.

Key Takeaways

  • Recoverable errors use Result. Unrecoverable bugs use panic!. The distinction is a design decision.
  • Error messages should include what failed, where, and enough context to diagnose the problem.
  • Log errors at the handling site, not at every propagation step.
  • Use early returns for validation to keep the happy path clean and unindented.
  • Not all errors should be retried. Match on the error type to decide the strategy.
  • ok_or_else converts Option to Result. .ok() goes the other direction. Use them to bridge the two types.