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.
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:
struct Point {
x: i32,
y: i32,
}
fn classify_point(point: &Point) -> &str {
match (point.x, point.y) {
(0, 0) => "at the origin",
(x, 0) if x > 0 => "on the positive x-axis",
(0, y) if y > 0 => "on the positive y-axis",
(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));
}
}
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));
}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
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?
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]
Once it passes, try two variations and predict each before running:
- Order the guards widest-first. In
severity, move theif *c < 30.0arm above theif *c < 0.0arm. Predict which check fails before running. It compiles with no warning at all and the very first severity check fails withleft: "normal", right: "freezing"—-4.0satisfies< 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. - Replace the temperature arms with a catch-all. In
severity, delete the unguardedReading::Temperature(_) => "hot"arm and let the trailing_ => "n/a"handle what is left. Predict which check fails before running. Theseverity(&Reading::Temperature(30.0))check fails withleft: "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.
Key Takeaways
matchis exhaustive -- the compiler ensures you handle all possible cases- Patterns can include literals, ranges (
..=), wildcards (_), and alternatives (|) - Match guards (
ifconditions) add extra flexibility to pattern arms - Destructuring extracts values from enums, structs, and tuples inside patterns
if letsimplifies code when you only care about one patternwhile letrepeats 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
matchwith many arms where most do the same thing, consider usingif letfor 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.
Next lesson
Borrowing in Depth
Deep dive into Rust borrowing and references — learn shared and mutable references, the borrow checker, and safe data access
25 min