Pattern Matching Fundamentals

Pattern matching is how Rust programs inspect a value’s shape and pull it apart in one step. Instead of writing a chain of if checks and manual field access, you describe the shapes you expect — a specific number, a range, an enum variant with data inside it — and Rust runs the code for whichever shape actually matched. The core tool is the match expression, and because the compiler checks that every possible shape is handled, entire categories of bugs (the forgotten else branch, the unchecked enum variant) become compile errors instead of production incidents.

Overview: How Pattern Matching Works

Think of a value arriving at a match expression the way a piece of mail arrives at a sorting office. Each arm of the match is a labeled slot: “letters addressed to apartment 3”, “anything under 10 grams”, “packages from this specific sender”. The sorter checks the item against each slot’s label, top to bottom, and drops it into the first slot whose label fits. A match expression does exactly this at compile time: it compares a value against a list of patterns, in order, and executes the first arm whose pattern matches.

What makes Rust’s version different from a C-style switch is that the compiler builds a proof that every possible value has a slot. If you match on an enum with four variants and only write arms for three, the code does not compile — the compiler reports exactly which variant you missed. This property is called exhaustiveness checking, and it is the reason Rust programmers can add a new variant to an enum and let the compiler point at every single place in the codebase that now needs updating, rather than discovering the gap at runtime.

Patterns are not limited to single values. A pattern can destructure a tuple into its parts, pull named fields out of a struct, dig into one variant of an enum while ignoring the others, match against an inclusive range of numbers, or combine several patterns with a boolean guard. The same pattern syntax also appears outside of match: in let bindings, in function parameters, and in the shorthand forms if let and while let for when you only care about one shape and want to ignore the rest.

One more idea is central to using match correctly: ownership. When you match on a value directly (for example match some_string { ... }), the arms that bind sub-values take ownership of them, and the original variable can no longer be used afterward. When you match on a reference (match &some_string { ... }), the arms only borrow, and the original stays usable. Keeping this distinction in mind up front will save you from a very common class of compiler error, covered later in Common Mistakes.

Syntax

The general shape of a match expression looks like this:

match VALUE {
    PATTERN1 => EXPRESSION1,
    PATTERN2 => EXPRESSION2,
    PATTERN3 if GUARD_CONDITION => EXPRESSION3,
    _ => DEFAULT_EXPRESSION,
}
  • VALUE — the expression being matched. It can be a variable, a reference, or any expression that produces a value.
  • PATTERN — a shape to compare the value against. Patterns are tried from top to bottom, and the first one that matches wins.
  • => EXPRESSION — the code to run if that pattern matches. Every arm’s expression must produce the same type, since the whole match is itself an expression with one resulting type.
  • if GUARD_CONDITION — an optional boolean check added after a pattern; the arm only matches if both the pattern matches and the guard is true.
  • _ — the wildcard pattern, which matches anything and binds nothing. It is commonly used as a catch-all final arm.

Common pattern forms you will see throughout Rust code:

Pattern kind Example Meaning
Literal 5 => ... Matches only the exact value 5
Variable binding n => ... Matches anything and binds it to the name n
Wildcard _ => ... Matches anything, value is discarded
Range 1..=9 => ... Matches any value from 1 to 9 inclusive
Or-pattern 1 | 2 | 3 => ... Matches any one of several patterns
Guard n if n > 0 => ... Adds an extra boolean condition
@ binding n @ 1..=9 => ... Binds the value while also testing a sub-pattern
Destructure Point { x, y } => ... Pulls named fields out of a struct or enum variant

Examples

Example 1: Matching a simple value

fn main() {
    let number = 3;

    match number {
        1 => println!("One"),
        2 => println!("Two"),
        3 => println!("Three"),
        _ => println!("Something else"),
    }
}

Output:

Three

The compiler compares number against each literal pattern in order. It matches 3 and runs that arm. The final _ arm is required — without it, the compiler would reject the match as non-exhaustive, since an i32 can hold values other than 1, 2, and 3.

Example 2: Destructuring an enum with data

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn process(msg: Message) {
    match msg {
        Message::Quit => println!("Quit received"),
        Message::Move { x, y } => println!("Move to ({}, {})", x, y),
        Message::Write(text) => println!("Write: {}", text),
        Message::ChangeColor(r, g, b) => println!("Change color to ({}, {}, {})", r, g, b),
    }
}

fn main() {
    let messages = vec![
        Message::Move { x: 10, y: 20 },
        Message::Write(String::from("hello")),
        Message::ChangeColor(255, 0, 0),
        Message::Quit,
    ];

    for msg in messages {
        process(msg);
    }
}

Output:

Move to (10, 20)
Write: hello
Change color to (255, 0, 0)
Quit received

This is where pattern matching earns its keep. Each variant of Message carries different data — no data, two named fields, one String, or three integers — and a single match both identifies which variant it is and unpacks the data in the same step, binding x, y, text, r, g, and b directly from the pattern. Note that process takes msg by value, so matching moves it; each Message is consumed exactly once.

Example 3: Ranges, guards, and @ bindings

fn main() {
    let numbers = [3, 7, 12, -4, 0, 55];

    for n in numbers {
        match n {
            0 => println!("{n}: zero"),
            n if n < 0 => println!("{n}: negative"),
            small @ 1..=9 => println!("{small}: small positive (single digit)"),
            n => println!("{n}: large positive"),
        }
    }
}

Output:

3: small positive (single digit)
7: small positive (single digit)
12: large positive
-4: negative
0: zero
55: large positive

Each arm layers on a different pattern feature. The literal 0 catches exactly zero. The guard n if n < 0 matches any value, but only takes the arm if the extra boolean condition holds. The @ binding small @ 1..=9 both tests that the value falls in the range and gives it the name small for use inside the arm. The final bare n is an irrefutable catch-all that also renames the value, satisfying exhaustiveness for every remaining case (values 10 and above).

How It Works Step by Step

When the compiler sees a match, it does two separate jobs. First, at compile time, it performs exhaustiveness analysis: it looks at the type being matched (how many enum variants it has, what a range pattern’s bounds cover, whether a final wildcard or irrefutable binding exists) and proves that every possible value of that type lands in at least one arm. If it cannot prove this, compilation fails with a “non-exhaustive patterns” error naming the missing case. This check is what makes match safer than an if/else chain — there is no way to silently fall through.

Second, the compiler lowers the arms into an efficient decision structure (conceptually similar to a series of tests and jumps) rather than literally testing every pattern one by one at runtime for simple cases like matching an integer or an enum tag — but the semantics you should reason about are exactly “patterns are tried top to bottom, first match wins.” This is why order matters: a broad pattern placed above a narrow one will shadow it. In Example 3, if the wildcard-like final n arm were placed first, it would match everything and the more specific arms below it would never run — the compiler even warns about arms that can never be reached.

Ownership is resolved as part of matching too. When you match a value directly, each arm’s bindings take ownership of the parts they bind (unless the type is Copy, in which case it’s duplicated instead of moved). When you match a reference, bindings become references into the original data, and the original value remains valid and usable after the match completes.

Common Mistakes

Mistake 1: Forgetting a variant (non-exhaustive match)

Adding a new enum variant later and forgetting to update every match on it is one of the most common Rust compile errors — and one of the most useful, since it means the compiler finds the gap for you.

enum Direction {
    North,
    South,
    East,
    West,
}

fn describe(dir: Direction) -> &'static str {
    match dir {
        Direction::North => "north",
        Direction::South => "south",
        Direction::East => "east",
    }
}

fn main() {}

This fails to compile with error[E0004]: non-exhaustive patterns: `Direction::West` not covered. The fix is to add the missing arm (or, only if it’s genuinely intentional, a _ catch-all):

enum Direction {
    North,
    South,
    East,
    West,
}

fn describe(dir: Direction) -> &'static str {
    match dir {
        Direction::North => "north",
        Direction::South => "south",
        Direction::East => "east",
        Direction::West => "west",
    }
}

fn main() {
    let dir = Direction::West;
    println!("{}", describe(dir));
}

Output:

west

Mistake 2: Using a value after matching moved it

fn main() {
    let name: Option<String> = Some(String::from("Ferris"));

    match name {
        Some(n) => println!("Hello, {}", n),
        None => println!("No name"),
    }

    println!("{:?}", name);
}

This fails with error[E0382]: borrow of moved value: `name`. Matching name directly binds Some(n) by value, which moves the String out of the Option and consumes name in the process — there is nothing left to print afterward. The fix is to match on a reference so the arms only borrow:

fn main() {
    let name: Option<String> = Some(String::from("Ferris"));

    match &name {
        Some(n) => println!("Hello, {}", n),
        None => println!("No name"),
    }

    println!("{:?}", name);
}

Output:

Hello, Ferris
Some("Ferris")

Matching &name makes n a &String reference instead of an owned String, so name itself is only borrowed for the duration of the match and remains usable afterward.

Mistake 3: Using a refutable pattern in a plain let

fn main() {
    let maybe_value: Option<i32> = Some(5);
    let Some(x) = maybe_value;
    println!("{}", x);
}

This fails with error[E0005]: refutable pattern in local binding: `None` not covered. A plain let requires an irrefutable pattern — one that is guaranteed to match, like a bare variable name or a tuple of variables. Some(x) is refutable because the value could have been None instead. Use if let (or a full match) whenever the pattern might not match:

fn main() {
    let maybe_value: Option<i32> = Some(5);

    if let Some(x) = maybe_value {
        println!("{}", x);
    } else {
        println!("No value");
    }
}

Output:

5

Best Practices

  • Prefer match when you need to handle every case of a type; prefer if let when you only care about one pattern and want to ignore the rest.
  • Let the compiler’s exhaustiveness checking work for you — avoid a blanket _ arm on enums you control, so that adding a new variant later forces you to revisit every relevant match.
  • Match on a reference (match &value) whenever you only need to read the data, so the original value stays usable afterward.
  • Keep guard conditions short and readable; if a guard grows complex, extract it into a well-named helper function.
  • Use @ bindings when an arm needs both the matched value itself and a range or sub-pattern check on it.
  • Order arms from most specific to most general, and put broad catch-alls (_ or a bare variable) last, since earlier arms shadow later ones.
  • Reach for if let / while let for the common “I only care about Some” or “keep going while there’s a next item” shapes instead of a full match with an empty _ arm.

Practice Exercises

  • Write a function describe(n: Option<i32>) -> String that returns "empty" for None, "small" for values 0 through 9 inclusive, and "big" for anything else, using a single match.
  • Define an enum Shape with variants Circle(f64) (radius) and Rectangle(f64, f64) (width, height). Write a function that matches on a Shape and prints its area (use 3.14159 for pi).
  • Given an array of integers, use a match with a guard to print whether each number is "even" or "odd", but print "zero" as a special case for 0.

Summary

  • match compares a value against a list of patterns, top to bottom, and runs the first arm that matches.
  • Rust checks exhaustiveness at compile time, so every possible value of the matched type must be handled — this catches forgotten enum variants before they become bugs.
  • Patterns can be literals, ranges, wildcards, or-patterns, guards, @ bindings, or destructured tuples/structs/enum variants.
  • Matching a value directly can move ownership out of it; matching a reference only borrows, leaving the original usable afterward.
  • if let and while let are shorthand for a match with one pattern of interest and an ignored fallback.
  • A plain let requires an irrefutable pattern; use if let or match for patterns that might not match.