Pattern Matching Fundamentals
Pattern matching is how Rust programs inspect a value’s shape and pull it apart in one step. Instead of writing a chain of if checks and manual field access, you describe the shapes you expect — a specific number, a range, an enum variant with data inside it — and Rust runs the code for whichever shape actually matched. The core tool is the match expression, and because the compiler checks that every possible shape is handled, entire categories of bugs (the forgotten else branch, the unchecked enum variant) become compile errors instead of production incidents.
Overview: How Pattern Matching Works
Think of a value arriving at a match expression the way a piece of mail arrives at a sorting office. Each arm of the match is a labeled slot: “letters addressed to apartment 3”, “anything under 10 grams”, “packages from this specific sender”. The sorter checks the item against each slot’s label, top to bottom, and drops it into the first slot whose label fits. A match expression does exactly this at compile time: it compares a value against a list of patterns, in order, and executes the first arm whose pattern matches.
What makes Rust’s version different from a C-style switch is that the compiler builds a proof that every possible value has a slot. If you match on an enum with four variants and only write arms for three, the code does not compile — the compiler reports exactly which variant you missed. This property is called exhaustiveness checking, and it is the reason Rust programmers can add a new variant to an enum and let the compiler point at every single place in the codebase that now needs updating, rather than discovering the gap at runtime.
Patterns are not limited to single values. A pattern can destructure a tuple into its parts, pull named fields out of a struct, dig into one variant of an enum while ignoring the others, match against an inclusive range of numbers, or combine several patterns with a boolean guard. The same pattern syntax also appears outside of match: in let bindings, in function parameters, and in the shorthand forms if let and while let for when you only care about one shape and want to ignore the rest.
One more idea is central to using match correctly: ownership. When you match on a value directly (for example match some_string { ... }), the arms that bind sub-values take ownership of them, and the original variable can no longer be used afterward. When you match on a reference (match &some_string { ... }), the arms only borrow, and the original stays usable. Keeping this distinction in mind up front will save you from a very common class of compiler error, covered later in Common Mistakes.
Syntax
The general shape of a match expression looks like this:
match VALUE {
PATTERN1 => EXPRESSION1,
PATTERN2 => EXPRESSION2,
PATTERN3 if GUARD_CONDITION => EXPRESSION3,
_ => DEFAULT_EXPRESSION,
}
- VALUE — the expression being matched. It can be a variable, a reference, or any expression that produces a value.
- PATTERN — a shape to compare the value against. Patterns are tried from top to bottom, and the first one that matches wins.
- => EXPRESSION — the code to run if that pattern matches. Every arm’s expression must produce the same type, since the whole
matchis itself an expression with one resulting type. - if GUARD_CONDITION — an optional boolean check added after a pattern; the arm only matches if both the pattern matches and the guard is true.
- _ — the wildcard pattern, which matches anything and binds nothing. It is commonly used as a catch-all final arm.
Common pattern forms you will see throughout Rust code:
| Pattern kind | Example | Meaning |
|---|---|---|
| Literal | 5 => ... |
Matches only the exact value 5 |
| Variable binding | n => ... |
Matches anything and binds it to the name n |
| Wildcard | _ => ... |
Matches anything, value is discarded |
| Range | 1..=9 => ... |
Matches any value from 1 to 9 inclusive |
| Or-pattern | 1 | 2 | 3 => ... |
Matches any one of several patterns |
| Guard | n if n > 0 => ... |
Adds an extra boolean condition |
| @ binding | n @ 1..=9 => ... |
Binds the value while also testing a sub-pattern |
| Destructure | Point { x, y } => ... |
Pulls named fields out of a struct or enum variant |
Examples
Example 1: Matching a simple value
fn main() {
let number = 3;
match number {
1 => println!("One"),
2 => println!("Two"),
3 => println!("Three"),
_ => println!("Something else"),
}
}
Output:
Three
The compiler compares number against each literal pattern in order. It matches 3 and runs that arm. The final _ arm is required — without it, the compiler would reject the match as non-exhaustive, since an i32 can hold values other than 1, 2, and 3.
Example 2: Destructuring an enum with data
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn process(msg: Message) {
match msg {
Message::Quit => println!("Quit received"),
Message::Move { x, y } => println!("Move to ({}, {})", x, y),
Message::Write(text) => println!("Write: {}", text),
Message::ChangeColor(r, g, b) => println!("Change color to ({}, {}, {})", r, g, b),
}
}
fn main() {
let messages = vec![
Message::Move { x: 10, y: 20 },
Message::Write(String::from("hello")),
Message::ChangeColor(255, 0, 0),
Message::Quit,
];
for msg in messages {
process(msg);
}
}
Output:
Move to (10, 20)
Write: hello
Change color to (255, 0, 0)
Quit received
This is where pattern matching earns its keep. Each variant of Message carries different data — no data, two named fields, one String, or three integers — and a single match both identifies which variant it is and unpacks the data in the same step, binding x, y, text, r, g, and b directly from the pattern. Note that process takes msg by value, so matching moves it; each Message is consumed exactly once.
Example 3: Ranges, guards, and @ bindings
fn main() {
let numbers = [3, 7, 12, -4, 0, 55];
for n in numbers {
match n {
0 => println!("{n}: zero"),
n if n < 0 => println!("{n}: negative"),
small @ 1..=9 => println!("{small}: small positive (single digit)"),
n => println!("{n}: large positive"),
}
}
}
Output:
3: small positive (single digit)
7: small positive (single digit)
12: large positive
-4: negative
0: zero
55: large positive
Each arm layers on a different pattern feature. The literal 0 catches exactly zero. The guard n if n < 0 matches any value, but only takes the arm if the extra boolean condition holds. The @ binding small @ 1..=9 both tests that the value falls in the range and gives it the name small for use inside the arm. The final bare n is an irrefutable catch-all that also renames the value, satisfying exhaustiveness for every remaining case (values 10 and above).
How It Works Step by Step
When the compiler sees a match, it does two separate jobs. First, at compile time, it performs exhaustiveness analysis: it looks at the type being matched (how many enum variants it has, what a range pattern’s bounds cover, whether a final wildcard or irrefutable binding exists) and proves that every possible value of that type lands in at least one arm. If it cannot prove this, compilation fails with a “non-exhaustive patterns” error naming the missing case. This check is what makes match safer than an if/else chain — there is no way to silently fall through.
Second, the compiler lowers the arms into an efficient decision structure (conceptually similar to a series of tests and jumps) rather than literally testing every pattern one by one at runtime for simple cases like matching an integer or an enum tag — but the semantics you should reason about are exactly “patterns are tried top to bottom, first match wins.” This is why order matters: a broad pattern placed above a narrow one will shadow it. In Example 3, if the wildcard-like final n arm were placed first, it would match everything and the more specific arms below it would never run — the compiler even warns about arms that can never be reached.
Ownership is resolved as part of matching too. When you match a value directly, each arm’s bindings take ownership of the parts they bind (unless the type is Copy, in which case it’s duplicated instead of moved). When you match a reference, bindings become references into the original data, and the original value remains valid and usable after the match completes.
Common Mistakes
Mistake 1: Forgetting a variant (non-exhaustive match)
Adding a new enum variant later and forgetting to update every match on it is one of the most common Rust compile errors — and one of the most useful, since it means the compiler finds the gap for you.
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: Direction) -> &'static str {
match dir {
Direction::North => "north",
Direction::South => "south",
Direction::East => "east",
}
}
fn main() {}
This fails to compile with error[E0004]: non-exhaustive patterns: `Direction::West` not covered. The fix is to add the missing arm (or, only if it’s genuinely intentional, a _ catch-all):
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: Direction) -> &'static str {
match dir {
Direction::North => "north",
Direction::South => "south",
Direction::East => "east",
Direction::West => "west",
}
}
fn main() {
let dir = Direction::West;
println!("{}", describe(dir));
}
Output:
west
Mistake 2: Using a value after matching moved it
fn main() {
let name: Option<String> = Some(String::from("Ferris"));
match name {
Some(n) => println!("Hello, {}", n),
None => println!("No name"),
}
println!("{:?}", name);
}
This fails with error[E0382]: borrow of moved value: `name`. Matching name directly binds Some(n) by value, which moves the String out of the Option and consumes name in the process — there is nothing left to print afterward. The fix is to match on a reference so the arms only borrow:
fn main() {
let name: Option<String> = Some(String::from("Ferris"));
match &name {
Some(n) => println!("Hello, {}", n),
None => println!("No name"),
}
println!("{:?}", name);
}
Output:
Hello, Ferris
Some("Ferris")
Matching &name makes n a &String reference instead of an owned String, so name itself is only borrowed for the duration of the match and remains usable afterward.
Mistake 3: Using a refutable pattern in a plain let
fn main() {
let maybe_value: Option<i32> = Some(5);
let Some(x) = maybe_value;
println!("{}", x);
}
This fails with error[E0005]: refutable pattern in local binding: `None` not covered. A plain let requires an irrefutable pattern — one that is guaranteed to match, like a bare variable name or a tuple of variables. Some(x) is refutable because the value could have been None instead. Use if let (or a full match) whenever the pattern might not match:
fn main() {
let maybe_value: Option<i32> = Some(5);
if let Some(x) = maybe_value {
println!("{}", x);
} else {
println!("No value");
}
}
Output:
5
Best Practices
- Prefer
matchwhen you need to handle every case of a type; preferif letwhen you only care about one pattern and want to ignore the rest. - Let the compiler’s exhaustiveness checking work for you — avoid a blanket
_arm on enums you control, so that adding a new variant later forces you to revisit every relevantmatch. - Match on a reference (
match &value) whenever you only need to read the data, so the original value stays usable afterward. - Keep guard conditions short and readable; if a guard grows complex, extract it into a well-named helper function.
- Use
@bindings when an arm needs both the matched value itself and a range or sub-pattern check on it. - Order arms from most specific to most general, and put broad catch-alls (
_or a bare variable) last, since earlier arms shadow later ones. - Reach for
if let/while letfor the common “I only care aboutSome” or “keep going while there’s a next item” shapes instead of a fullmatchwith an empty_arm.
Practice Exercises
- Write a function
describe(n: Option<i32>) -> Stringthat returns"empty"forNone,"small"for values 0 through 9 inclusive, and"big"for anything else, using a singlematch. - Define an enum
Shapewith variantsCircle(f64)(radius) andRectangle(f64, f64)(width, height). Write a function that matches on aShapeand prints its area (use3.14159for pi). - Given an array of integers, use a
matchwith a guard to print whether each number is"even"or"odd", but print"zero"as a special case for 0.
Summary
matchcompares a value against a list of patterns, top to bottom, and runs the first arm that matches.- Rust checks exhaustiveness at compile time, so every possible value of the matched type must be handled — this catches forgotten enum variants before they become bugs.
- Patterns can be literals, ranges, wildcards, or-patterns, guards,
@bindings, or destructured tuples/structs/enum variants. - Matching a value directly can move ownership out of it; matching a reference only borrows, leaving the original usable afterward.
if letandwhile letare shorthand for amatchwith one pattern of interest and an ignored fallback.- A plain
letrequires an irrefutable pattern; useif letormatchfor patterns that might not match.
