---
title: What are error handling best practices
summary: Use Result for recoverable errors with the ? operator and panic! only for unrecoverable failures in Rust.
lang: en
slug: what-are-error-handling-best-practices
published: 2026-04-17T12:05:42+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/what-are-error-handling-best-practices/
---

# What are error handling best practices



## You are building a command-line tool that reads a configuration file

The file might not exist. It might be corrupted. The disk might be full. In a language with exceptions, you wrap the call in a try-catch and hope the error message makes sense later. In Rust, the compiler refuses to let you ignore the possibility of failure. Every function that can fail declares it. You have to decide what to do before the code even runs.

Rust splits error handling into two buckets. Recoverable errors use `Result<T, E>`. Unrecoverable errors use `panic!`. Think of `Result` as a sealed envelope. The function hands you the envelope. It contains either the data you asked for or a note explaining why it failed. You must open the envelope to proceed. `panic!` is the kitchen catching fire. The program stops immediately, unwinds the stack, and prints a traceback. You don't recover from a panic. You restart.

Treat `Result` as a value, not an exception. You own the error.

## The minimal pattern

Most error handling in Rust revolves around `Result<T, E>` and the `?` operator. `Result` is an enum with two variants: `Ok(T)` for success and `Err(E)` for failure. The `?` operator propagates errors automatically. If the value is `Ok`, it extracts the inner value. If it is `Err`, it returns the error from the current function immediately.

```rust
use std::fs;

/// Reads a file and returns its contents or an IO error.
fn read_config(path: &str) -> Result<String, std::io::Error> {
    // fs::read_to_string returns a Result.
    // The ? operator extracts the value on success.
    // On error, it returns early from this function with the error.
    fs::read_to_string(path)
}

fn main() {
    // main can return Result to let the OS handle the exit code.
    // This is a common convention for CLI tools.
    match read_config("config.toml") {
        Ok(contents) => println!("Config: {contents}"),
        Err(e) => eprintln!("Failed to read config: {e}"),
    }
}
```

When `read_config` calls `fs::read_to_string`, the filesystem operation might fail. The `?` operator checks the result. If it is `Ok`, the string flows through. If it is `Err`, `?` immediately returns that error from `read_config`. The function stops. No manual `match` needed for propagation. In `main`, the `match` forces you to handle both cases. You can print the error or take action. The compiler guarantees you cannot access `contents` without proving the result is `Ok`.

Trust the type system on errors. If the code compiles, you handled the failure.

## Conventions that pay off

The community follows a few small patterns that make error handling readable and consistent.

Use `eprintln!` for error messages. `println!` writes to standard output. `eprintln!` writes to standard error. This separation lets users pipe the successful output to another tool while still seeing errors on the console.

Return `Result<(), E>` from `main` in command-line tools. The compiler provides a special implementation for `main`. If `main` returns a `Result`, the program exits with a non-zero status code on error and prints the error message. This saves you from writing a boilerplate `match` in every binary.

Write `let _ = result;` when you intentionally ignore a result. The compiler warns about unused results by default. Assigning to `_` signals to readers that you considered the value and chose to drop it.

## Realistic error composition

Real programs combine multiple sources of failure. You might read a file and then parse a number. The file read can fail with `std::io::Error`. The parse can fail with `std::num::ParseIntError`. You cannot return both types directly. You need a way to unify them.

The standard approach is a custom error enum. Each variant wraps one of the underlying errors. You use `map_err` to convert the specific errors into your custom type so the `?` operator can propagate them.

```rust
use std::fs;
use std::num::ParseIntError;

/// Custom error type to combine IO and parsing failures.
#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(ParseIntError),
}

/// Reads a file and parses the first line as an integer.
fn read_port(path: &str) -> Result<u16, ConfigError> {
    // Read the file, mapping the IO error to our custom variant.
    // map_err transforms the error type so ? can propagate it.
    let content = fs::read_to_string(path)
        .map_err(ConfigError::Io)?;

    // Parse the integer, mapping the parse error.
    let port: u16 = content
        .trim()
        .parse()
        .map_err(ConfigError::Parse)?;

    Ok(port)
}
```

Most crates use `thiserror` to derive `Error` implementations. Writing `map_err` manually is educational, but in production code, you'll likely see `#[derive(Error)]`. The `thiserror` crate generates the boilerplate for implementing `std::error::Error` and the `From` conversions automatically.

Don't leak implementation details. Wrap external errors in your own type.

## How the `?` operator converts errors

The `?` operator does more than propagate. It converts errors. If the function returns `Result<T, ConfigError>` and you use `?` on a `Result<T, std::io::Error>`, Rust looks for a `From<std::io::Error> for ConfigError` implementation. If it exists, `?` calls `From::from` to convert the error automatically. You don't need `map_err` if `From` is implemented.

This is why `thiserror` is so popular. It generates the `From` impls for you. When you annotate a variant with `#[from]`, the crate writes the conversion code. The `?` operator then works seamlessly without explicit mapping.

If you forget the `From` impl, the compiler rejects the code with `E0277` (trait bound not satisfied). It tells you the error types don't match and no conversion exists. The error message usually points to the `?` operator and suggests implementing `From`.

Implement `From` for your error variants. Let `?` do the heavy lifting.

## Pitfalls and compiler errors

Calling `unwrap()` on a `Result` panics if the value is `Err`. This turns a recoverable error into a crash. Use `unwrap()` only in tests or when you have proven the error is impossible. If you reach for `unwrap`, ask yourself if the program can actually survive the failure.

Shadowing errors is a common trap. When chaining `?` operators, Rust infers the error type. If one call returns `std::io::Error` and another returns `ParseIntError`, the compiler cannot unify them. You get a type mismatch error. You need a custom error type or a trait object like `Box<dyn std::error::Error>`.

If you try to return a `Result<String, std::io::Error>` from a function that returns `String`, the compiler rejects it with `E0308` (mismatched types). You must handle the error or propagate it. The compiler will not silently drop the error.

Don't fight the compiler here. Reach for a custom error enum.

## When to use what

Use `Result<T, E>` for recoverable errors where the caller can retry, fallback, or report the issue. Use `panic!` for programming bugs, invariants that should never be violated, or unrecoverable states like out-of-memory. Use the `?` operator to propagate errors up the call stack when the current function cannot handle the failure meaningfully. Use `map_err` to convert error types when you need to unify different error sources into a single return type and `From` is not implemented. Use `unwrap_or` or `unwrap_or_else` when a sensible default exists and the error is expected or ignorable. Use `thiserror` to define custom error enums in library code; it handles the boilerplate of implementing the `std::error::Error` trait. Use `Box<dyn std::error::Error>` in `main` functions or simple scripts where you want to accept any error type without defining a custom enum.

Make the compiler your safety net. If the code compiles, the errors are handled.

## Where to go next

- [How to implement std error Error trait](/en/how-to-implement-std-error-error-trait/)
- [How to Use Box<dyn Error> for Error Handling in Rust](/en/how-to-use-boxdyn-error-for-error-handling-in-rust/)
- [How to use unwrap_or and unwrap_or_else](/en/how-to-use-unwrapor-and-unwraporelse/)