Learning Rust · Part III · Chapter 08

Pattern Matching

In other languages a switch compares one value against a list of constants and falls through. Rust's match takes a value apart, binds the pieces by name, and refuses to compile until you have accounted for every shape it could have.

01

A switch that returns a value

You have written a switchbefore. In Go, in Java, in JavaScript, you compare one value against a list of constants and run a block for the one that matches. It is a statement: it does work, it does not produce a value, and if you forget a case nothing complains. Rust’s match looks like that switch at first glance and is a different tool underneath. It is an expression that yields a value, and it is exhaustive, meaning the compiler counts the cases for you and refuses to build until every one is handled. Read this once for shape, then press Run:

match_is_an_expression.rs
// `match` is exhaustive AND it is an expression: it yields a value.
// Every arm must produce the same type, and every case must be covered.
fn http_reason(code: u16) -> &'static str {
    match code {
        200 => "OK",
        301 | 302 => "redirect",
        400..=499 => "client error",
        500..=599 => "server error",
        _ => "something else",
    }
}

fn main() {
    // The whole match evaluates to one value, which we bind to `label`.
    for code in [200, 302, 404, 503, 100] {
        let label = http_reason(code);
        println!("{code} -> {label}");
    }
}

Two things are happening that a switch does not do. First, the whole match evaluates to a value: every arm produces a &'static str, and that string is what http_reason returns and what label binds to. There is no break, no fall-through, no scratch variable assigned in each branch; the construct itself is the value. Second, the arms are not all bare constants. 301 | 302 matches either number with one arm, 400..=499 matches an inclusive range, and _ at the bottom catches everything left. Those are patterns, and patterns are the subject of this whole chapter.

The word pattern means more than “a value to compare against”. A pattern describes a shape, and where the shape holds, it can bind names to the pieces inside. That is the move a switch cannot make and the one that makes matchworth learning properly: it does not just ask “which case is this”, it takes the value apart and hands you the parts.

match is an expression, and it is exhaustive

A match produces a value, so you can return it, bind it, or pass it along, the same way you would any expression. And it must cover every case the type allows; leave one out and the code does not compile. Those two properties, taken together, are why match is the workhorse of Rust and not a tidier switch.

02

Destructuring: taking the shape apart

Here is the pain a switch leaves you with. You match a value, you learn which case it is, and then you still have to dig the fields out by hand: shape.from.x, shape.to.y, one accessor at a time, in the body of the branch. The matching told you the shape; it did not give you the pieces. Rust folds those two steps into one. A pattern that matches a struct, an enum, or a tuple can bind the inner fields to names in the same breath that it matches. This is called destructuring: naming the parts of a compound value as you confirm its shape. Run it:

destructuring_the_lot.rs
// A pattern can reach inside a struct, an enum, or a tuple and bind the parts.
struct Point {
    x: i32,
    y: i32,
}

enum Shape {
    Dot(Point),
    Segment { from: Point, to: Point },
}

fn describe(shape: &Shape) -> String {
    match shape {
        // Pull the struct's fields straight out by name.
        Shape::Dot(Point { x, y }) => format!("a dot at ({x}, {y})"),
        // Struct-variant fields are named, so bind them by their names.
        Shape::Segment { from, to } => {
            // Tuple destructuring in a plain `let`, no match needed.
            let (Point { x: x0, y: y0 }, Point { x: x1, y: y1 }) = (from, to);
            format!("a segment from ({x0}, {y0}) to ({x1}, {y1})")
        }
    }
}

fn main() {
    let dot = Shape::Dot(Point { x: 1, y: 2 });
    let seg = Shape::Segment {
        from: Point { x: 0, y: 0 },
        to: Point { x: 3, y: 4 },
    };
    println!("{}", describe(&dot));
    println!("{}", describe(&seg));
}

Read the arms. Shape::Dot(Point { x, y }) says: if this is a Dot, reach into the Point it carries and bind its x and y fields to names I can use in the body. Shape::Segment { from, to }does the same for the struct-variant’s two named fields. The arm body never writes .x or .from; the pattern already pulled everything out. The shape of the pattern mirrors the shape of the value, which is the whole idea: you write the structure you expect, and where it fits, the names fall into place.

Destructuring is not limited to match. The line let (Point { x: x0, y: y0 }, ...) = (from, to) takes a tuple of two points apart in a single let, binding four coordinates at once. Anywhere Rust expects a name to bind, it will accept a pattern instead, and the pattern can be as nested as the value is. A tuple of structs, a struct holding an enum, an enum carrying a tuple: the pattern follows the value down as far as you care to go.

Destructuring is matching and unpacking at once

In Go you switch on a value and then read its fields separately. A Rust pattern does both: Point { x, y } confirms the value is a Point and binds x and yin the same step. The pattern is a picture of the value’s structure, and matching it hands you every named part.

03

The wildcard _ and the catch-all binding

Often you do not want to name every case. Some you handle specifically; the rest get one common treatment. Rust gives you two ways to write “everything else”, and the difference between them matters. One throws the value away; the other keeps it. Run this:

wildcard_and_catch_all.rs
// Two ways to mop up the cases you did not name: `_` ignores, a name binds.
fn roll(face: u8) -> String {
    match face {
        1 => "you lose".to_string(),
        6 => "you win".to_string(),
        // A bound name catches every remaining value AND keeps it.
        other => format!("roll again, you got {other}"),
    }
}

fn first_field(pair: (i32, i32)) -> i32 {
    // `_` says "there is a second field, and I am deliberately ignoring it".
    let (first, _) = pair;
    first
}

fn main() {
    for face in [1, 3, 6] {
        println!("{}", roll(face));
    }
    println!("first field is {}", first_field((10, 20)));
}

In roll, the last arm is other => .... The name other is a pattern that matches any value not caught above and binds it, so the body can print the actual number you rolled. In first_field, the pattern (first, _) uses _, the wildcard, for the second field. _ matches anything and binds nothing: it is how you say “there is a value here, I see it, and I am deliberately ignoring it”. The two read almost the same in source but mean opposite things about whether the value is available afterward.

Reach for _ when you genuinely do not need the value, and a named binding when you do. There is a third use of _ worth knowing now: in a struct pattern, ..stands for “and the rest of the fields, whatever they are”, so Point { x, .. } binds x and ignores every other field without naming them one _ at a time.

Why a bare name is a catch-all, not a comparison

A beginner often writes othermeaning “match the constant named other”. Rust has no such reading. A bare lowercase name in a pattern is always a new binding that matches anything, never a lookup of an existing variable. That is why a catch-all arm with a name shadows nothing and compares against nothing: it simply succeeds and captures. To match against an existing constant you name a real const or use a literal, not a fresh lowercase identifier.

04

Or-patterns and ranges: one arm, many values

Back in section 01 you saw 301 | 302 and 400..=499 slip by without a name. They are worth slowing down for, because they let one arm stand in for a whole set of values without a wall of near-identical branches. The vertical bar | is an or-pattern: 301 | 302 matches if the value is either one. The ..= is an inclusive range pattern: 400..=499matches any value from 400 through 499, endpoints included. Put them in the same arm and you can say “this number, that number, or anything in this band” on a single line.

The two compose with each other and with binding. You can write 1 | 2 | 3 => ... to fold three literals into one arm, or combine a range with an or as in 0..=9 | 100 => .... Ranges are not just for numbers either: 'a'..='z' matches any lowercase ASCII letter, because char has an order the compiler can walk. The point is the same in every case: the arm stops being a single constant and becomes a small set described compactly.

| means or, ..= means an inclusive range

Read a | b as “matches a or b” and lo..=hias “matches everything from lo through hi, both ends included”. They turn a long ladder of one-value arms into a few arms that each describe a set. The exclusive form lo..hi (stopping just before hi) exists for slices and iterators, but range patterns use the inclusive ..=.

An or-pattern must bind the same names

If an or-pattern binds a name, every alternative in it must bind the same name with the same type, because the arm body has to find that binding no matter which side matched. So Some(n) | Ok(n) is fine only when both ns have one type; a branch that binds n beside one that does not will not compile. The rule is mechanical: the arm runs one body, so the bindings it relies on must exist on every path into it.

05

Guards and @ bindings: testing while you match

Patterns describe structure, but sometimes structure is not enough. You want this arm only when the matched value also satisfies a condition the pattern cannot express: a number that is even, a string that starts with a slash, a field within some computed bound. For that, an arm can carry a guard: an if clause that runs after the pattern matches and decides whether the arm actually fires. And when you need both to test a value and to keep it, the @ operator binds a name to the whole value while a sub-pattern tests it. Run it:

guards_and_at_bindings.rs
// A guard is an `if` on an arm; `@` captures a value while you test it.
enum Event {
    Key(char),
    Resize { width: u32, height: u32 },
}

fn handle(event: &Event) -> String {
    match event {
        // Guard: the pattern matches, then the `if` decides whether the arm fires.
        Event::Key(c) if c.is_ascii_digit() => format!("digit key {c}"),
        Event::Key(c) => format!("key {c}"),
        // `@` binds the whole width to `w` while the range pattern tests it.
        Event::Resize { width: w @ 0..=640, height } => {
            format!("small window: {w} by {height}")
        }
        Event::Resize { width, height } => {
            format!("window: {width} by {height}")
        }
    }
}

fn main() {
    let events = [
        Event::Key('7'),
        Event::Key('q'),
        Event::Resize { width: 320, height: 240 },
        Event::Resize { width: 1920, height: 1080 },
    ];
    for event in &events {
        println!("{}", handle(event));
    }
}

The first arm, Event::Key(c) if c.is_ascii_digit(), matches a key press, binds the character to c, and then the guard checks whether that character is a digit. If the guard is false the arm does not fire and matching continues to the next arm, which catches the non-digit keys. A guard is ordinary code: it can call methods, compare against other variables, do arithmetic, anything that yields a bool. It is the escape hatch for tests that live outside the shape of the value.

The third arm shows @. The pattern width: w @ 0..=640 reads: match the width field against the range 0..=640, and if it fits, bind the whole matched value to w. Without the @ you would have to choose between testing the range (and losing the number) or binding the number (and losing the range test). The @lets you do both at once, which is exactly what you want when an arm cares about a value’s band and needs to print the value.

Why it is called an @ binding

The @symbol is read “at”, and the binding reads like an address: w @ 0..=640 is “w, found at a value in 0 through 640”. The name on the left captures whatever the pattern on the right matched. It looks cryptic the first time and becomes plain once you read it as “bind this name to the thing that matched this sub-pattern”. Guards and @ are different tools: a guard is an arbitrary boolean test, while @ is purely a way to keep a value you are also pattern-testing.

06

Exhaustiveness: the rule that earns its keep

Now the property that makes all of this load-bearing rather than merely convenient. A match must be exhaustive: its arms must cover every value the matched type can hold. Miss one and the program does not compile, with error E0004, non-exhaustive patterns, naming a case you did not handle. This is not a lint you can wave away; it is a rule of the language, and it is the reason matching is safe to lean on.

Consider why it matters. In a switch with silent fall-through, adding a new case to an enum is a quiet catastrophe: every switch that did not know about the new case keeps compiling and silently does the wrong thing, or hits a default that was never meant for it. In Rust, add a variant to an enum and every match that named its variants explicitly stops compilinguntil you decide what the new variant should do. The compiler turns “I added a case” into a checklist of every place that has to react. That is exhaustiveness paying rent: it converts a class of runtime bugs into a wall of compile errors you fix before you ship.

This is also why the wildcard _ is a decision, not a habit. Writing _ => ... as a final arm makes the match exhaustive by sweeping up everything left, which is right when the remaining cases genuinely share one treatment. But it also silences the future warning: a new enum variant falls into the _ arm without a word. So you face a real choice. Name the variants and let the compiler nag you when the enum grows, or write _ and accept that growth passes silently. For an enum you control and expect to extend, naming the variants is usually the safer bet.

Exhaustiveness is the safety, not the syntax

The arms and bindings are pleasant; the exhaustiveness is the point. A match that compiles has, by construction, handled every shape the value can take. There is no forgotten case waiting to surface at runtime, because the compiler counted the cases and would not let the code build with one missing.

Refutable vs irrefutable, the two scary words

A pattern is irrefutable if it always matches (like let (a, b) = pair: a tuple is always a tuple) and refutable if it can fail (like Some(x), which fails on None). The words sound academic but the rule is simple: a plain let needs an irrefutable pattern, because there is nowhere for a failure to go. Refutable patterns live where failure has somewhere to land: a match arm, an if let, a let else. That distinction is exactly what the next section is built on.

07

if let, let else, while let: patterns off the main road

A full match is the right tool when you care about every case. When you care about one, it is too much ceremony: a two-arm match where one arm does the work and the other is an empty _ => {} reads like noise. Rust gives you three shorter forms, each a match folded down to the shape you actually need. Run the first two:

if_let_and_let_else.rs
// When you care about one shape, a full match is too much ceremony.
// `if let` handles the one case; `let else` handles the early-return case.
fn greet(name: Option<&str>) {
    // `if let` runs the block only when the pattern matches.
    if let Some(name) = name {
        println!("hello, {name}");
    } else {
        println!("hello, stranger");
    }
}

fn parse_port(text: &str) -> Result<u16, String> {
    // `let else`: bind on success, or run the `else` (which must diverge) on failure.
    let Ok(port) = text.trim().parse::<u16>() else {
        return Err(format!("{text:?} is not a port"));
    };
    // `port` is in scope for the rest of the function, no nesting.
    Ok(port)
}

fn main() {
    greet(Some("Ada"));
    greet(None);
    println!("{:?}", parse_port("  8080 "));
    println!("{:?}", parse_port("https"));
}

if let Some(name) = name runs its block only when the pattern matches, binding name inside it, with an optional else for the case it does not. It is the one-case match: when you want to act on a Some (or one enum variant) and shrug at the rest, if let says exactly that and no more. let else is the mirror image, built for early return. The line let Ok(port) = ... else { return ... } binds port on success and runs the else on failure, and the else block must diverge: it has to return, break, continue, or panic, so that past the let the binding is guaranteed to exist. The payoff is that port stays in scope for the whole rest of the function, flat and unnested, instead of trapped inside a match arm.

The third form, while let, turns a refutable pattern into a loop condition: keep going as long as the pattern matches, stop when it does not. It is the idiomatic way to drain something until it runs dry. Run it:

while_let_drains.rs
// `while let` keeps looping as long as the pattern matches: drain until None.
fn main() {
    let mut stack = vec![1, 2, 3, 4, 5];

    // `pop` returns Option<i32>: Some(n) while items remain, None when empty.
    // The loop runs while the pattern Some(top) matches, then stops on None.
    while let Some(top) = stack.pop() {
        println!("popped {top}, {} left", stack.len());
    }

    println!("stack is now {stack:?}");
}

stack.pop() returns Some(value) while items remain and None when the stack is empty. The loop while let Some(top) = stack.pop() runs its body for each Some, binding the popped value to top, and ends the instant pop returns None. You did not write a counter, a length check, or a break; the pattern is the loop condition, and “the pattern stopped matching” is the exit. This is the drain-until-None loop, and you will reach for it constantly.

Three shorthands, one idea

if let is a match you care about one arm of. let else is a match that binds on success and bails on failure. while let is a match used as a loop condition. All three exist so that a refutable pattern, the kind that can fail to match, can be used in the place a plain let cannot accept it, without spelling out a whole match.

08

Patterns everywhere: let and function parameters

By now the lesson under the lesson should be visible: patterns are not a match feature, they are a language feature, and match is only their most visible home. Anywhere Rust binds a name, it will take a pattern instead. You have already used one without ceremony: every let (a, b) = pair is a pattern in a let. The same goes for function parameters, where a pattern can destructure an argument the moment it arrives. Run it:

patterns_in_params.rs
// Patterns are not just for match: they work in `let` and function parameters.
struct Rgb {
    r: u8,
    g: u8,
    b: u8,
}

// The parameter is a pattern that destructures the tuple as it arrives.
fn distance((x1, y1): (f64, f64), (x2, y2): (f64, f64)) -> f64 {
    ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
}

fn main() {
    // Irrefutable pattern in a `let`: this shape always matches, so no `else`.
    let Rgb { r, g, b } = Rgb { r: 240, g: 113, b: 66 };
    println!("rust orange is #{r:02x}{g:02x}{b:02x}");

    // The tuple is destructured by the parameter patterns, not inside the body.
    let d = distance((0.0, 0.0), (3.0, 4.0));
    println!("distance is {d}");
}

The line let Rgb { r, g, b } = ... destructures a struct in a plain let: no match, no if let, because the pattern is irrefutable. A value of type Rgb is always an Rgb with those three fields, so the match cannot fail, so a bare let accepts it. The distance function goes further: its parameters are written (x1, y1): (f64, f64), so each tuple argument is taken apart into named coordinates at the call boundary, and the body works with x1 and y1 directly, never .0 or .1.

This is the irrefutable-pattern rule from section 06 cashing out. let bindings and function parameters demand patterns that always match, which is why you destructure tuples and structs there freely but cannot write let Some(x) = maybe; as a plain let (that one is refutable, and the compiler points you at if let or let else instead). Same patterns, different homes, with the refutable-or-not distinction deciding which home each one is allowed in.

If it binds a name, it can take a pattern

match arms, let bindings, function parameters, if let, let else, while let, and the loop variable in a for: every one of them is a place a pattern can go. Learn patterns once and you have learned the binding grammar of the whole language.

09

The whole pattern

Look back at the matches in this chapter and the shape is one shape worn many ways. A pattern is a picture of a value: it confirms the value’s structure and, where it fits, binds the inner parts to names. Stack those pictures as the arms of a match and the compiler checks they cover every case, so a match that compiles has handled every shape the value can take. Shrink the same idea to one case and you get if let; to an early return, let else; to a loop, while let; to a bare binding, the patterns in let and in function parameters.

One thread runs past this chapter on purpose. Every match here worked on one concrete type, an Event or a Shape you wrote out by hand. Soon you will want one match-driven function that works over many types at once, and for that you need generics and traits. That is the story of Part III, chapters 09 and 10; the patterns you learned here are what those functions will match against.

The whole chapter in six lines

  1. A patterndescribes a value’s shape and binds its inner parts to names where the shape fits.
  2. match is an expression (it yields a value) and is exhaustive (every case handled, or it will not compile, error E0004).
  3. Patterns destructure structs, enums, and tuples; | matches alternatives, ..= matches an inclusive range, _ ignores, a name catches and keeps.
  4. A guard (if on an arm) adds a boolean test the pattern cannot express; @ binds a name while a sub-pattern tests the value.
  5. if let handles one case, let else binds or early-returns, while let drains until the pattern stops matching.
  6. Irrefutable patterns (always match) live in let and function parameters; refutable ones (can fail) live where failure has somewhere to go.
End-of-chapter assessmentCheck your understanding20 questions. Your first attempt is recorded on your report card.Take it →