Catching Errors with match

Learn how to use the match expression to handle Result values in Rust, including nested matching for different error kinds, match guards, and how the compiler helps you write exhaustive error handling

When a function returns a Result, it hands you a value that might be a success or a failure. You cannot use the success value directly because it is wrapped inside the Ok variant, and the compiler will not let you ignore the possibility of an Err. The most explicit and fundamental way to unpack that value and decide what to do in each case is the match expression.

The match expression forces you to write a branch for every possible variant of the Result enum — Ok and Err. This is not a suggestion; the compiler checks that all variants are covered. That guarantee is what makes Rust error handling reliable: there is no way to forget that an operation might fail.

The Shape of a Basic Result Match

A match that handles a Result looks like any other match on an enum. You write an arm for Ok(value) and an arm for Err(error), then decide what each arm should do. The arms must produce values of the same type if the match is used as an expression.

src/main.rs
use std::fs::File;
fn main() {
    let file_result = File::open("config.toml");
    let file = match file_result {
        Ok(file_handle) => file_handle,
        Err(error) => {
            panic!("Could not open config file: {:?}", error);
        }
    };
    // `file` is a std::fs::File handle that can be used for reading
    println!("Config file opened successfully");
}

The Ok arm destructures the success value and names it file_handle. Because the arm returns that value without a trailing semicolon, the whole match expression evaluates to the file handle when File::open succeeds. The Err arm destructures the error value and calls panic!. The panic! macro never returns, so it satisfies the type requirement from the match — it coerces to whatever type the Ok arm produces.

When the file does not exist, you will see output similar to:

thread 'main' panicked at src/main.rs:8:13:
Could not open config file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

This example is functional, but it treats every error the same way: panic. Real programs need to distinguish between different reasons for failure. The std::io::Error type provides a method kind() that returns an io::ErrorKind enum, and you can match on that information inside the Err arm.

Matching on Different Error Kinds

When an operation can fail for multiple reasons, matching solely on Ok and Err is not granular enough. You need to inspect the error and react differently depending on what went wrong. The standard library makes this possible through io::Error::kind(), which returns a variant of io::ErrorKind. Common variants include NotFound, PermissionDenied, AlreadyExists, and TimedOut.

The following example opens a file, creates it if it does not exist, and panics on any other I/O error.

src/main.rs
use std::fs::File;
use std::io::ErrorKind;
fn main() {
    let file_result = File::open("data.txt");
    let file = match file_result {
        Ok(file_handle) => file_handle,
        Err(ref error) if error.kind() == ErrorKind::NotFound => {
            match File::create("data.txt") {
                Ok(created_handle) => created_handle,
                Err(e) => panic!("Could not create data.txt: {:?}", e),
            }
        }
        Err(error) => panic!("Problem opening data.txt: {:?}", error),
    };
    println!("data.txt is ready for reading or writing");
}

Two details here deserve attention. The ref keyword in Err(ref error) takes a reference to the error instead of moving it. Without ref, the error would be moved into the match guard, and you could not use it again in the fallback Err(error) arm. The guard if error.kind() == ErrorKind::NotFound filters the arm so that it only matches when the error kind is NotFound. If the guard evaluates to false, the pattern is skipped and the next arm is tried.

The inner match on File::create is necessary because file creation can also fail — the filesystem might be read-only, the disk might be full, or a permissions problem might exist. Using another match ensures those failure cases are handled explicitly rather than silently ignored.

Don't swallow errors:

It is tempting to write a catch-all arm like Err(_) => {} to ignore errors. This hides failure conditions from both the compiler and anyone reading the code, and it often leads to subtle bugs where the program continues running in an invalid state. Always decide explicitly what to do when an Err occurs — even if the decision is to log the error and return.

How Match Guards and References Work Together

The combination of ref and match guards is a pattern you will encounter whenever you need to inspect the contents of an error without consuming it. If you wrote Err(error) if error.kind() == ... without ref, the compiler would reject the code because error would be moved into the guard expression, making it unavailable for the next arm.

A simpler alternative for this specific case is to match on the error kind directly using a nested match, which avoids guards and ref altogether:

let file = match File::open("data.txt") {
    Ok(file_handle) => file_handle,
    Err(error) => match error.kind() {
        ErrorKind::NotFound => match File::create("data.txt") {
            Ok(created) => created,
            Err(e) => panic!("Could not create data.txt: {:?}", e),
        },
        _ => panic!("Problem opening data.txt: {:?}", error),
    },
};

Which style to choose is a matter of clarity. The version with a match guard keeps the outer match flat but requires understanding ref. The nested version is more verbose but uses only basic patterns. Both are correct, and both are checked for exhaustiveness.

Exhaustiveness and the Compiler's Safety Net

Every match on a Result must cover both Ok and Err. If you omit one arm, the compiler stops with an error:

error[E0004]: non-exhaustive patterns: `Err(_)` not covered

This is not a nuisance; it is the mechanism that prevents unhandled errors at compile time. In languages with exceptions, forgetting to catch a specific exception type causes a runtime crash. In Rust, the type system catches the omission before the program ever runs.

The same rule applies to nested matches on ErrorKind if you list specific variants. You can use a wildcard _ arm to handle all remaining error kinds, but you must still decide what that arm does.

The wildcard arm silences future errors:

A catch-all pattern like _ => {} on an enum like ErrorKind will compile today, but if the standard library adds new error variants in a future Rust edition, your code will silently ignore them rather than alerting you that something new needs handling. When you know you only care about a few specific variants, consider matching those explicitly and using a _ arm that at least logs the unknown variant.

The #[must_use] Attribute and Ignored Results

Result is annotated with #[must_use], meaning the compiler emits a warning when you drop a Result value without inspecting it. This catches the most trivial category of error handling mistakes — ignoring the return value entirely.

use std::fs::File;
use std::io::Write;
fn main() {
    let mut file = File::create("output.txt").unwrap();
    file.write_all(b"hello"); // warning: unused `Result` that must be used
}

The warning message reminds you that write_all returns a Result and that ignoring it means you will never know if the write actually succeeded. The fix is to handle the result with a match, an if let, or a method like unwrap or expect.

The compiler is your reviewer:

When you write a match that covers every variant, you are essentially telling the compiler "I have thought about both possibilities." The compiler then confirms that you did not miss anything. That feedback loop makes error handling in Rust feel less like a burden and more like a dialogue with the toolchain.

Using Match to Propagate Errors

Sometimes the right response to an error is not to handle it locally, but to send it back to the caller. match can do this with an early return.

use std::fs::File;
use std::io;
fn open_config() -> Result<File, io::Error> {
    let f = match File::open("config.toml") {
        Ok(file) => file,
        Err(e) => return Err(e), // propagate the error
    };
    Ok(f)
}

This function returns the file handle on success and the same I/O error on failure. It is a common pattern, and it is so common that later sections introduce the ? operator as a shorthand for exactly this: let f = File::open("config.toml")?;. Under the hood, the ? operator expands to something very similar to the match above.

Understanding the match version is essential even after you learn ?, because there are situations where you want to do extra work before returning the error — adding context, logging, or transforming the error type. Those customizations are easiest to express with a match.

Common Mistakes When Matching on Result

A few pitfalls catch beginners consistently, and recognizing them will save you from puzzling compiler errors.

Forgetting ref with match guards. If you write Err(error) if error.kind() == ... without ref, the compiler will complain that error has been moved and cannot be used later. The fix is to borrow with ref.

Assuming the error type is always std::io::Error. The error type E in Result<T, E> varies per function. File::open uses io::Error. str::parse uses ParseIntError. Matching on io::ErrorKind when the error type is something else will fail at compile time because the methods do not exist. Read the function's documentation or let the compiler tell you the concrete type.

Returning different types from arms. If one arm returns a value and the other does not, or if both return values but their types differ, the match expression will not compile unless the types unify. When one arm panics, it coerces, but when both arms produce real values they must be of the same type.

Trying to use the success value outside the match without handling the error case. You must either assign the success value through the match (as shown in all examples) or handle the error so that the function can continue. There is no "nullable" file handle that you can use without first proving it exists.

When to Use Match Versus Other Tools

match is the most explicit way to handle a Result. It works in every situation, but it can become verbose when you are chaining several fallible operations. That verbosity is the reason unwrap, expect, and ? exist — they condense common patterns. However, they all build on the same ideas that match makes visible.

  • Use match when you need to react to specific error kinds or perform multi-step recovery.
  • Use unwrap and expect for quick prototyping or when you know an error cannot happen and a panic is acceptable.
  • Use ? to propagate errors upward without extra ceremony (covered in a later section).

Every Rust programmer should feel comfortable writing a match on a Result because it is the building block underneath every convenience method. If you ever wonder what a particular helper method actually does, looking at its source or imagining the equivalent match will clarify the behavior instantly.

match is a gateway:

Once you internalize how match destructures Result, the functional combinators like map, and_then, and or_else become easier to learn because they are essentially match arms lifted into method form. The same exhaustive thinking carries over.

Summary

The match expression turns the Result type from an abstract enum into a practical decision point in your code. It forces you to write a response for both success and failure, and the compiler guarantees that you do not forget either one. When errors can have multiple causes, match allows you to branch on the exact error kind using guards or nested matches, giving you fine-grained control over recovery logic.

What makes match powerful is not just the syntax — it is the discipline of always confronting failure explicitly. That discipline is the foundation upon which the rest of Rust's error handling features are built.