Skip to content

08 · Pattern Matching

Rust's match expression is exhaustive — the compiler forces you to handle every possible case, which eliminates an entire class of "forgot to check this" bugs.

Basic match

fn main() {
    let number = 3;

    match number {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("something else"),   // `_` catches anything not listed
    }
}
three

Removing the _ arm here would be a compile error — match must cover every possible value of the type being matched.

Matching multiple values and ranges

fn main() {
    let number = 7;

    match number {
        1 | 2 | 3 => println!("small"),       // OR pattern
        4..=6 => println!("medium"),           // inclusive range
        7..=10 => println!("large"),
        _ => println!("out of range"),
    }
}

Matching on Option and Result

fn describe(value: Option<i32>) -> String {
    match value {
        Some(n) if n < 0 => format!("negative: {}", n),   // match guard
        Some(0) => String::from("zero"),
        Some(n) => format!("positive: {}", n),
        None => String::from("nothing"),
    }
}

fn main() {
    println!("{}", describe(Some(-5))); // negative: -5
    println!("{}", describe(Some(0)));  // zero
    println!("{}", describe(Some(42))); // positive: 42
    println!("{}", describe(None));     // nothing
}

A match guard (if n < 0 after the pattern) adds an extra condition — the arm only matches if both the pattern and the guard are true.

Destructuring structs and tuples

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

fn describe(p: &Point) -> &str {
    match p {
        Point { x: 0, y: 0 } => "origin",
        Point { x: 0, .. } => "on the y-axis",
        Point { y: 0, .. } => "on the x-axis",
        Point { .. } => "somewhere else",
    }
}

fn main() {
    println!("{}", describe(&Point { x: 0, y: 0 })); // origin
    println!("{}", describe(&Point { x: 0, y: 5 })); // on the y-axis
    println!("{}", describe(&Point { x: 3, y: 4 })); // somewhere else
}

.. inside a pattern means "ignore the remaining fields" — useful when you only care about matching a specific subset of a struct's fields.

if let — for when you only care about one case

fn main() {
    let config_value: Option<i32> = Some(42);

    // Verbose with match:
    match config_value {
        Some(v) => println!("value is {}", v),
        None => {}   // nothing to do, but we still have to write this arm
    }

    // Cleaner with if let -- only handle the case you care about
    if let Some(v) = config_value {
        println!("value is {}", v);
    }
}

while let — looping while a pattern keeps matching

fn main() {
    let mut stack = vec![1, 2, 3];

    while let Some(top) = stack.pop() {
        println!("{}", top);
    }
    // 3
    // 2
    // 1
}

stack.pop() returns Option<T> (Some while there are elements, None once empty) — while let loops for as long as the pattern keeps matching Some, stopping automatically at None.

How It Actually Works

match compiles to a decision tree, not a linear chain of if comparisons — the compiler analyzes all the arms together and builds an efficient branching structure (often a jump table for simple enum discriminants, or a tree of tests for structural patterns), so matching against ten variants isn't ten sequential checks in the worst case. This is also the same machinery that powers exhaustiveness checking: the compiler literally constructs the space of possible values the scrutinee's type can take and verifies your arms cover all of it, which is why adding a variant to an enum turns every match on it elsewhere in the codebase that lacks a wildcard _ arm into a compile error instead of a silent runtime bug.

if let Some(v) = config_value and while let are sugar over that same match machinery, just for the single-pattern case — if let PAT = EXPR { BODY } desugars to match EXPR { PAT => BODY, _ => {} }. while let Some(top) = stack.pop() desugars to a loop containing a match that breaks on the non-matching arm: loop { match stack.pop() { Some(top) => { ... }, None => break } }. Since Vec::pop returns Option<T> by moving the popped element out rather than returning a reference, each iteration also transfers ownership of top to the loop body — no separate bounds check or null test is needed, because "empty" is represented as None, a value the match has to handle explicitly rather than a sentinel that could be forgotten.

Cheat sheet

Construct When to use
match Exhaustively handle every possible variant/value
\| (OR pattern) Match several literal values in one arm
a..=b Match an inclusive numeric range
Match guard (if cond) Add an extra condition to a pattern
if let Handle just one pattern, ignore the rest
while let Loop for as long as a pattern keeps matching
.. in a struct pattern Ignore the remaining fields

🔀 See this in another language

Exercise

Write a function classify(n: i32) -> &'static str that uses match with ranges to return "negative", "zero", "small" (1-9), or "large" (10+). Then write a loop using while let that repeatedly pops values off a Vec<Option<i32>> and prints "got: N" for Some(n) values, skipping None values silently, stopping when the vector is empty.