How Does Error Handling Work in Rust? (No Exceptions!)

Rust uses the Result enum and panic! macro to force explicit error handling without exceptions, ensuring robust and safe code.

Errors are values, not events

You're writing a script to fetch a configuration file, parse some JSON, and connect to a database. In Python, you wrap the whole thing in a try block and hope for the best. If the file is missing, a FileNotFoundError bubbles up. If the JSON is malformed, a json.JSONDecodeError crashes your program. You add except clauses as bugs appear, often catching too much and hiding real problems. In JavaScript, you chain .catch() or use async/await, and errors flow through a hidden channel that only the runtime understands.

Rust makes you stop. You can't just hope. The compiler forces you to look at every single thing that could go wrong before your code runs. If a function can fail, its signature says so. If you call it, you must handle the failure. There are no hidden exceptions. There is no silent crash. Errors are values, just like integers or strings, and they flow through your code explicitly.

The Result enum

Rust represents recoverable errors with the Result enum. It has two variants: Ok(T) for success and Err(E) for failure. The T is the success value. The E is the error value. When a function returns Result, it hands you a box. You have to open the box to see what's inside. You can't assume the box contains success.

Think of a function call like ordering food at a restaurant. The waiter brings you a covered dish. The dish is either the meal you ordered (Ok) or a note explaining why the kitchen couldn't make it (Err). You have to lift the lid. If you try to eat the dish without lifting the lid, you might get a note instead of food. Rust forces you to lift the lid.

fn divide(a: f64, b: f64) -> Result<f64, String> {
    // Return an error if division by zero is attempted
    if b == 0.0 {
        Err("Cannot divide by zero".to_string())
    } else {
        // Return the result wrapped in Ok
        Ok(a / b)
    }
}

fn main() {
    // Call the function and get a Result
    let result = divide(10.0, 2.0);

    // Inspect the Result with match
    match result {
        Ok(value) => println!("The result is {}", value),
        Err(e) => println!("Error: {}", e),
    }
}

The match expression forces you to handle both cases. If you forget the Err arm, the compiler rejects the code. You can't ignore errors. This makes your code robust by default. Every error path is accounted for.

Errors are values. Treat them like data, not exceptions.

Propagating errors with the ? operator

Writing match for every function call gets tedious. Most of the time, you don't want to handle the error locally. You want to pass it up to the caller. Rust provides the ? operator for this. It's syntactic sugar that checks the Result. If it's Ok, it extracts the value. If it's Err, it returns the error from the current function immediately.

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

fn read_config(path: &str) -> Result<String, io::Error> {
    // Open the file. If this fails, return the error early.
    let file = fs::File::open(path)?;

    // Read the contents. If this fails, return the error early.
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    // Return the contents wrapped in Ok
    Ok(contents)
}

The ? operator makes error propagation clean. You focus on the success path. The compiler generates the error handling boilerplate. If File::open fails, read_config returns that error. The caller gets the error and decides what to do.

Convention aside: The community uses ? for propagation almost exclusively. Writing manual match blocks just to return an error is considered verbose and unidiomatic. Use ? and keep your logic visible.

Let the compiler handle the boilerplate. Use ? and keep your logic visible.

Type conversion and the From trait

The ? operator does more than unwrap values. It also converts error types. This is one of Rust's most powerful features. If a function returns Result<T, E1> and you use ? in a function that returns Result<T, E2>, the compiler tries to convert E1 to E2 using the From trait.

This works because standard library types implement From for each other. For example, io::Error implements From<std::num::ParseIntError>. This means you can use ? to propagate a parse error into an IO error.

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

fn read_and_parse_number(path: &str) -> Result<i32, io::Error> {
    // Read the file. Returns io::Error on failure.
    let contents = fs::read_to_string(path)?;

    // Parse the number. Returns ParseIntError on failure.
    // The ? operator converts ParseIntError to io::Error automatically.
    let number = contents.trim().parse::<i32>()?;

    Ok(number)
}

The parse method returns Result<i32, ParseIntError>. The function signature says Result<i32, io::Error>. The ? operator sees the mismatch and looks for a From<ParseIntError> implementation for io::Error. It finds one, performs the conversion, and returns the error. This allows you to write clean code without manual error mapping.

Ah-ha moment: The ? operator works on Option just like Result. It returns None early if the value is None. This unifies the mental model for missing data and errors. Both use the same propagation mechanism.

Errors flow through the type system. Let From handle the conversions.

Panic for unrecoverable failures

Not all errors are recoverable. Sometimes the program is in a state it can't handle. Maybe an invariant is violated. Maybe a critical resource is missing. In these cases, you use panic!. It unwinds the stack, prints a message, and exits the program.

fn get_first_element(items: &[i32]) -> i32 {
    // Panic if the slice is empty
    if items.is_empty() {
        panic!("Cannot get first element of an empty slice");
    }
    items[0]
}

Use panic! sparingly. It's for bugs, not for expected failures. If a function can fail because of bad input, return a Result. If a function can fail because of a logic error in your code, panic.

Convention aside: The community distinguishes between library code and application code. Libraries should panic only for bugs. Applications can panic for unrecoverable errors, but they should still prefer Result where possible. This keeps libraries safe and predictable.

Panic is the nuclear option. Reserve it for bugs, not bad input.

Pitfalls and compiler errors

Rust's error handling catches mistakes early. Here are common pitfalls and how the compiler helps.

If you try to use ? in a function that doesn't return a Result, the compiler rejects you with E0277 (the trait From<...> is not implemented). You can't propagate errors out of a function that doesn't support it.

fn bad_example() {
    // This fails to compile
    let _ = fs::read_to_string("file.txt")?;
}

The compiler tells you that ? can only be used in functions that return Result or Option. You need to change the return type or handle the error manually.

If you forget to handle a Result, the compiler rejects you with E0308 (mismatched types). You can't assign a Result to a variable that expects the inner type.

fn another_bad_example() {
    // This fails to compile
    let contents = fs::read_to_string("file.txt");
    println!("{}", contents);
}

The compiler sees that contents is a Result<String, io::Error>, but you're trying to print it as a string. You need to unwrap the result or handle the error.

If you use unwrap() on an Err, the program panics. This is often a sign of poor error handling. Use expect() instead, which takes a message. This makes debugging easier.

fn better_example() {
    let contents = fs::read_to_string("file.txt")
        .expect("Failed to read config file");
    println!("{}", contents);
}

The expect message appears in the panic output, telling you exactly what went wrong. This is a small habit that saves hours of debugging.

Trust the borrow checker. It usually has a point. Treat errors the same way. Trust the compiler to catch missing handlers.

Decision matrix

Use Result when the error is recoverable and the caller can decide how to proceed. Use Result for I/O operations, network requests, parsing, and any function that interacts with the outside world.

Use ? when you want to propagate the error up the call stack without changing its type. Use ? in functions that return Result or Option to keep the success path clean.

Use match when you need to handle the error locally or transform it into a different type. Use match when you want to retry an operation, log the error, or provide a fallback value.

Use panic! when the program is in an invalid state and continuing would cause data corruption or security issues. Use panic! for bugs, invariant violations, and unreachable code.

Use expect() instead of unwrap() in library code to provide context when a panic occurs. Use expect() with a descriptive message that explains why the operation should succeed.

Pick the tool that matches the failure mode. Recoverable errors get Result. Bugs get panic!.

Where to go next