How to Use the match Expression in Rust

The match expression in Rust compares a value against patterns to execute specific code blocks for each case.

You get a response from the server

You're building a command-line tool that fetches data from an API. The server returns a status code. You need to handle 200, 404, 500, and any other code that might appear. In Python or JavaScript, you write a chain of if-else statements or a switch block. You might forget a case. You might return the wrong type from one branch. Rust gives you match. It looks like a switch statement, but it's an expression that returns a value, enforces that you handle every possible case, and can tear apart complex data structures to extract exactly what you need.

The match expression

Think of match as a high-security mail sorter. You feed it a piece of mail (the value). The sorter has a series of slots, each with a specific label (the pattern). The mail slides down and drops into the first slot whose label matches the mail. If the mail doesn't match any specific slot, it must fall into a catch-all bin. Rust forces you to provide that bin, or prove the mail can never miss.

The match keyword takes a value and compares it against a list of patterns. Each pattern is paired with a code block called an arm. The compiler evaluates the value once and checks it against each pattern in order. When a pattern matches, the corresponding arm runs. The result of the arm becomes the result of the entire match expression.

Minimal example

/// Demonstrates basic pattern matching with literals and wildcards.
fn main() {
    let number = 13;

    // match is an expression. It evaluates to a value.
    let result = match number {
        // Match exact literals first.
        1 => "One",
        // Use the pipe operator to group multiple values.
        2 | 3 | 5 | 7 | 11 => "Prime",
        // The underscore is the catch-all pattern.
        // Rust requires this because not all integers are covered above.
        _ => "Other",
    };

    println!("{result}");
}

How it works

Every arm must return the same type. If one arm returns a String and another returns an i32, the compiler rejects you with E0308 (mismatched types). You can use blocks to coerce types or compute values, but the final type of every arm must unify.

Rust checks exhaustiveness. If your patterns don't cover every possible value, the code won't compile. Drop the wildcard and you get E0004 (non-exhaustive patterns). This check is not a warning. It is a hard error. The compiler refuses to generate code that could panic at runtime due to an unhandled case.

Order matters. Patterns are checked from top to bottom. The first match wins. If you put the wildcard _ at the top, every value matches it immediately. The arms below become dead code. The compiler warns about unreachable patterns, but it won't stop you from writing them.

Counter-intuitive but true: the underscore pattern matches everything, including values you haven't thought of yet. Put it first and your other arms become dead code.

Destructuring data

match shines when you need to extract data from complex types. You can match on enums, structs, tuples, and nested combinations. The pattern describes the shape of the data. If the shape matches, the compiler binds the inner values to variables you can use in the arm.

/// Represents different types of user input in a game.
#[derive(Debug)]
enum Input {
    Move { x: i32, y: i32 },
    Attack { weapon: String },
    Quit,
}

/// Processes an input and returns a status message.
fn process_input(input: Input) -> String {
    match input {
        // Destructure the Move variant and extract fields.
        // x and y are now available in this arm.
        Input::Move { x, y } => format!("Moving to ({x}, {y})"),
        // Bind the weapon field to a variable for use in the arm.
        Input::Attack { weapon } => format!("Attacking with {weapon}"),
        // Match the unit variant. No data to extract.
        Input::Quit => "Quitting...".to_string(),
    }
}

You can also match on tuples and structs directly. The pattern mirrors the syntax of the value. For structs, you can use the .. syntax to ignore fields you don't need.

/// Shows matching on structs with ignored fields.
fn get_user_name(user: User) -> String {
    match user {
        // Match the name field. Ignore everything else with ..
        User { name, .. } => name,
    }
}

struct User {
    name: String,
    email: String,
    age: u32,
}

Convention aside: The community prefers if let over match when you only care about one variant. It reduces noise. Use match when you have multiple arms or need a wildcard.

Ranges, guards, and bindings

Patterns support ranges, conditional guards, and the @ binding operator. These tools let you express complex logic without nesting if statements inside arms.

/// Classifies a score using ranges, guards, and the @ operator.
fn classify_score(score: u32) -> String {
    match score {
        // Range pattern. Inclusive on both ends.
        0..=50 => "Fail".to_string(),
        // Guard clause adds a condition to the pattern.
        // The arm only runs if the guard evaluates to true.
        51..=80 if score > 60 => "Pass".to_string(),
        // The @ operator binds the value to a name while matching.
        // high is available in the arm.
        81..=100 @ high => format!("Excellent: {high}"),
        // Catch-all for invalid scores.
        _ => "Invalid score".to_string(),
    }
}

Ranges work on integer types and characters. The ..= syntax is inclusive. Guards let you add arbitrary boolean expressions. The @ operator is useful when you need to match a pattern but also keep the whole value for later use.

The exhaustiveness guarantee

The real power of match isn't the syntax. It's the exhaustiveness check. When you match on Option<T>, the compiler forces you to handle None. When you match on Result<T, E>, it forces you to handle Err. You cannot accidentally ignore an error. This is why Rust code is robust. The compiler acts as a rigorous reviewer that never sleeps.

/// Demonstrates exhaustiveness with Option.
fn print_value(opt: Option<i32>) {
    match opt {
        // Handle the Some case.
        Some(val) => println!("Value: {val}"),
        // Handle the None case.
        // If you remove this arm, the compiler rejects the code.
        None => println!("No value"),
    }
}

This guarantee scales. If you add a new variant to an enum, the compiler highlights every match expression that forgot to handle it. You can't break callers silently. This is a major advantage over switch statements in other languages, where adding a case often leaves old code vulnerable.

Trust the exhaustiveness check. It catches bugs you haven't written yet.

Pitfalls

Order matters. Put specific patterns first and the wildcard last. If you reverse the order, the wildcard swallows everything. The compiler warns about unreachable patterns, but it won't stop you from writing them.

Arms must return the same type. You can use blocks to coerce types, but the final type must match. If one arm returns &str and another returns String, the compiler rejects you with E0308. Use .to_string() or & to unify types.

Variables in patterns create new bindings. They shadow variables in the outer scope. If you match on a variable named x and bind x in the pattern, the inner x hides the outer one. This is usually what you want, but it can be confusing if you expect to modify the outer value.

Shadowing in patterns is a feature, not a bug. It lets you extract values cleanly. Just remember that the binding is local to the arm.

When to use match

Use match when you need to handle multiple distinct cases and extract data from each. Use match when the compiler complains about non-exhaustive patterns and you need to prove you covered every variant. Use if let when you only care about one specific pattern and want to ignore the rest without a wildcard arm. Use if when you are checking a boolean condition or a simple range that doesn't benefit from destructuring.

Don't use match for simple boolean checks. if condition is clearer. Don't use match when if let suffices. Extra arms add noise. Keep your code focused.

Treat the underscore arm as a safety net, not a shortcut. If you find yourself using _ to ignore cases you should handle, reconsider your design. The compiler is telling you something.

Where to go next