Skip to content
beginner Phase 5 · Enums, Option, Result, and Pattern Matching

match Expressions

Branch on values with exhaustive arms that the compiler forces you to cover.

45m
0 problems
Topic Progress 0%

Exhaustiveness: the Guarantee Behind Every `match`

match is Rust's branching expression, and it carries one property no other language's switch does: the compiler proves you handled every possible value before the program runs. Write a match over an enum and remove an arm:

enum Direction { North, South, East, West }

fn bearing(d: Direction) -> u16 {
    match d {
        Direction::North => 0,
        Direction::East => 90,
        Direction::South => 180,
        // West missing
    }
}
// error[E0004]: non-exhaustive patterns: `Direction::West` not covered

The error names the exact uncovered variant. Add a fifth variant to the enum and every match that enumerates variants breaks in the same way — which is the feature working as designed: the compiler turns "handle every case" from a code-review wish into a type-system guarantee. This is the mechanism behind the phrase making illegal states unrepresentable: the states are declared in the enum, and the exhaustiveness checker forces every consumer to address each one.

The idiomatic escape valve is a wildcard arm, which covers everything not listed:

fn bearing(d: Direction) -> u16 {
    match d {
        Direction::North => 0,
        Direction::East => 90,
        _ => 180,
    }
}

_ also matches values, not just variants: it covers remaining integers, characters, or structs in ranges you did not enumerate. The trade-off is explicit and worth stating in review: a wildcard arm buys compiles-today at the cost of not failing when a new case appears. When a new Direction variant arrives, the wildcard arm silently swallows it — so use _ for genuinely open sets (user input, protocol bytes) and enumerate deliberately for closed sets you own.

One refinement that keeps both properties: bind a name instead of _ to keep the value usable in the fallback:

match d {
    Direction::North => 0,
    other => {
        println!("heading elsewhere: {other:?}");
        180
    }
}

A lowercase binding like other catches everything remaining and gives you the value, while Direction::North keeps its exhaustiveness guarantee. Note also that match is an expression: the arms above yield u16s, so the match itself can initialize a variable, be a function's tail expression, or be nested inside another expression — no semicolon needed for it to produce a value.

Exhaustiveness also changes how a team absorbs change. When the enum gains a variant, cargo build prints every function that must be revisited — the compiler is doing your change-impact analysis for free. In a language with only a default clause, the new state silently flows into the catch-all and the bug surfaces wherever the default happens to be wrong. "Why is Rust's match verbose?" is best answered with this: it is not verbose, it is a checklist the compiler maintains, and it is exactly as long as your domain has cases.

One boundary on the guarantee: it applies to types you can enumerate. Your own enums are closed sets, so arms without a wildcard suffice. Integers, characters, and strings are open sets — nobody lists 256 arms for a u8 — so the compiler requires a _ or a range to cover what you did not name:

match byte {
    0 => "zero",
    1..=9 => "small",
    _ => "large",     // mandatory: u8 has 246 more values
}

You decide explicitly what happens outside the values you care about, and — this is the part that matters — the decision is visible in the source instead of hidden in an omitted case branch.

Arms Are Checked in Order: Ranges, Guards, and the Lowercase Trap

Arms run top to bottom and the first matching arm wins; the rest are not evaluated. That simple rule explains most match bugs, because patterns that overlap silently make earlier arms unreachable.

fn classify(n: i32) -> &'static str {
    match n {
        1..=9 => "small",
        10..=99 => "medium",
        _ => "large",        // catch-all: nothing listed below can ever match
    }
}

The compiler warns unreachable pattern for any arm after a catch-all, which is exactly the nudge you want in review. A named binding in the same position — other => "large" — catches everything too, and additionally keeps the value usable in that arm. The same warning applies to literal overlap:

match n {
    1 => "one",
    1 => "still one, never reached",
    _ => "other",
}
// warning: unreachable pattern on the second arm

The trap that catches nearly everyone once is binding vs. literal patterns by name. Lowercase names are new bindings; uppercase names are paths to existing variants or constants:

let max = 100;                 // a local variable, not a constant
match n {
    max => "catches EVERYTHING",  // binds n to a new variable `max` — no comparison happens
    _ => unreachable!(),
}

There is no comparison at all: max is treated as a fresh binding, which irrefutably matches. Writing MAX in SCREAMING_CASE and declaring it const MAX: i32 = 100; restores the comparison, because constants are paths and therefore literal patterns. This is why the style guide's casing rules are a correctness matter inside match.

Guards add conditions without changing exhaustiveness:

match score {
    s if s >= 90 => "A",
    s if s >= 60 => "B",
    _ => "F",
}

Each if clause refines a pattern, so s if s >= 90 binds the value and then tests it. The catch: guards do not count toward exhaustiveness — the compiler cannot evaluate your boolean — so match score { s if s >= 90 => "A", s if s < 90 => "F" } still demands a _ arm even though the two guards cover every i32. Range and slice patterns round out the toolkit: 1..=9, 'a'..='z', and [first, .., last] for slices, all participating in the same ordered, first-match-wins evaluation.

Trace a concrete input through the arms to make the rule automatic: classify(15) fails 1..=9, succeeds 10..=99, and returns without touching the catch-all. The bugs come from broad patterns sitting above narrow ones — match n { _ => "large", 1 => "one" } compiles with an unreachable pattern warning and then reports "large" for every input including 1. Teams that mean it turn that warning into a gate with #![deny(unreachable_patterns)] at the crate root, so overlap can never land.

Two more ordering traps worth naming. A partial struct pattern matches far more than it looks like: match point { Point { x: 0, .. } => "on axis", _ => "free" } only catches zero-x points, but Point { x, .. } with no conditions matches every point, so placing it before another arm hides that arm completely. And matching a value by name moves it — match owned { Packet::Text(t) => consume(t), .. } hands t to the arm and leaves owned partially moved — while match &owned { ... } borrows instead. Choosing between the two is an ownership decision, not a style one.

`match` Produces a Value: Binding Payloads and Picking It Apart

Every arm is an expression, so a match can compute the value you actually want instead of mutating a flag and branching later. This is the shape you will write thousands of times:

enum Packet {
    Text(String),
    Ping,
    Size(u32, u32),
}

fn describe(p: &Packet) -> String {
    match p {
        Packet::Text(t) => format!("text of {} bytes", t.len()),
        Packet::Ping => "ping".to_string(),
        Packet::Size(w, h) => format!("{w}x{h}"),
    }
}

Each arm's pattern names the payload it extracted: t, w, and h are ordinary bindings scoped to their arm, holding whatever the variant carried. Matching on &Packet binds t as &String — references flow through patterns — so you borrow the payload rather than moving it out of the matched value.

Arms with real work get their own block, and blocks can mix statements and a tail expression:

match parse_port(input) {
    Ok(port) if port != 0 => {
        log::info!("port accepted");
        port
    }
    Ok(_) => default_port(),
    Err(e) => {
        eprintln!("bad input: {e}");
        return Err(e);          // diverges: `!`, compatible with any arm type
    }
}

The last arm returns from the enclosing function, which type-checks because return (and panic!, and unreachable!()) has type ! — it coerces to whatever the other arms produce. That is why an otherwise-unmatched arm that panics still satisfies exhaustiveness: it covers the case by never producing a value.

Two daily-driver macros build on the same patterns. matches!(value, pattern) returns a bool and is the clean way to test a shape without bindings — if matches!(state, State::Ready). And if let is the one-arm version for the common "handle one shape, ignore the rest" case:

if let Packet::Text(t) = &packet {
    println!("payload: {t}");
}

Reach for full match when the cases are closed and each deserves a branch — the exhaustiveness guarantee is doing real work. Reach for if let when you genuinely care about one shape; the cost is that the compiler will not warn when new variants appear, so the discipline is: if a new variant should change your behavior, write the match.

An ownership detail decides whether you write match &packet or match packet: matching by value moves the payload into the arm's bindings, matching by reference borrows it:

let p = Packet::Text(String::from("hi"));
match p {
    Packet::Text(t) => consume(t),   // `t` owns the String; `p` is now partially moved
    Packet::Ping => {}
}
// println!("{p:?}");                // error: borrow of partially moved value

If the matched type is Copy — small integers, char, tuples of copyable values — matching by value copies instead, which is why numeric matches never seem to have this problem. For everything else, decide deliberately: consume when the arm genuinely takes ownership, borrow when the value is used afterwards.

Placement matters too. A match in statement position must produce () in every arm, which produces the rite-of-passage error the first time you mix styles:

match cache.remove(&key) {
    Some(v) => v.len(),   // error[E0308]: expected `()`, found `usize`
    None => 0,
}

Either wrap the match in let used = match ... { }; to keep the value, or give every arm a trailing semicolon to discard it. And since Rust 1.65, the single-shape case has a let form that reads especially well for early returns:

let Ok(port) = input.parse::<u16>() else {
    return Err(ConfigError::BadPort);
};
println!("port {port}");   // bound only on the success path

let-else diverges in the else block — return, panic!, continue — and binds the pattern's values on success, which makes it the ergonomic replacement for the if let ... else { return } pyramid.

Full pattern syntax — nested destructuring, @ bindings, slice patterns — is the subject of Destructuring and Nested Patterns.

Quiz

1. A `match` over an enum has arms for three of four variants and a wildcard `_` arm. What happens when you add a fifth variant to the enum?

Question 1 options

2. Why does `match n { max => "a", _ => "b" }` never print "b", given `let max = 100;`?

Question 2 options

3. Why must this fail to compile: `match score { s if s >= 90 => "A", s if s >= 60 => "B" }`?

Question 3 options

4. What does an arm whose body calls `return` or `panic!()` do to the match's type checking?

Question 4 options

Flashcards

Question

What is exhaustiveness, and what error do you get when a `match` is not exhaustive?

Answer

Every possible value must be covered by an arm or a wildcard, so the compiler proves all cases are handled. Violations produce `error[E0004]: non-exhaustive patterns`, naming the missing variants — e.g. `` `Direction::West` not covered``.

Question

How do arms bind payload values, and what are their scopes?

Answer

A pattern like `Packet::Size(w, h)` extracts the variant's payload into fresh bindings `w` and `h`, scoped to that arm's body. Matching through a reference (`&Packet`) binds the payload fields as references, so data is borrowed rather than moved.

Question

In what order are `match` arms evaluated, and what warning do overlapping arms trigger?

Answer

Top to bottom, with the first matching arm chosen and later arms skipped; `match` also stops evaluating once one arm matches. Overlapping arms after a catch-all or duplicate literal trigger the `unreachable pattern` warning from the compiler.

Question

What does the `_` arm do, and what does it cost you?

Answer

`_` matches every remaining value, making the match exhaustive without listing cases — required after match guards (which don't count toward coverage). The cost: adding a new variant later compiles silently into the wildcard instead of forcing you to handle it.

Question

When should you use `if let` instead of `match`, and what do you give up?

Answer

Use `if let` for the one-shape case where the rest of the values are irrelevant — it reads cleaner and skips three arms. You give up exhaustiveness checking for the unhandled variants: new variants won't trigger a compile error, so switch to `match` whenever unhandled cases should change behavior.

Revision Notes

Key Takeaways

  • 1. Exhaustiveness turns "handle every case" into a compile-time proof — remove an arm and the compiler names the missing variant
  • 2. Arms are ordered and first-match-wins, so a catch-all above a specific arm makes it unreachable and warns
  • 3. Lowercase names bind, uppercase paths compare — the single most common `match` bug
  • 4. Guards refine a pattern but never satisfy exhaustiveness; diverging arms (`!`) satisfy it by producing no value
  • 5. `match` computes values, `if let` handles one shape, `matches!` tests shapes — pick by whether unhandled cases must matter

Interview Tips

  • • "What does exhaustiveness mean in Rust?" — the compiler proves every value of the matched type is handled; explain E0004 and how adding a variant breaks downstream matches by design
  • • "Difference between `_` and a named binding arm?" — both catch everything, but `_` discards while `other` keeps the value usable; note that a binding arm placed early silently swallows later arms
  • • "Why doesn't `match n { max => ... }` compare against `max`?" — identifier patterns bind, they don't compare; constants need `const` in SCREAMING_CASE or a variant path
  • • "match vs if let?" — match keeps exhaustiveness checking for closed sets; if let is for single-shape handling, and the trade-off is losing the compile error when new variants appear

Cheat Sheet

match Expressions

Core rules:

  1. Must be exhaustive — every value covered by an arm or _ (error E0004 otherwise)
  2. Arms run top-down; first match wins
  3. Each arm is an expression; the match yields one value
  4. Patterns bind payloads; bindings scope to their arm

Pattern kinds at a glance:

Pattern Matches Notes
Direction::North one variant path — uppercase
n (lowercase) everything fresh binding, not a comparison
1..=9 value in range also 'a'..='z'
Packet::Size(w, h) variant + payload binds w, h
[first, .., last] slices rest capture with ..
s if s >= 60 guard boolean refinement; doesn't count for exhaustiveness

Diverging arms: return/panic!()/unreachable!() have type ! — they cover a case by never producing a value.

Choice guide:

match v { ... }      // closed set: exhaustiveness does the work
if let P(x) = v {}   // one shape matters; rest ignored (no coverage check)
matches!(v, P(..))   // shape test → bool, no bindings needed

Watch for: lowercase identifiers are bindings, not values — match n { max => .. } matches everything.

Continue Learning

Before You Start

Related Reading