Error Handling in Rust: Fundamentals

Author

Caroline Morton

Date

January 19, 2026

Rust’s approach to error handling is one of its most distinctive features, and one of the most confusing for newcomers. In this first of two posts, we’ll cover the core concepts: recoverable vs unrecoverable errors, when to panic, and how to propagate errors effectively. This series accompanies my Women in Rust talk on the topic, but stands alone as a reference.

Before we get into the mechanics, we need to understand the fundamental distinction that shapes all error handling in Rust.

What do we want from errors?

Recoverable and unrecoverable errors

Unlike many other programming languages, Rust explicitly groups errors into two categories:

  1. Recoverable
  2. Unrecoverable

Let’s first explore what we mean by these terms. In short, a recoverable error is something that we would expect the user to be able to solve and then retry. For example, a file has been moved or does not exist causes a FileNotFound error.

An unrecoverable error is, in practice, almost certainly a bug. This might be that the logic is incorrect or the assumptions about the conditions under which the program is running are incorrect. This group includes things like trying to access the 10th item in a vector that is only 4 items long.

Many other languages rely on exceptions which end up grouping both of these together. In Rust, we must explicitly choose what we want to happen. The Rust Book is an excellent introduction to this philosophy of errors in Rust and is well worth a read.

What do we want from errors

We are using errors for two major purposes:

  1. To determine what to do next - i.e should we stop the program, should we retry the network connection, should we create a missing file
  2. To report what the error is so it can be investigated later.

This framing shapes everything that follows.

Determining what to do next

When we get an error, we are running into something that we are not expecting. Some of the time these are going to be errors that we cannot control - for example, an external API hangs, or a database connection is not available - and some of the time they are going to be errors that we have a solution for. Pure bugs aside, we want to make sure that our program knows what to do when it hits an error. Let’s work though an example of an API request from a frontend for a reset password:

  1. Parse request body → Missing field → Return 400 Bad Request
  2. Validate email exists → Database unreachable → Retry once, then fail
  3. Call email service → Service down → Alert on-call via Slack

These actions might also include deciding what to tell the end user. A missing field? Tell them exactly what’s wrong—they can fix it. Database unreachable? A generic “something went wrong, please try again” is appropriate—the user can’t do anything about your infrastructure. Email service down? Perhaps “we’re experiencing delays, you’ll receive your email shortly” while your team investigates. The error type shapes both the internal response and the external message.

There are more errors that could come out of our simple API endpoint but you get the idea. We need to think about what we want the program to do next, and this includes both internal (for your team) and external actions (for the end user).

Reporting on the error

The second part of the purpose of errors is reporting. This means providing context so that we can solve the issue in the future. You can imagine that if all of the different errors above gave the same error message, that would be quite frustrating.

In my mind, there are a couple of ways that we can illuminate the context of the error. The first one is just great error messages (and we are going to be talking about this a lot in the second blog of this series). A good error message provides enough context of what the error is without overwhelming the user with a full call stack (though that’s sometimes useful in development!).

The second way is by writing good logs. We want our system to have robust tracing which provides a set of useful contextual information for the person debugging this error. We want richer internal context than what we expose to the caller. We want to create a structured log that can be viewed and searched. This is particularly important for large programs with high throughput.

Now we have talked about what we want errors to do, let’s talk about the most basic of error handling panic!.

Don’t panic

panic!

panic! is Rust’s mechanism for unrecoverable errors - situations that indicate a fundamental mistake in the code’s assumptions. We want to use panic for situations where the error cannot be or should not be recovered from.

When the panic! macro is called, the program:

  • Prints an error message
  • Unwinds the stack
  • Cleans up data
  • Exits immediately

The error message will report some basic information about where the panic came from, namely filename and line number. This can be pretty useful.

We can invoke panic! in two ways.

  1. We take an action that causes a panic from the internal workings of Rust. For example, trying to access an array past the end of its length
let my_items = vec!["apple", "coin", "keys"];
println!("{}", my_items[5]);

In this case, we get:

thread 'main' panicked at src/main.rs:3:24:
index out of bounds: the len is 3 but the index is 5
  1. Explicitly calling the panic! macro in our code.
fn get_user(user_id: i32) {
    if user_id < 0 {
        panic!("user_id cannot be negative, got {}", user_id);
    }
    // fetch user from database
}

What this all means

The first of these situations is a result of bad programming. It is an error in something that cannot be done. The second one is more interesting to me as a developer. It is getting into adding control in our program and deciding consciously what is a recoverable or unrecoverable error.

In our get_user example, a query with user_id = -5 might technically execute against the database - perhaps it would just return nothing, and our code might be happy to accept this as an Option. However in this example, we’re deciding that a negative user ID represents such a fundamental violation of our assumptions that we’d rather crash than continue with corrupted state.

Graceful error handling

So far, we have only seen how Rust handles unrecoverable errors, and we are now going to learn about doing this for recoverable error.

Let’s introduce the concept of Result. Rust has the Result enum which has two variants:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

T and E are generic type parameters. Think of them as placeholders. T stands for the type of value that you get on success, and E stands for the type of error you get on failure. We can use these to write the type signature of a function that either returns a username or an std::io::Error.

fn get_username(user_id: i32) -> Result<String, std::io::Error> {
    // Either returns Ok(String) on success
    // or Err(std::io::Error) on failure
}

We tend to use Result a lot in our function signatures because functions usually have a successful end state and at least one error state. Many functions that are in the standard library use Result. This is really helpful because we can match on this.

Let’s write our own function that takes in a user-inputted value of age, and either returns the age as an i32 or an error if the input is invalid. The specific error we will use is std::num::ParseIntError which is returned when parsing a string to an integer fails.

fn parse_user_age(input: &str) -> Result<i32, std::num::ParseIntError> {
    input.trim().parse::<i32>()
}

This function takes a string, trims whitespace, and attempts to parse it as an integer. It returns either an i32 or a ParseIntError.

If we did this:

let age_result1 = parse_user_age("32"); // Returns Ok(i32)
let age_result2 = parse_user_age("twenty-five"); // Returns  Err(ParseIntError)

So the only outcome of this function is either a successful integer or an error.

We can then match on this and could handle this in a number of ways. The important thing is that we get to choose. We could choose to call panic!, or print the error or pass back a String error message.

// Example 1
match age_result {
    Ok(age) => println!("User is {} years old", age),
    Err(e) => panic!("Invalid age provided: {}", e)
}

// Example 2
match age_result {
    Ok(age) => println!("User is {} years old", age),
    Err(e) => println!("Invalid age provided: {}", e),
}

// Example 3
let result: Result<i32, String> = match age_result {
    Ok(age) => Ok(age),
    Err(e) => Err(format!("Could not parse '{}' as integer: {}", input, e)),
};

In this third example, we’re not just handling the error, we’re transforming it. We take the Err(ParseIntError) that our function returns and convert it into an Err(String) with a more descriptive message.

More specific error handling

We are starting to get into the details of some of the control we can add.

Let’s imagine another situation in which we have a function that takes in a filename, opens that file, reads the content and parses it as an i32. This function fails in three distinct ways, each of which we want to report clearly: The file could not exist, the contents might be corrupted, or not parseable as an i32. We want to pass useful error message for all of these. We can do this with match but it quickly becomes very verbose.

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

fn read_number_from_file(filename: &str) -> Result<i32, String> {
    let mut file = match File::open(filename) {
        Ok(f) => f,
        Err(e) => return Err(format!("Could not open file '{}': {}", filename, e)),
    };

    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => {},
        Err(e) => return Err(format!("Could not read file '{}': {}", filename, e)),
    };

    match contents.trim().parse::<i32>() {
        Ok(n) => Ok(n),
        Err(e) => Err(format!("Could not parse '{}' as integer: {}", contents.trim(), e)),
    }
}

We could leave it like this. It is easy to read and makes it clear what is happening at each stage. However most people will make the code shorter and more ergonomic. For this, we can start to use the operator ?.

Question mark

The ? Operator

The ? operator is syntactic sugar for propagating errors. When you put ? after a Result, it does two things:

  • If the value is Ok, it unwraps it and gives you the inner value
  • If the value is Err, it returns early from the function with that error

Let’s rewrite our function:

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

fn read_number_from_file(filename: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let number = contents.trim().parse::<i32>()?;
    Ok(number)
}

This is much cleaner. But you’ll notice we changed the return type to Box<dyn std::error::Error>.This is because ? needs to convert different error types (io::Error, ParseIntError) into a common type. Box<dyn std::error::Error> is a trait object that can hold any error - it’s the quick and dirty solution.

The downside is that we have lost our custom messages. The caller is now going to get the raw underlying errors. We’ll see how to get the best of both worlds - concise code with rich context—in part two, when we look at crates like anyhow and thiserror.

Summary of Pros and Cons of Result.

Pros:

  • Explicit error handling.
  • Composable with ? operator
  • Type-safe.
  • Forces consideration of error cases

Cons:

  • Can lead to verbose code
  • Requires explicit handling
  • Learning curve for beginners

Expect and unwrap

Let’s talk about expect() and unwrap().

Firstly, consider unwrap(). We can use unwrap when we don’t want to match.

let file = File::open(filename).unwrap();
// panics with: called `Result::unwrap()` on an `Err` value: Os { code: 2, 
// kind: NotFound, message: "No such file or directory" }

In my opinion, we shouldn’t be using this, except in very specific situations. The main problem is that if there is an error in your code - for example, that file doesn’t exist - your code will panic when it tries to unwrap() the Err variant of the Result.

I would be very cautious about using unwrap() - in fact, I normally have clippy configured to deny it during CI. The recent cloudflare outage was caused in part by an unwrap() being called on an Err value. It’s like removing your car’s airbag because you don’t expect to crash. We can all agree that crashes do happen!

A better alternative is to use expect().

let file = File::open(filename).expect(&format!("File '{}' could not be opened", filename));
// panics with: File 'config.txt' could not be opened: Os { code: 2, 
// kind: NotFound, message: "No such file or directory" }

We can add some context to our error message but ultimately we still end up with a panic.

The only situations in which you should be using expect() in my view is:

  • During prototyping and developing
  • During some tests
  • When you want a panic but with more context (although you could just use panic!).
  • In situations where you know more than the compiler

The only situations in which you should be using expect() in my view is:

  • During prototyping and developing
  • During some tests
  • When you want a panic but with more context (although you could just use panic!).
  • In very rare situations where you genuinely know more than the compiler

This last case needs a big caveat: you almost never actually know more than the compiler. It’s easy to convince yourself that something “can’t fail” when it absolutely can. That said, there are genuine cases. Let’s imagine an example where we are parsing in a local IP address to become an Ipv4Addr type.

let localhost: Ipv4Addr = "127.0.0.1"
    .parse()
    .expect("hardcoded IP address should be valid");

We know that 127.0.0.1 is a valid address and will parse successfully. The parse() will return a Result and since we know it will always be successful, we allow an expect() here to avoid writing an unnecessary match statement.

But please be very careful with this reasoning. The moment you’re parsing user input, reading from a file, or dealing with anything that isn’t a literal in your source code, you don’t know more than the compiler - you’re just hoping. And hope is not a strategy. When in doubt, handle the error properly.

Wrapping up

We’ve covered the fundamentals: the distinction between recoverable and unrecoverable errors, when to reach for panic!, how Result gives us explicit control, and how ? makes error propagation ergonomic (and I would argue fun!). These building blocks appear everywhere in Rust code.

I hope that we have also seen the tension: verbose match statements give us rich context, while ? gives us clean code but loses our custom messages. In part two, we’ll resolve some of these issues with crates like anyhow and thiserror - which are tools that let us write concise code without sacrificing meaningful error messages.

Related Posts

graph_1 yellow

DIVINA

A medical simulation tool for training medical students in Germany

Read More
stethoscope orange

SNOMED and friends

This blog provides an introduction to SNOMED codes and how they are used in routine care in the UK. It also covers some of the quirks of SNOMED and the challenges of using it in research.

Read More
dag_2 orange

What are GANs and how can they generate synthetic data?

This blog explores Generative Adversarial Networks (GANs) and how they can be used to generate synthetic healthcare data.

Read More