How to pattern match on error types in Rust

Use match or if let to destructure Result or custom error enums and handle specific failure cases in Rust.

When errors are data, not just strings

You're building a config loader for a game server. The file might not exist. The file might contain malformed JSON. The file might have a version number your server doesn't support yet. You need to handle each case differently. If the file is missing, you create a default config. If the format is broken, you tell the user to fix their file. If the version is too new, you suggest updating the server.

In languages that treat errors as exceptions or strings, you often catch everything and hope the message tells you what went wrong. Rust treats errors as data. An error type is a value you can inspect, destructure, and route to the right handler. Pattern matching is the tool you use to open that data and react precisely. The compiler checks that you've covered every possibility. If you miss a case, the code won't compile.

Errors as enums

Rust's standard library uses Result<T, E> for operations that can fail. Result is an enum with two variants: Ok(T) and Err(E). The T holds the success value. The E holds the error.

Custom error types are also usually enums. An enum lets you define a type that can be one of several distinct variants. Each variant can carry different data. This structure matches how errors work in the real world. A network error is different from a file error. A timeout carries different information than a connection refusal.

Pattern matching lets you check which variant you have and extract the data inside. It's like opening a sealed envelope. You look at the stamp to see where it came from, then you open it to read the letter. The compiler verifies that you've accounted for every possible stamp.

Trust the exhaustiveness check. It catches bugs before they ship.

Minimal example

Here's a simple error enum and a match that handles every variant.

/// Represents errors that can occur when loading configuration.
#[derive(Debug)]
enum ConfigError {
    /// The configuration file was not found.
    MissingFile,
    /// The file content could not be parsed.
    ParseError(String),
    /// The file version is incompatible.
    VersionMismatch(u32),
}

/// Simulates loading a config and returning a version mismatch.
fn load_config() -> Result<String, ConfigError> {
    Err(ConfigError::VersionMismatch(2))
}

fn main() {
    // Match on the Result to handle success and failure.
    match load_config() {
        // Extract the success value.
        Ok(content) => println!("Loaded config: {}", content),
        // Handle missing file by creating a default.
        Err(ConfigError::MissingFile) => println!("File not found. Creating default config."),
        // Extract the error message from ParseError.
        Err(ConfigError::ParseError(msg)) => println!("Bad format: {}", msg),
        // Extract the version number and report it.
        Err(ConfigError::VersionMismatch(v)) => println!("Version {} is unsupported.", v),
    }
}

The match expression evaluates load_config(). The result is a Result<String, ConfigError>. The first arm matches Ok. If the function returns success, content binds to the inner string. The second arm matches Err. The pattern Err(ConfigError::MissingFile) drills down. It checks if the outer wrapper is Err and the inner enum is MissingFile. If so, the arm executes.

The compiler verifies exhaustiveness. You listed MissingFile, ParseError, and VersionMismatch. You covered all three. The code compiles. If you removed the VersionMismatch arm, the compiler rejects you with E0004 (non-exhaustive patterns). You have to handle every possibility.

Write the match arms that make sense. The compiler will force you to finish the job.

Destructuring complex errors

Real errors often carry structured data. You can destructure that data directly in the pattern. This lets you access fields without extra variable bindings.

/// Detailed HTTP error with status and body.
struct HttpError {
    status: u16,
    body: String,
}

/// API errors can be network issues or HTTP responses.
enum ApiError {
    Network(String),
    Http(HttpError),
}

fn handle_error(error: ApiError) {
    match error {
        // Destructure the struct inside the enum.
        // Match specifically on status 404.
        ApiError::Http(HttpError { status: 404, .. }) => {
            println!("Resource not found.");
        }
        // Match 500 and extract the body for logging.
        ApiError::Http(HttpError { status: 500, body }) => {
            println!("Server error: {}", body);
        }
        // Catch all other HTTP errors.
        ApiError::Http(_) => {
            println!("Other HTTP error occurred.");
        }
        // Handle network errors.
        ApiError::Network(msg) => {
            println!("Network failure: {}", msg);
        }
    }
}

The pattern HttpError { status: 404, .. } matches a struct with status equal to 404. The .. ignores all other fields. This is a convention for future-proofing. If HttpError gains a new field later, your match doesn't break. You don't need to update every match arm when the struct evolves.

Use .. to ignore fields you don't need. It keeps your code stable when types change.

Guards for conditional logic

Sometimes the variant isn't enough. You need to check the data inside. Guards let you add a boolean condition to a match arm.

fn handle_version_error(error: ConfigError) {
    match error {
        // Guard clause filters versions greater than 5.
        ConfigError::VersionMismatch(v) if v > 5 => {
            println!("Future version {}. Please update the server.", v);
        }
        // This arm catches older versions.
        ConfigError::VersionMismatch(v) => {
            println!("Old version {}. Migration required.", v);
        }
        ConfigError::MissingFile => println!("No config found."),
        ConfigError::ParseError(msg) => println!("Parse error: {}", msg),
    }
}

The guard if v > 5 runs only if the variant matches. If the guard is false, the match continues to the next arm. This lets you split a single variant into multiple behaviors based on the data.

Keep guards simple. Complex logic in a guard makes the match hard to read. Extract the logic into a helper function if the condition grows.

The if let shortcut

You don't always need a full match. If you care about exactly one variant, if let is cleaner.

fn check_timeout(error: ApiError) {
    // Match only the Network variant with a specific message pattern.
    // Other variants are ignored.
    if let ApiError::Network(msg) = error {
        println!("Network issue: {}", msg);
    }
}

if let desugars to a match with a wildcard for the other cases. It's equivalent to:

match error {
    ApiError::Network(msg) => println!("Network issue: {}", msg),
    _ => {}
}

Convention prefers if let when you handle one case and ignore the rest. Use match when you need to handle multiple cases or when exhaustiveness is required. Using match with a single arm and _ => {} works, but if let signals intent more clearly.

Pick the tool that matches your intent. Propagate when you can't handle it. Match when you can.

The ? operator is a match in disguise

The ? operator propagates errors up the call stack. It's syntactic sugar for a pattern match.

/// Returns a Result.
fn risky_operation() -> Result<String, ConfigError> {
    Err(ConfigError::MissingFile)
}

fn process_config() -> Result<(), ConfigError> {
    // The ? operator matches Ok to extract the value.
    // It matches Err to return early.
    let content = risky_operation()?;
    println!("Processing: {}", content);
    Ok(())
}

The ? expands roughly to:

let content = match risky_operation() {
    Ok(val) => val,
    Err(e) => return Err(e.into()),
};

It matches Ok to get the value. It matches Err to return the error from the current function. The .into() call converts the error type if needed. This is why ? works seamlessly with Result and Option.

Use ? when you want to propagate the error without inspecting it. It keeps error handling concise and pushes responsibility to the caller.

Pitfalls and compiler errors

Pattern matching is powerful, but a few traps exist.

Non-exhaustive matches. If you forget a variant, the compiler rejects the code with E0004. This is a feature. It forces you to think about every error path. Don't suppress this with wildcards unless you truly don't care. If you add a new error variant later, the compiler will remind you to update every match site.

Mismatched types. If you try to match a pattern against a value of the wrong type, you get E0308. This happens when you match on Result but write a pattern for Option, or vice versa. Check the type of the value you're matching.

Variable shadowing. Patterns can bind variables. If you write Err(e) => e, you bind e. If you write Err(MyError::Foo(x)) => x, you bind x. Be careful not to shadow outer variables unintentionally. The compiler warns about unused variables, which helps catch accidental shadows.

Overusing unwrap. unwrap() panics on Err. It's a match that crashes. Use it only in tests or throwaway code where a failure is a bug, not a recoverable condition. In production code, handle the error or use expect() with a message. Convention requires expect() messages to explain why the error is unexpected.

E0004 is a feature, not a bug. It saves you from runtime crashes.

Decision matrix

Choose the right tool based on your goal.

Use match when you need to handle multiple error variants with distinct logic.

Use if let when you care about exactly one variant and want to ignore the rest.

Use the ? operator when you want to propagate the error to the caller without inspecting it.

Use match with a guard clause when the action depends on the data inside the variant, not just the variant name.

Use unwrap() or expect() only in tests or throwaway code where a failure indicates a programming error, not a runtime condition.

Use .. in struct patterns when you want to ignore fields and keep the match stable across type changes.

Keep error handling close to the source. Don't let errors bubble up until you can do something useful with them.

Where to go next