Pattern Matching (match, if let, while let)
Reviewed & published by Brayan K
Pattern matching is Rust's way of testing a value's shape and pulling data out of it at the same time — the match expression checks a value against patterns and the compiler insists every case is covered.
Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll learn exhaustive match, ranges and or-patterns, binding with @, guards, destructuring enums and tuples, and the if let / let else / while let shorthands.
What You'll Learn in This Lesson
- Exhaustive match and the _ wildcard arm
- Ranges (..=) and or-patterns (|)
- Binding a tested value with @ and adding guards
- Destructuring tuples, structs, and enum variants
- if let for a single case and let else for early returns
- while let to loop while a pattern keeps matching
1️⃣ match: Arms, Ranges, Or-Patterns, Bindings
A match tries each arm top to bottom and runs the first that fits. Arms can be exact values, an or-pattern with |, an inclusive range with ..=, or the wildcard _. The @ operator lets you bind the matched value while testing it. Crucially, a match must be exhaustive.
fn describe(n: i32) -> &'static str {
// match compares a value against PATTERNS, top to bottom.
match n {
0 => "zero",
1 | 2 | 3 => "small", // | is an OR-pattern
4..=9 => "medium", // ..= is an inclusive RANGE
_ => "large", // _ is the catch-all wildcard
}
}
fn main() {
// match must be EXHAUSTIVE — every possible value handled.
for n in [0, 2, 7, 42] {
println!("{} -> {}", n, describe(n));
}
// Binding with @ captures the matched value while testing it.
let code = 5;
match code {
x @ 1..=5 => println!("low code {}", x),
x => println!("other code {}", x),
}
}Because match is an expression, each arm yields a value you can return or bind — here every arm produces a string. Drop the final _ arm and the compiler refuses to build until every i32 is covered.
2️⃣ Destructuring & Guards
Patterns can reach inside structured data. You can destructure an enum variant to pull out its fields, a tuple into named pieces, or a struct's fields by name. A guard — an if after the pattern — adds a condition the pattern syntax alone can't express.
// An enum models a value that is one of several variants.
enum Shape {
Circle(f64), // tuple-like variant
Rect { w: f64, h: f64 }, // struct-like variant
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rect { w, h } => w * h,
}
}
fn main() {
let shapes = [Shape::Circle(2.0), Shape::Rect { w: 3.0, h: 4.0 }];
for s in &shapes {
println!("area = {:.2}", area(s));
}
// Destructure a tuple AND add a guard (an extra if condition).
let point = (3, -2);
match point {
(0, 0) => println!("origin"),
(x, y) if x == -y => println!("on anti-diagonal"),
(x, y) => println!("point ({}, {})", x, y),
}
}3️⃣ if let, let else & while let
When you care about just one pattern, a full match is overkill. if let runs a block only on a match. let ... else binds variables on success and forces an early exit on failure. while let repeats as long as the pattern keeps matching.
fn main() {
// if let runs a block only when ONE pattern matches — no need for full match.
let maybe: Option<i32> = Some(7);
if let Some(n) = maybe {
println!("got {}", n);
}
// let ... else binds on success or DIVERGES (returns/panics) on failure.
let text = "42";
let Ok(parsed) = text.parse::<i32>() else {
println!("not a number");
return;
};
println!("parsed = {}", parsed);
// while let loops as long as the pattern keeps matching.
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("popped {}", top);
}
}Your turn. Fill in the blanks marked ___, then run it.
// 🎯 YOUR TURN — fill in the blanks, then run it.
fn classify(n: i32) -> &'static str {
match n {
0 => "zero",
// 1) Match every value from 1 to 9 inclusive with a range.
1___9 => "single digit",
// 2) The catch-all wildcard for everything else.
___ => "big",
}
}
fn main() {
for n in [0, 5, 100] {
println!("{} -> {}", n, classify(n));
}
let maybe = Some(3);
// 3) Unwrap the Option only when it is Some.
if ___ Some(x) = maybe {
println!("inner = {}", x);
}
}
// ✅ Expected output:
// 0 -> zero
// 5 -> single digit
// 100 -> big
// inner = 3Mini-Challenge: An Event Handler
Model events as an enum and dispatch them with one exhaustive match, destructuring each variant. Run it with cargo run and check the output.
// 🎯 MINI-CHALLENGE: an event handler.
//
// Define an enum Event with three variants:
// Click { x: i32, y: i32 }, Key(char), Quit
// Write fn handle(e: &Event) -> String that uses ONE match to return:
// - "click at (x, y)" for a Click (destructure the struct fields)
// - "key 'c'" for a Key (bind the char)
// - "quit" for Quit
// Then loop over a few events and print each result.
//
// ✅ Example output:
// click at (10, 20)
// key 'a'
// quit
fn main() {
// define Event, handle, and the loop here
}Common Errors (and the fix)
- ❌ "non-exhaustive patterns: ... not covered" — A possible value has no arm. ✅ Add the missing case, or a _ wildcard last.
- ❌ "unreachable pattern" — A later arm can never match because an earlier one already covers it. ✅ Reorder, or remove the redundant arm.
- ❌ let else "must diverge" — The else block falls through. ✅ End it with return, break, continue, or panic!.
- ❌ Using .. instead of ..= — 1..9 excludes 9 in a range pattern. ✅ Use 1..=9 for an inclusive range.
Pro Tips
- 💡 Let exhaustiveness work for you: avoid a blanket _ on enums so adding a variant forces you to handle it.
- 💡 Reach for let else to unwrap-or-bail and keep the happy path flat and readable.
- 💡 Combine patterns: @ bindings plus guards handle cases that ranges and literals alone can't.
📋 Quick Reference — Patterns
| Pattern | Meaning |
|---|---|
| 1 | 2 | 3 | Or-pattern (any of) |
| 4..=9 | Inclusive range |
| x @ 1..=5 | Bind while testing |
| (x, y) if x > y | Destructure + guard |
| if let Some(n) = | Single-case match |
| while let Some(x) = | Loop while matching |
🎉 Lesson Complete!
- ✅ match is exhaustive; arms try top to bottom; _ catches the rest
- ✅ | or-patterns, ..= ranges, @ bindings, and guards refine arms
- ✅ Patterns destructure tuples, structs, and enum variants in one step
- ✅ if let handles one case, let else bails early, while let loops
Practice quiz
What does it mean that a match must be exhaustive?
- It must be fast
- It must have one arm
- Every possible value must be covered
- It must use a guard
Answer: Every possible value must be covered. The compiler requires every possible value to be handled.
What does the _ pattern do in a match?
- Is the catch-all wildcard
- Matches nothing
- Binds a variable
- Causes an error
Answer: Is the catch-all wildcard. _ is the wildcard arm that matches anything not yet covered.
What does the pattern 4..=9 match?
- 4 to 8
- Only 4 and 9
- 9 to 4
- 4 to 9 inclusive
Answer: 4 to 9 inclusive. ..= is an inclusive range, matching 4 through 9.
What does the | symbol mean in 1 | 2 | 3?
- Bitwise OR
- An or-pattern matching any listed value
- A guard
- A range
Answer: An or-pattern matching any listed value. | is an or-pattern: it matches any of the listed values.
What does x @ 1..=5 do?
- Tests the range and binds the value to x
- Adds x and the range
- Imports x
- Nothing
Answer: Tests the range and binds the value to x. @ binds the matched value to x while testing the pattern.
What is a guard in a match arm?
- A type annotation
- A wildcard
- An if condition after the pattern
- A loop
Answer: An if condition after the pattern. A guard is an extra if condition the pattern alone cannot express.
When is if let preferred over a full match?
- When handling many cases
- When you care about only one pattern
- When you need exhaustiveness
- Never
Answer: When you care about only one pattern. if let is the idiomatic shortcut when only one pattern matters.
What must the else block of a let...else do?
- Return Ok
- Nothing
- Bind a variable
- Diverge (return, break, continue, or panic)
Answer: Diverge (return, break, continue, or panic). The else block must diverge when the pattern does not match.
What does while let Some(x) = stack.pop() do?
- Runs once
- Loops while the pattern keeps matching
- Errors on None
- Pops nothing
Answer: Loops while the pattern keeps matching. while let repeats as long as pop() keeps returning Some.
Why does a guarded arm not count toward exhaustiveness?
- Guards are slow
- Guards are deprecated
- The compiler cannot reason about the guard condition
- It always counts
Answer: The compiler cannot reason about the guard condition. Because the compiler cannot evaluate the guard, you still need a fallback arm.
Continue this course
- Previous: Concurrency & Threads
- Next: Type Conversions (From, Into, as, TryFrom) — Cast with as, convert with From/Into, and fallibly with TryFrom
- Quick reference: Rust cheat sheet
Frequently asked questions
What does it mean that a match must be exhaustive?
Exhaustiveness means a match has to cover every possible value of the type you're matching on — the compiler checks this at build time and refuses to compile if any case is left out. For an enum, you must handle each variant; for an integer, you typically end with a _ wildcard arm to catch the rest. This is a big safety win: when you add a new enum variant later, every match that didn't account for it becomes a compile error, pointing you straight at the code that needs updating, instead of silently doing the wrong thing at runtime.
When should I use if let instead of match?
Reach for if let when you only care about a single pattern and want to ignore everything else — for example, doing something only when an Option is Some. Writing a full match with an empty _ => {} arm for that case is noisier, so if let is the idiomatic shortcut. You can add an else branch for the not-matched case. Use a full match when you need to handle several cases, when you want the compiler's exhaustiveness checking, or when each branch produces a value you'll bind to a variable.
What's the difference between let else and if let?
Both test a refutable pattern, but they're shaped for opposite goals. if let runs a block when the pattern matches and otherwise falls through, so the bound variables only exist inside that block. let else inverts this: it binds the variables in the surrounding scope when the pattern matches, and its else block must diverge — return, break, continue, or panic — when it doesn't. That makes let else perfect for the 'unwrap or bail out early' pattern, keeping the happy path flat and unindented instead of nesting it inside an if let.
What do the @, |, and ..= symbols mean in a pattern?
These are pattern operators. The pipe | is an or-pattern: 1 | 2 | 3 matches any of those values in a single arm. The ..= operator is an inclusive range: 4..=9 matches every value from 4 through 9. The @ symbol is a binding: x @ 1..=5 both tests that the value is in 1..=5 and binds it to x so you can use the actual value in the arm's body. You can also add a guard with if after the pattern for arbitrary extra conditions the pattern syntax alone can't express.