Skip to lesson

learningrust.org / intermediate / 11-pattern-matching · lesson 7 of 26

TL;DR

Master Rust's powerful pattern matching with match expressions, if let, and destructuring

Key concepts

  • Rust pattern matching
  • Rust match expression
  • Rust if let
  • Rust destructuring
  • Rust exhaustive matching

Pattern Matching in Rust

Pattern matching is one of Rust's most powerful features. It lets you compare a value against a series of patterns and execute code based on which pattern matches. Unlike simple switch statements in other languages, Rust's pattern matching is exhaustive, meaning the compiler ensures you handle every possible case. Combined with destructuring, it becomes an expressive tool for writing concise and safe code.

What You'll Learn

By the end of this lesson you will build a sensor router: three small functions over one enum, each reaching for a different tool — a plain exhaustive match, a set of guarded arms that must be ordered correctly, and an if let for the case where only one variant matters. The part that catches people is the guards. A guarded arm ordered wrongly still compiles, still runs, and emits no warning at all, because the guard switches off the very lint that would have caught it; the only evidence is an answer that is quietly wrong.

match is the construct the capstone leans on hardest — ten of them, turning a word off a task line into a Priority, a Priority back into a label, an argument into a setting, and every Result into either a value or a reported failure — and the enums it branches over are the ones you defined last lesson. You arrive here able to declare an enum whose value is exactly one variant at a time and whose payload rides inside it (Structs & Enums); what is new is the machinery for getting that payload back out, and the guarantee that you dealt with every case.

The match Expression

The match expression compares a value against a series of patterns. Each pattern is called an arm, and Rust guarantees that all possible values are covered:

fn main() {
    let number = 7;

    match number {
        1 => println!("One"),
        2 => println!("Two"),
        3 => println!("Three"),
        4..=6 => println!("Between four and six"),
        7 | 8 => println!("Seven or eight"),
        9..=20 => println!("Between nine and twenty"),
        _ => println!("Something else"),
    }
}

The _ pattern is a wildcard that matches anything. It is commonly used as the last arm to handle all remaining cases. The | operator lets you combine multiple patterns in a single arm, and ..= creates inclusive ranges.

match Returns a Value

Since match is an expression in Rust, it produces a value. This means you can assign the result of a match to a variable:

fn main() {
    let coin = "quarter";

    let value_in_cents = match coin {
        "penny" => 1,
        "nickel" => 5,
        "dime" => 10,
        "quarter" => 25,
        _ => {
            println!("Unknown coin: {}", coin);
            0
        }
    };

    println!("A {} is worth {} cents", coin, value_in_cents);
}

Notice that each arm must return the same type. If an arm has multiple statements, wrap them in braces and ensure the last expression is the return value.

Matching Enums

Pattern matching truly shines when working with enums. Because match is exhaustive, the compiler forces you to handle every variant, which prevents bugs caused by forgotten cases:

enum HttpStatus {
    Ok,
    NotFound,
    InternalError,
    Redirect(String),
    Custom(u16, String),
}

fn describe_status(status: HttpStatus) -> String {
    match status {
        HttpStatus::Ok => String::from("200 - Everything is fine"),
        HttpStatus::NotFound => String::from("404 - Resource not found"),
        HttpStatus::InternalError => String::from("500 - Server error"),
        HttpStatus::Redirect(url) => format!("301 - Redirecting to {}", url),
        HttpStatus::Custom(code, msg) => format!("{} - {}", code, msg),
    }
}

fn main() {
    let statuses = vec![
        HttpStatus::Ok,
        HttpStatus::NotFound,
        HttpStatus::Redirect(String::from("https://example.com")),
        HttpStatus::Custom(418, String::from("I'm a teapot")),
    ];

    for status in statuses {
        println!("{}", describe_status(status));
    }
}

Destructuring in Match Arms

You can destructure structs, tuples, and nested types directly inside match arms to extract the data you need. Read the comments below for the choices rather than the syntax — every arm here is a decision about how much of the work the pattern does and how much is left to a guard:

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

fn classify_point(point: &Point) -> &str {
    // Match on a TUPLE of the two fields, not on the Point itself. Building
    // (point.x, point.y) is what lets one arm talk about both coordinates at
    // once; matching Point { x, y } would bind them but could not express
    // "both zero" as a pattern the way (0, 0) does.
    match (point.x, point.y) {
        // Literal patterns first, because they are the narrowest thing here.
        // (0, 0) is a pattern, not a guard, so the exhaustiveness checker can
        // still see it — that is worth preferring wherever a literal will do.
        (0, 0) => "at the origin",
        // From here the arms need arithmetic, which no pattern can express, so
        // each one pairs a pattern with a guard. Note the pattern still does
        // half the work: (x, 0) has already pinned y to zero before the guard
        // is consulted, so the guard only has to ask about x.
        (x, 0) if x > 0 => "on the positive x-axis",
        (0, y) if y > 0 => "on the positive y-axis",
        // These two are narrower than the catch-all but wider than the axis
        // arms above, and the order reflects that: an axis point would satisfy
        // neither of them anyway, but writing them after keeps the arms in
        // strictly narrowing order, which is the habit that survives edits.
        (x, y) if x > 0 && y > 0 => "in the first quadrant",
        (x, y) if x < 0 && y > 0 => "in the second quadrant",
        _ => "somewhere else",
    }
}

fn main() {
    let points = vec![
        Point { x: 0, y: 0 },
        Point { x: 5, y: 0 },
        Point { x: 3, y: 7 },
        Point { x: -2, y: 4 },
    ];

    for p in &points {
        println!("({}, {}) is {}", p.x, p.y, classify_point(p));
    }
}

It prints at the origin, on the positive x-axis, in the first quadrant, in the second quadrant. Now the same shape with the reasoning withheld. One arm below could swallow two of the others if it were moved up — work out which before reading on, then run it:

fn shipping(weight_g: u32, express: bool) -> &'static str {
    match (weight_g, express) {
        // One literal pattern, then guards that narrow. Predict the order
        // before reading it: which of these three could swallow the others?
        (0, _) => "nothing to ship",
        (w, true) if w <= 500 => "express small",
        (_, true) => "express large",
        (w, false) if w <= 500 => "standard small",
        _ => "standard large",
    }
}

fn main() {
    println!("{}", shipping(0, true));
    println!("{}", shipping(200, true));
    println!("{}", shipping(900, true));
    println!("{}", shipping(200, false));
    println!("{}", shipping(900, false));
}

It prints nothing to ship, express small, express large, standard small, standard large. The dangerous arm is (_, true) => "express large": it has no guard, so it matches every express parcel regardless of weight. It is written after the guarded express small arm, which is the only reason shipping(200, true) does not come back as express large.

Move it up one line and the second answer changes to express large — but this time rustc catches you, with warning: unreachable pattern and a note: multiple earlier patterns match some of the same values. That difference is the one worth filing away. The arm you moved up is unguarded, so the checker can prove from the patterns alone that nothing can reach the arm below it. Reorder two guarded arms, as the Predict block above did, and you get the same class of bug with complete silence, because the checker will not evaluate a guard to find out whether it always holds. The lint protects you exactly as far as the patterns go and no further.

The third stage is the build task at the end of this lesson, where the arms come with no comments at all and the check battery is the only thing that will tell you whether you ordered them correctly.

Match Guards

A match guard is an additional if condition on a match arm. The arm only matches if both the pattern and the guard are satisfied:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    for &num in &numbers {
        let description = match num {
            n if n % 2 == 0 && n > 5 => "large even",
            n if n % 2 == 0 => "small even",
            n if n > 5 => "large odd",
            _ => "small odd",
        };
        println!("{} is {}", num, description);
    }
}

Guards are useful when you need to test conditions that cannot be expressed purely through patterns, like arithmetic comparisons or calling functions. They also carry a hazard that the exhaustiveness checker cannot protect you from, and the program below walks straight into it. It compiles with no errors and no warnings at all. Predict all four printed lines before you run it.

Predict

This grading function compiles with zero errors and zero warnings. Predict all four printed lines before running.

fn tier(score: i32) -> &'static str {
  match score {
      n if n >= 50 => "pass",
      90..=100 => "distinction",
      n if n >= 80 => "merit",
      _ => "fail",
  }
}

fn main() {
  println!("{}", tier(95));
  println!("{}", tier(85));
  println!("{}", tier(50));
  println!("{}", tier(20));
}
Continue learning

It prints pass, pass, pass, fail. Match arms are tried strictly top to bottom, and the first match wins — there is no most-specific-wins resolution. n if n >= 50 swallows 95, 85 and 50 alike, so the distinction and merit arms below it can never run. The dangerous part is the silence: rustc's unreachable-pattern lint reasons about patterns, not about guard expressions, so it assumes a guarded arm might fail and treats everything beneath it as still reachable. A guard therefore switches off the one warning that would have caught this. The rule is to order guarded arms narrowest first — put 90..=100 and n if n >= 80 above n if n >= 50 and each score lands where it belongs.

if let for Simple Patterns

When you only care about one pattern and want to ignore the rest, if let provides a cleaner syntax than a full match:

fn main() {
    let config_value: Option<u32> = Some(3);

    // With match - verbose for a single case
    match config_value {
        Some(val) => println!("Config value: {}", val),
        None => {}
    }

    // With if let - much cleaner
    if let Some(val) = config_value {
        println!("Config value (if let): {}", val);
    }

    // if let with else
    let missing: Option<u32> = None;
    if let Some(val) = missing {
        println!("Found: {}", val);
    } else {
        println!("No value configured, using default");
    }
}

There is a second way a match can look right and behave wrong, and it comes from what a pattern does with a name. A bare name in a pattern position is not a comparison — it is a binding, and it matches anything while shadowing whatever variable of that name already existed. The program below meant to compare against a variable and accidentally rebound it instead. It compiles, and rustc does emit two warnings that are worth reading closely. Commit a hypothesis about what those warnings are telling you before you change anything:

Debug

This program should label only the 404 as the code it was watching for. It compiles and runs, but every code that is present gets that label. Read the two compiler warnings, say what the pattern is actually doing, then fix it.

fn main() {
  let expected = 404;

  let codes = vec![Some(200), Some(404), Some(500), None];
  let mut labels = Vec::new();

  for code in codes {
      let label = match code {
          Some(expected) => "the code we were watching for",
          Some(_) => "some other code",
          None => "no code at all",
      };
      labels.push(label);
  }

  assert_eq!(
      labels,
      vec![
          "some other code",
          "the code we were watching for",
          "some other code",
          "no code at all",
      ],
      "only the 404 should match the watched code, got {:?}",
      labels
  );
  println!("Labels: {:?}", labels);
  println!("Still watching for {}", expected);
}

Expected output: Labels: ["some other code", "the code we were watching for", "some other code", "no code at all"] Still watching for 404

Continue learning

The bug is that Some(expected) binds rather than compares. In a pattern, a bare name always introduces a new variable: it matches whatever is inside the Some and shadows the outer expected for that arm's body. So the arm matches every Some, which is precisely what the two warnings said — unreachable pattern on the Some(_) arm, because nothing survives an arm that matches everything, and unused variable: expected, because the body never reads what it bound. The fix is a match guard: Some(n) if n == expected. A guard is ordinary code rather than a pattern, so it can read the outer variable and do the comparison, while n does the binding. Any time you want to test against a variable's value, the comparison has to live in a guard — a pattern can only ever destructure and bind.

while let for Iterative Matching

The while let construct repeatedly matches a pattern in a loop. It keeps running as long as the pattern continues to match, which is especially useful for consuming iterators or popping from collections:

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

    // Pop elements until the stack is empty
    while let Some(top) = stack.pop() {
        println!("Popped: {}", top);
    }

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

    // while let with an iterator
    let names = vec!["Alice", "Bob", "Charlie"];
    let mut iter = names.iter();

    while let Some(name) = iter.next() {
        println!("Hello, {}!", name);
    }
}

Nested Pattern Matching

Patterns can be nested to match complex data structures. This is particularly powerful when working with enums that contain other enums or structs:

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

enum AppEvent {
    UserCommand(Command),
    SystemAlert(String),
    Tick(u64),
}

fn handle_event(event: AppEvent) {
    match event {
        AppEvent::UserCommand(Command::Move { x, y }) => {
            println!("User moved to ({}, {})", x, y);
        }
        AppEvent::UserCommand(Command::Write(text)) => {
            println!("User wrote: {}", text);
        }
        AppEvent::UserCommand(Command::Quit) => {
            println!("User quit the application");
        }
        AppEvent::SystemAlert(msg) => {
            println!("ALERT: {}", msg);
        }
        AppEvent::Tick(ms) => {
            println!("Tick at {} ms", ms);
        }
    }
}

fn main() {
    let events = vec![
        AppEvent::UserCommand(Command::Move { x: 10, y: 20 }),
        AppEvent::SystemAlert(String::from("Low memory")),
        AppEvent::UserCommand(Command::Write(String::from("Hello!"))),
        AppEvent::Tick(1500),
        AppEvent::UserCommand(Command::Quit),
    ];

    for event in events {
        handle_event(event);
    }
}

Every match in this lesson has been reading an enum, and that is not a coincidence — patterns and enums are two halves of one design. Close the page and answer this from memory:

Recall

Without scrolling up: in Structs & Enums you learned that an enum variant can carry data — Message::Write(String) holds a String, Message::Move { x, y } holds two named fields — and that adding a _ arm makes a match compile no matter which variants exist. Why does a match over a data-carrying enum need patterns at all, rather than a field access like msg.text?

Continue learning

To say it plainly: an enum value is only ever one variant at a time, so there is no msg.text to reach for — a Quit has no text. A pattern does two jobs in one step, establishing which variant is present and then binding that variant's payload, which makes reading a field from the wrong variant unrepresentable rather than merely discouraged. And naming every variant instead of writing _ turns the enum into a checklist the compiler re-runs on every build: add a variant later and every exhaustive match stops compiling until it is handled. That pairing is why Rust models states as enums rather than as a struct full of optional fields.

Try It Yourself

Reading about patterns is not the same as reaching for the right one under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out over one sensor-reading enum — one is a plain exhaustive match, one needs match guards ordered correctly, and one wants if let rather than a full match. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

The second function is where the lesson bites. Its boundary checks are deliberately adversarial — 0.0 must come back "normal" rather than "freezing", and 30.0 must be "hot" rather than "normal" — so a guard ordered widest-first will pass some checks and fail others. That is the Predict block's rule cashed in. Note too that matching on a &Reading binds the payloads by reference, so an f64 payload arrives as an &f64 and comparisons need a *.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide which pattern-matching tool that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// A tiny sensor router. Every function below is one match.

enum Reading {
  Temperature(f64),
  Humidity(u32),
  Offline,
}

// TODO 1: describe a reading in words. The match skeleton names EVERY
//   variant and has no '_' arm - fill in what each one produces.
//     Temperature(t) -> format!("{:.1} C", t)   e.g. "21.5 C"
//     Humidity(h)    -> format!("{}%", h)       e.g. "60%"
//     Offline        -> String::from("offline")
fn describe(reading: &Reading) -> String {
  match reading {
      Reading::Temperature(celsius) => {
          let _ = celsius;
          String::new()
      }
      Reading::Humidity(percent) => {
          let _ = percent;
          String::new()
      }
      Reading::Offline => String::new(),
  }
}

// TODO 2: classify a temperature reading using MATCH GUARDS. Anything that
//   is not a Temperature is "n/a".
//     below 0            -> "freezing"
//     0 up to but not 30 -> "normal"
//     30 and above       -> "hot"
//   Order the guarded arms NARROWEST FIRST: the first arm that matches wins,
//   so a wide guard written first will swallow the narrow cases below it.
//   Matching on &Reading binds the payload by reference, so compare with *c.
fn severity(reading: &Reading) -> &'static str {
  let _ = reading;
  ""
}

// TODO 3: pull the temperature out of a reading, or None if it is not one.
//   Use 'if let' rather than a full match - you only care about one variant.
//   temperature_of(&Reading::Temperature(21.5)) -> Some(21.5)
//   temperature_of(&Reading::Offline) -> None
fn temperature_of(reading: &Reading) -> Option<f64> {
  let _ = reading;
  None
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  assert_eq!(describe(&Reading::Temperature(21.5)), "21.5 C", "describe should format a temperature to one decimal place");
  assert_eq!(describe(&Reading::Humidity(60)), "60%", "describe should suffix a humidity with a percent sign");
  assert_eq!(describe(&Reading::Offline), "offline", "describe should name the Offline variant");

  assert_eq!(severity(&Reading::Temperature(-4.0)), "freezing", "below zero is freezing");
  assert_eq!(severity(&Reading::Temperature(0.0)), "normal", "exactly zero is already normal, not freezing");
  assert_eq!(severity(&Reading::Temperature(29.9)), "normal", "29.9 is still normal");
  assert_eq!(severity(&Reading::Temperature(30.0)), "hot", "exactly 30 is hot");
  assert_eq!(severity(&Reading::Humidity(60)), "n/a", "a humidity reading has no temperature severity");
  assert_eq!(severity(&Reading::Offline), "n/a", "an offline sensor has no temperature severity");

  assert_eq!(temperature_of(&Reading::Temperature(21.5)), Some(21.5), "temperature_of should unwrap a Temperature");
  assert_eq!(temperature_of(&Reading::Humidity(60)), None, "a humidity reading is not a temperature");
  assert_eq!(temperature_of(&Reading::Offline), None, "an offline sensor is not a temperature");

  println!("All checks passed.");
  let feed = [Reading::Temperature(31.2), Reading::Humidity(48), Reading::Offline];
  for reading in &feed {
      println!("{} [{}]", describe(reading), severity(reading));
  }
}

Expected output: All checks passed. 31.2 C [hot] 48% [n/a] offline [n/a]

Continue learning

Once it passes, try two variations and predict each before running:

  1. Order the guards widest-first. In severity, move the if *c < 30.0 arm above the if *c < 0.0 arm. Predict which check fails before running. It compiles with no warning at all and the very first severity check fails with left: "normal", right: "freezing" — -4.0 satisfies < 30.0, so the wider arm now catches it and the freezing arm below is dead code. This is the Predict block's trap reproduced in your own function, and the silence is the point: the guard is what stops the unreachable-pattern lint from firing.
  2. Replace the temperature arms with a catch-all. In severity, delete the unguarded Reading::Temperature(_) => "hot" arm and let the trailing _ => "n/a" handle what is left. Predict which check fails before running. The severity(&Reading::Temperature(30.0)) check fails with left: "n/a", right: "hot": 30.0 satisfies neither guard, so it falls through to the catch-all and a hot sensor is reported as having no reading. A _ arm will absorb anything your guards happen to miss, which is why guarded matches want an explicit unguarded arm for the same variant rather than a catch-all.

Reading the Unreachable-Pattern Warning

The lint that watches your arm order is the one diagnostic in this lesson you will meet most often, and it repays reading properly rather than glancing at. Here it is in full, from the shipping function above with its unguarded arm moved up one line:

warning: unreachable pattern
 --> /tmp/main.rs:5:9
  |
5 |         (w, true) if w <= 500 => "express small",
  |         ^^^^^^^^^ no value can reach this
  |
note: multiple earlier patterns match some of the same values
 --> /tmp/main.rs:5:9
  |
3 |         (0, _) => "nothing to ship",
  |         ------ matches some of the same values
4 |         (_, true) => "express large",
  |         --------- matches some of the same values
5 |         (w, true) if w <= 500 => "express small",
  |         ^^^^^^^^^ collectively making this unreachable
  = note: `#[warn(unreachable_patterns)]` (part of `#[warn(unused)]`) on by default

Four parts, each with a job. The --> line says where, and note what it points at: line 5, the arm that is now dead — not line 4, the arm you actually moved. The diagnostic names the victim, not the culprit. The ^^^^^^^^^ span says what, underlining the pattern that can never match, and the phrase no value can reach this is the claim being made. The note: block is the constraint, and it is the part that names the culprits: two earlier patterns are listed with matches some of the same values, and the word collectively is doing real work — neither arm alone shadows line 5, but between them they cover it. The trailing = note: gives the lint name, unreachable_patterns, which is what you would search for or allow.

The habit to build: when this warning fires, read upward from the underlined arm through the arms the note lists, and ask which of them you would have to narrow to give the dead arm something to catch. And keep the limit from the section above firmly in mind — this warning is the compiler's help exactly as far as the patterns go. Add a guard to the shadowing arm and the same shadowing becomes invisible, because the checker will not evaluate if w <= 500 to discover that it always holds. Silence from this lint is not evidence that your arms are ordered correctly; it is only evidence that no pattern shadows another.

Put the Order Back

Here is the enum the next block's four arms match on, and the harness they run under:

enum Reading {
    Temperature(f64),
    Humidity(u32),
    Offline,
}

fn severity(reading: &Reading) -> &'static str {
    match reading {
        // the four arms go here
    }
}

Arrange the code

These four arms classify a sensor reading as freezing, normal, hot, or not applicable. Shuffled, they still compile in any order — every arrangement builds and runs. Put them in the order that answers correctly for a feed of -4.0, 0.0, 29.9, 30.0, a humidity and an offline sensor, which should read: freezing normal normal hot n/a n/a. Then answer what the block is really asking: what makes this ordering the only correct one when the compiler accepts all twenty-four, and which single arrangement would rustc actually warn you about?

  1. Reading::Temperature(c) if *c < 30.0 => "normal",
  2. _ => "n/a",
  3. Reading::Temperature(_) => "hot",
  4. Reading::Temperature(c) if *c < 0.0 => "freezing",
Continue learning

Carrying the Idea Across

Transfer

Rust's match will not compile unless the arms account for every possible value, so adding a variant to an enum breaks every match that has not been updated. TypeScript reaches the same goal with a discriminated union and a switch, but by a different route. Which statement names what genuinely transfers between the two, rather than a surface resemblance?

Continue learning

Key Takeaways

  • match is exhaustive -- the compiler ensures you handle all possible cases
  • Patterns can include literals, ranges (..=), wildcards (_), and alternatives (|)
  • Match guards (if conditions) add extra flexibility to pattern arms
  • Destructuring extracts values from enums, structs, and tuples inside patterns
  • if let simplifies code when you only care about one pattern
  • while let repeats as long as a pattern matches, great for consuming iterators and stacks
  • Patterns can be nested to match deeply structured data

Pro Tip: If you find yourself writing a match with many arms where most do the same thing, consider using if let for the one case you care about, or restructure your code with methods on the enum to encapsulate the matching logic.

Next Steps

Now that you can destructure data with patterns, we'll go deeper into borrowing — the rules that govern how references let different parts of your program share data safely without copying it.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.