Error Handling in Rust:
Result and panic!

Last updated: April 13, 2025

1. Introduction: Robustness Through Explicit Handling

Unlike many languages that rely heavily on exceptions, Rust distinguishes between two main categories of errors: recoverable errors and unrecoverable errors. This distinction forces developers to think explicitly about how failure should be handled, leading to more robust and reliable programs.

Rust provides different mechanisms for dealing with each category: the Result<T, E> enum for recoverable errors and the panic! macro for unrecoverable errors. Understanding when and how to use each is crucial for writing idiomatic Rust code.

If you're just beginning with Rust, ensure you've covered the basics in our Getting Started with Rust guide.

2. Categories of Errors in Rust

  • Recoverable Errors: These are errors that are expected to happen occasionally and should be handled gracefully by the program. Examples include a file not being found, network connection issues, or invalid user input. Rust uses the Result<T, E> enum to represent these.
  • Unrecoverable Errors: These represent bugs or conditions from which the program cannot realistically recover. Examples include accessing an array index out of bounds, encountering an unexpected internal state, or hitting a situation that indicates a logic error in the code itself. Rust uses the panic! macro to signal these.

3. Unrecoverable Errors with panic!

When something goes irrecoverably wrong, the panic! macro is used. Invoking panic! stops the normal execution flow.

3.1 When to Use panic!

Panicking is appropriate when:

  • A program reaches a state that shouldn't be possible (a bug).
  • An external condition violates a fundamental contract the code relies on.
  • You're writing example code, tests, or prototypes where robust error handling isn't the primary focus yet (though using unwrap() or expect() is often preferred even here).

You generally **should not** panic for expected failures like user input errors or file-not-found scenarios. Use Result for those.

fn main() {
    // Example: Explicit panic
    // panic!("Something went terribly wrong!");

    // Example: Implicit panic from array out-of-bounds access
    let numbers = [1, 2, 3];
    println!("Accessing index 5: {}", numbers[5]); // This will panic!
}

3.2 How panic! Works

By default, when a panic occurs, Rust performs **stack unwinding**. It walks back up the call stack, cleaning up the data owned by each function it encounters. This cleanup process can be resource-intensive.

Alternatively, you can configure Rust to **abort** immediately upon panic (by adjusting settings in Cargo.toml). Aborting skips cleanup and hands control directly back to the operating system, resulting in a smaller binary but potentially leaving resources in an unclean state.

4. Recoverable Errors with Result<T, E>

For errors that are expected and should be handled, Rust uses the Result enum. It represents either success (Ok) containing a value, or failure (Err) containing an error value.

4.1 The Result Enum

Its definition is straightforward:

enum Result {
    Ok(T),  // Contains the success value of type T
    Err(E), // Contains the error value of type E
}

Functions that might fail return a Result. For example, File::open returns Result<std::fs::File, std::io::Error>.

4.2 Handling Result with match

The most fundamental way to handle a Result is using a match expression, forcing you to consider both the Ok and Err cases:

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

fn main() {
    let file_result = File::open("hello.txt");

    let file_handle = match file_result {
        Ok(file) => {
            println!("File opened successfully.");
            file // Return the file handle if Ok
        },
        Err(error) => match error.kind() {
            // Handle specific error kinds
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => {
                    println!("File not found, created it instead.");
                    fc
                },
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            // Handle other kinds of errors
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
    // Use file_handle here...
}

Matching ensures you explicitly handle all possible outcomes.

4.3 Convenience Methods: unwrap() and expect()

Result provides shortcut methods, but use them cautiously:

  • unwrap(): Returns the value inside Ok. If the Result is Err, it **panics** with a default error message. Convenient for examples or situations where failure is considered a bug, but risky in production code for expected errors.
  • expect(message: &str): Similar to unwrap(), but allows you to provide a custom panic message if the Result is Err. Slightly better than unwrap() as the message provides more context on panic.
// Using unwrap (can panic!)
// let file_handle = File::open("hello.txt").unwrap();

// Using expect (can panic, but with a custom message)
let file_handle = File::open("hello.txt")
  .expect("Failed to open hello.txt");

Generally, prefer using match or the ? operator (see below) over unwrap/expect for recoverable errors in library or application code.

5. Propagating Errors with the ? Operator

Manually matching every Result can be verbose. The ? operator provides a concise way to **propagate** errors up the call stack.

If used on a Result value within a function that *itself* returns a Result:

  • If the value is Ok(T), the T inside is extracted and assigned to the variable.
  • If the value is Err(E), the Err(E) is **returned immediately** from the *entire function*. The error type E must be compatible with the function's declared error return type (often involving the From trait for conversions).
use std::fs::File;
use std::io::{self, Read};

// This function returns Result, so we can use ? inside it.
fn read_username_from_file() -> Result {
    let mut username_file = File::open("username.txt")?; // If Err, returns Err from function
    let mut username = String::new();
    username_file.read_to_string(&mut username)?; // If Err, returns Err from function
    Ok(username) // If both operations succeed, return Ok(username)
}
// Note: The ? operator can only be used in functions that return Result or Option.

fn main() {
    match read_username_from_file() {
        Ok(username) => println!("Username: {}", username),
        Err(e) => println!("Error reading username: {}", e),
    }
}

The ? operator significantly cleans up error handling code where errors need to be passed upwards.

6. Creating Custom Error Types (Briefly)

For more complex applications or libraries, it's often beneficial to define custom error types (usually structs or enums) that implement the std::error::Error trait. This allows you to provide more specific information about failures and integrate well with the ? operator and other error-handling libraries (like thiserror or anyhow).

7. Conclusion: Choosing When to Panic

Rust's error handling philosophy encourages developers to anticipate and handle potential failures explicitly using the Result enum. The panic! macro is reserved for truly exceptional, unrecoverable situations, often indicating a bug in the program's logic.

  • Use Result<T, E> for expected, recoverable errors (file not found, network issues, invalid input). Handle them using match or propagate them using the ? operator.
  • Use panic! (or methods like unwrap/expect that call panic) primarily for programming errors or unrecoverable states where continuing execution is impossible or unsafe.

By distinguishing these cases and leveraging Rust's type system, you can build more robust and reliable software.

8. Additional Resources

Related Articles

External Resources