match Expressions

A match expression is Rust’s primary tool for comparing a value against a series of patterns and running the code for whichever pattern fits first. It looks similar to a switch statement from C, Java, or JavaScript, but it is considerably more powerful: patterns can destructure tuples, structs, and enums; the compiler forces you to account for every possible value; and because match is itself an expression, it can produce a value you store in a variable or return from a function. Once you are comfortable with match, you will reach for it constantly — unwrapping Option<T> and Result<T, E>, walking custom enums, and replacing long chains of if/else if.

Overview: How match Works

Picture handing a value to an inspector who checks it against an ordered list of shapes it might take, from top to bottom, and immediately runs the code attached to the first shape that fits. That is exactly what match does. It takes one value and a list of arms, where each arm is a pattern followed by => and the code to run if the value matches that pattern. The inspector never checks more than one arm that matches — there is no “fallthrough” like in a C switch statement, so you never need a break.

The feature that makes match genuinely different from a switch statement is exhaustiveness checking. The Rust compiler proves, at compile time, that your arms cover every value the matched type could possibly hold. If you match on an i32 and only list a few specific numbers, the compiler will refuse to compile your program unless you add a catch-all arm (written _) to handle the rest. If you match on an enum with three variants and only write arms for two of them, the compiler tells you exactly which variant you forgot. This eliminates an entire category of bugs common in other languages: the case nobody thought to handle. This exhaustiveness check is why adding a new variant to an enum later in a project’s life is often described as a feature in Rust — the compiler will point you to every match in your codebase that needs updating.

The second important idea is that match is an expression, not a statement. Every arm must evaluate to a value of the same type, and the whole match evaluates to whichever arm ran. That means you can write let x = match ... { ... }; and bind the result directly, or make a match the final expression of a function body to return its value, with no explicit return needed.

Patterns themselves can be far richer than single values: they can match ranges of numbers, bind a name to whatever value matched, destructure a tuple or struct into its fields, match several alternatives with |, and attach an extra boolean condition (a guard) with if. The sections below build these up piece by piece, then work through progressively more realistic examples.

Syntax

The general shape of a match expression looks like this:

match VALUE {
    PATTERN1 => EXPRESSION1,
    PATTERN2 if GUARD_CONDITION => EXPRESSION2,
    PATTERN3 | PATTERN4 => EXPRESSION3,
    _ => DEFAULT_EXPRESSION,
}
  • VALUE — the expression being matched. It can be a variable, a reference to one, or any expression that produces a value (a function call, a tuple literal, and so on).
  • PATTERN — what a given arm tries to match against: a literal (5), a range (1..=10), a variable name that binds the whole value (n), a destructuring pattern (Some(n), (x, y)), or the wildcard _ that matches anything without binding it.
  • if GUARD_CONDITION — an optional extra boolean check evaluated only after the pattern itself matches; if the guard is false, matching continues to the next arm.
  • | — combines several patterns into one arm; the arm runs if the value matches any of them.
  • => EXPRESSION — the code to run for that arm. A block { ... } is allowed when you need multiple statements; its final expression becomes the arm’s value.
  • Exhaustiveness — the full set of arms must cover every possible value of VALUE’s type, which is why a wildcard _ arm is so common as a final catch-all.

Examples

Example 1: Matching literals, alternatives, and ranges

fn main() {
    let number = 7;

    match number {
        1 => println!("One"),
        2 | 3 | 5 | 7 | 11 => println!("A small prime number"),
        4..=6 => println!("Between four and six"),
        _ => println!("Something else"),
    }
}
A small prime number

The value 7 is checked against each arm in order. It does not equal 1, so the compiler moves on. The second arm uses | to list several alternatives on one line; 7 is one of them, so this arm runs and the match stops — the compiler never even looks at the range arm, even though 7 is close to 4..=6. Ordering matters: the first matching arm always wins.

Example 2: Destructuring an enum’s data

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle(f64, f64, f64),
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(radius) => {
            let r = *radius;
            std::f64::consts::PI * r * r
        }
        Shape::Rectangle(width, height) => {
            let w = *width;
            let h = *height;
            w * h
        }
        Shape::Triangle(a, b, c) => {
            let a = *a;
            let b = *b;
            let c = *c;
            let s = (a + b + c) / 2.0;
            (s * (s - a) * (s - b) * (s - c)).sqrt()
        }
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle(2.0),
        Shape::Rectangle(3.0, 4.0),
        Shape::Triangle(3.0, 4.0, 5.0),
    ];

    for shape in &shapes {
        println!("{:.2}", area(shape));
    }
}
12.57
12.00
6.00

Shape is an enum where each variant carries its own data. Because area takes &Shape, matching on shape uses Rust’s match ergonomics: patterns like Shape::Circle(radius) automatically bind radius as a reference (&f64) into the borrowed data rather than moving it out. Dereferencing with *radius copies out the plain f64 (numbers implement Copy, so this is cheap and leaves the original untouched). Each arm computes a different formula and the block’s last expression becomes that arm’s value, so every arm still evaluates to an f64 as the function signature requires.

Example 3: Guards and the @ binding

fn grade_report(score: u32) -> String {
    match score {
        s @ 90..=100 => format!("A ({s})"),
        s @ 80..=89 => format!("B ({s})"),
        s @ 70..=79 => format!("C ({s})"),
        s if s < 70 => format!("F ({s})"),
        s => format!("Invalid score: {s}"),
    }
}

fn main() {
    let scores = [95, 82, 71, 40, 150];
    for &score in &scores {
        println!("{}", grade_report(score));
    }
}
A (95)
B (82)
C (71)
F (40)
Invalid score: 150

The @ operator lets an arm test a range while still binding the matched value to a name (s) that the arm body can use — without it, you would only know that the value fell in 90..=100, not which number it was. The fourth arm has no range at all; it is a plain binding pattern (s, which matches anything) paired with an if guard, so it only fires when the guard is true. The final arm, an unguarded s, matches literally anything and is what makes the whole match exhaustive — it is what catches 150, since u32 allows values above 100.

How It Works Step by Step

When the compiler processes a match, it does two distinct jobs. First, at compile time, it performs exhaustiveness analysis: for the type being matched, it walks every arm’s pattern and proves the union of all patterns covers the type’s entire range of possible values. For an enum, this means every variant must appear in some arm (or be covered by a wildcard). For an integer, since guards can contain arbitrary conditions the compiler cannot evaluate at compile time, only the unguarded patterns count toward exhaustiveness — that is precisely why grade_report needs its final unguarded s arm even though the guarded s if s < 70 arm looks like it should cover the low end.

Second, at runtime, matching proceeds top to bottom: for each arm, the runtime checks whether the value’s shape fits the pattern (does it equal this literal, fall in this range, match this variant tag?). If the pattern matches and there is a guard, the guard expression is evaluated; if the guard is false, matching resumes at the next arm as though the pattern hadn’t matched at all. The first arm that matches its pattern and passes its guard runs, its expression becomes the value of the whole match, and no further arms are considered. For simple patterns like matching against enum variants, the compiler typically generates an efficient jump table rather than a linear chain of comparisons, so match is not just readable — it is fast.

Common Mistakes

Mistake 1: Forgetting to cover every case

Rust will not compile a match that misses possible values:

fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 => "one",
    }
}

This fails with a “non-exhaustive patterns” error, because i32 has far more values than 0 and 1. Add a wildcard arm to handle everything else:

fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 => "one",
        _ => "many",
    }
}

fn main() {
    println!("{}", describe(0));
    println!("{}", describe(1));
    println!("{}", describe(5));
}
zero
one
many

Mistake 2: Matching by value moves the value

Matching directly on an owned, non-Copy value moves it into the arm that catches it, just like passing it to a function would:

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

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

    println!("{:?}", name); // error: borrow of moved value: `name`
}

The Some(n) arm moves the String out of name and into n, so name as a whole is no longer usable afterward. Match on a reference instead so the match only borrows:

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

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

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

Matching &name instead of name means n is bound as &String rather than String; nothing is moved, and name is still valid on the next line.

Mistake 3: Arms that produce different types

Because match is an expression, every arm must evaluate to the same type:

fn main() {
    let n = 3;

    let description = match n {
        1 => "one",
        2 => "two",
        _ => 0,
    };

    println!("{description}");
}

The first two arms produce &str while the last produces an integer, so the compiler rejects it with a type mismatch. Make every arm agree on the type:

fn main() {
    let n = 3;

    let description = match n {
        1 => "one",
        2 => "two",
        _ => "many",
    };

    println!("{description}");
}
many

Best Practices

  • Prefer match over long if/else if chains whenever you are comparing one value against several discrete cases — it reads more clearly and the compiler checks completeness for you.
  • Avoid a blanket _ => {} arm on enums you control; an explicit arm per variant means the compiler will flag every match that needs attention when you add a new variant later.
  • Match on a reference (&value) rather than the value itself when you only need to read the data, so ownership stays where it is.
  • Reach for a guard (if) only for conditions a pattern truly cannot express (like comparing two bound variables); prefer expressing the condition directly in the pattern when you can.
  • Use if let or while let instead of a full match when you only care about one pattern and want to ignore everything else.
  • Group patterns with | when several cases share identical arm bodies, instead of repeating the same code.
  • Reach for @ bindings when an arm’s pattern is a range or alternative but the arm body still needs the exact matched value.

Practice Exercises

  • Write a function fizzbuzz(n: u32) -> String that uses a match with guards to return "FizzBuzz" when n is divisible by 15, "Fizz" when divisible by 3, "Buzz" when divisible by 5, and otherwise n converted to a string.
  • Define an enum TrafficLight with variants Red, Yellow, and Green, and write a function that matches on it to return an instruction string ("Stop", "Slow down", "Go"). Then add a fourth variant, such as FlashingRed, and notice the compiler error pointing at your match until you add a matching arm.
  • Write a function that takes Option<i32> and returns the value doubled, or 0 for None, once using match and once using .map(...).unwrap_or(0). Compare the two versions — both should produce identical results for the same inputs.

Summary

  • match compares a value against an ordered list of patterns and runs the first arm whose pattern (and optional guard) matches, with no fallthrough between arms.
  • The compiler enforces exhaustiveness: every possible value of the matched type must be covered, usually via a wildcard _ arm.
  • match is an expression — every arm must produce the same type, and the result can be assigned, returned, or used inline.
  • Patterns can be literals, ranges (a..=b), alternatives (|), destructured enum or tuple data, and bindings combined with a range via @.
  • Matching an owned value by value moves it; match on a reference (&value) when you only need to read the data.
  • Guards (if condition) add an extra runtime check after a pattern matches, for conditions patterns alone cannot express.