How does pattern matching work

Pattern matching in Rust uses the match operator to compare values against patterns and execute code based on the first successful match.

How pattern matching works

You're building a game. The player picks up an item. The item could be a sword, a potion, or a key. Each item changes the game state differently. In JavaScript, you might write a long chain of if (item.type === 'sword') checks. In Rust, you reach for match. Pattern matching isn't just a fancy switch statement. It's a way to destructure data and prove to the compiler that you've handled every possible shape the data can take.

Think of pattern matching like a sorting robot in a warehouse. Packages arrive on a conveyor belt. The robot has a set of trays. Each tray has a specific shape cut out. If a package fits the square hole, it drops into the square bin. If it fits the round hole, it goes to the round bin. If it doesn't fit any specific hole, it falls into the catch-all bin at the bottom. The robot doesn't just check the label; it checks the shape. In Rust, the "shape" is the structure of your data. The compiler acts as the quality inspector. It refuses to let the robot start unless every possible package shape has a bin. There are no leaks.

The basics

A match expression takes a value and compares it against a list of patterns. The first pattern that fits triggers the associated code. The code runs, and the result becomes the value of the entire match.

fn main() {
    let status_code = 200;

    // Match arms are expressions.
    // The value of the matching arm becomes the value of the match.
    let message = match status_code {
        200 => "OK",
        404 => "Not Found",
        // The underscore is a wildcard pattern.
        // It matches anything that hasn't matched yet.
        _ => "Unknown",
    };

    println!("{}", message);
}

The compiler checks two things immediately. First, it verifies the order. Patterns are evaluated from top to bottom. The first match wins. Second, it checks exhaustiveness. Every possible value of the input type must be covered. If you leave a case out, the code won't compile. This prevents runtime panics from unhandled cases. Trust the compiler here. It catches the missing case before the user does.

Realistic usage with enums

Pattern matching shines with enums. Enums let you define a type that can be one of several variants. match lets you handle each variant and extract data from it.

#[derive(Debug)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn process_message(msg: Message) {
    // Match destructures the enum variant.
    // You can extract fields directly into variables.
    match msg {
        Message::Quit => {
            println!("Quitting the application.");
        }
        // Bind the x and y fields to local variables.
        // This avoids manual field access like msg.x.
        Message::Move { x, y } => {
            println!("Moving to ({}, {})", x, y);
        }
        // Ignore the string content with an underscore.
        // The underscore discards the value.
        Message::Write(_) => {
            println!("Writing a message.");
        }
        // Destructure a tuple variant.
        // r, g, and b bind to the tuple elements.
        Message::ChangeColor(r, g, b) => {
            println!("Color changed to RGB({}, {}, {})", r, g, b);
        }
    }
}

Destructuring turns verbose field access into clean variable bindings. You get the data you need without writing .field over and over. The compiler ensures all variants are handled. If you add a new variant to Message later, every match on Message breaks until you handle the new case. This makes refactoring safe.

Structs and tuples

You can match on structs and tuples too. The pattern describes the shape you expect.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 10, y: 20 };

    match p {
        // Match a specific value for x.
        // y is bound to a variable.
        Point { x: 0, y } => println!("On the y-axis at {}", y),
        // Match a specific value for y.
        // x is bound to a variable.
        Point { x, y: 0 } => println!("On the x-axis at {}", x),
        // Match anything else.
        // Bind both fields.
        Point { x, y } => println!("Point at ({}, {})", x, y),
    }
}

Convention aside: when you bind a field to a variable with the same name, you can use shorthand. Point { x, y } is equivalent to Point { x: x, y: y }. The community prefers the shorthand for readability. Use the explicit form only when you need to rename the binding.

Guards and nested patterns

Sometimes a pattern matches the structure, but you need an additional check. Guards let you add a condition to a match arm.

fn main() {
    let number = 7;

    match number {
        // The guard runs only if the pattern matches.
        // n is bound in the pattern, so the guard can use it.
        n if n > 0 => println!("Positive"),
        n if n < 0 => println!("Negative"),
        _ => println!("Zero"),
    }
}

Guards are expressions that return a boolean. They run after the pattern matches. If the guard returns false, the match continues to the next arm. You can also match nested structures. Patterns can contain other patterns.

fn main() {
    let pair = (Some(1), Some(2));

    match pair {
        // Match a tuple where both elements are Some.
        // Extract the inner values.
        (Some(a), Some(b)) => println!("Sum: {}", a + b),
        // Match anything else.
        _ => println!("Missing value"),
    }
}

Nested patterns let you drill down into complex data without writing multiple levels of if statements. Keep guards simple. Complex logic in a guard makes the match hard to read. Extract complex checks into helper functions.

Move semantics in match

Pattern matching interacts with ownership. When you match, you move values. If you bind a value in a pattern, that binding takes ownership.

fn main() {
    let s = String::from("hello");

    match s {
        // val owns the String.
        // The String is moved from s to val.
        val => println!("{}", val),
    }

    // Error: s has been moved.
    // println!("{}", s);
}

If you try to use s after the match, the compiler rejects you with E0382 (use of moved value). The value moved into the match arm. If you need to use the value after the match, borrow it instead.

fn main() {
    let s = String::from("hello");

    // Match on a reference.
    // val binds to a reference, not the owned value.
    match &s {
        val => println!("{}", val),
    }

    // s is still valid.
    println!("{}", s);
}

Convention aside: matching on references is common when you want to inspect data without taking ownership. The compiler will often suggest this with a helpful error message. Follow the suggestion. It keeps your ownership logic clean.

Pitfalls and compiler errors

Pattern matching has a few traps. The compiler catches most of them, but knowing what to expect saves time.

Exhaustiveness errors are the most common. If you miss a case, you get E0004 (non-exhaustive patterns). The compiler lists the missing cases. Fix this by adding the missing arms or using a wildcard.

Order matters. If you put a wildcard _ first, nothing else matches. The compiler warns about unreachable patterns. Put specific patterns before general ones. Order is law in match arms.

Type mismatches happen when arms return different types. All arms must return the same type. If one arm returns String and another returns &str, you get E0308 (mismatched types). Convert the types to match, or return a common type.

Shadowing can be confusing. If you bind a variable in a pattern, it shadows any outer variable with the same name. The inner variable is active only in that arm. This is usually what you want, but be aware of the scope.

When to use what

Use match when you need to handle multiple distinct cases and extract data from each. Use match when you want the compiler to enforce that every possible case is covered. Use match when you are destructuring enums, structs, or tuples to access inner fields. Use if let when you only care about one specific pattern and want to ignore the rest. Use while let when you want to loop as long as a pattern matches, like draining an iterator. Use guards when a pattern matches structurally but you need an additional runtime check. Use destructuring assignments with let when you just want to break apart a tuple or struct without branching logic.

Pick the tool that matches your intent. match for exhaustive handling, if let for single-case focus. The compiler will guide you if you pick wrong.

Where to go next