Rust Pattern Matching: Advanced Techniques and Guards
Go beyond basic match with pattern guards, bindings, nested patterns, or-patterns, and real-world refactoring strategies in Rust.
What you'll learn
- ✓Use match guards for conditional matching
- ✓Bind values with @ patterns
- ✓Destructure nested structs and enums in one pattern
- ✓Apply patterns in let, if-let, while-let, and function parameters
Prerequisites
- •Rust basics — see /blog/rust-variables-and-types
- •Enums and basic match — see /blog/rust-structs-and-enums
If you have used match to handle Option and Result, you already know the basics of Rust pattern matching. But patterns in Rust go far deeper. You can match on nested structures, add conditions with guards, bind intermediate values, and use patterns in places you might not expect.
This guide covers the techniques that make pattern matching a powerful tool for writing clear, correct Rust code.
Quick Refresher
A basic match on an enum:
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: Direction) -> &'static str {
match dir {
Direction::North => "heading north",
Direction::South => "heading south",
Direction::East => "heading east",
Direction::West => "heading west",
}
}
Rust’s match is exhaustive — every possible value must be covered. The compiler enforces this, which eliminates an entire class of bugs.
Match Guards
A match guard is a boolean condition added to a pattern arm with if:
fn classify_number(n: i32) -> &'static str {
match n {
0 => "zero",
n if n > 0 && n <= 10 => "small positive",
n if n > 10 && n <= 100 => "medium positive",
n if n > 100 => "large positive",
_ => "negative",
}
}
fn main() {
println!("{}", classify_number(5)); // "small positive"
println!("{}", classify_number(50)); // "medium positive"
println!("{}", classify_number(-3)); // "negative"
}
Guards are checked after the pattern matches structurally. If the guard fails, matching continues to the next arm. This means you can have multiple arms with the same structural pattern but different guards.
Guards with Enums
Guards become especially useful when matching enums that carry data:
#[derive(Debug)]
enum HttpResponse {
Success { status: u16, body: String },
Error { status: u16, message: String },
Redirect { status: u16, location: String },
}
fn handle_response(resp: &HttpResponse) {
match resp {
HttpResponse::Success { status, body } if body.is_empty() => {
println!("Empty success response ({})", status);
}
HttpResponse::Success { status, body } => {
println!("Success ({}): {} bytes", status, body.len());
}
HttpResponse::Error { status, message } if *status == 404 => {
println!("Not found: {}", message);
}
HttpResponse::Error { status, message } if *status >= 500 => {
println!("Server error ({}): {}", status, message);
}
HttpResponse::Error { status, message } => {
println!("Client error ({}): {}", status, message);
}
HttpResponse::Redirect { location, .. } => {
println!("Redirecting to: {}", location);
}
}
}
Note the order: more specific patterns (with guards) come before general patterns. The first matching arm wins.
The @ Binding Operator
The @ operator lets you bind a value to a name while also testing it against a pattern:
fn describe_age(age: u32) -> String {
match age {
0 => "newborn".to_string(),
a @ 1..=12 => format!("child (age {})", a),
a @ 13..=17 => format!("teenager (age {})", a),
a @ 18..=64 => format!("adult (age {})", a),
a @ 65.. => format!("senior (age {})", a),
}
}
fn main() {
println!("{}", describe_age(8)); // "child (age 8)"
println!("{}", describe_age(15)); // "teenager (age 15)"
println!("{}", describe_age(70)); // "senior (age 70)"
}
Without @, you could match the range but would not have the specific value available. Without ranges, you could bind the value but could not restrict the range in the pattern.
@ with Nested Patterns
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
fn classify_point(p: &Point) -> &'static str {
match p {
Point { x: 0.0, y: 0.0 } => "origin",
Point { x, y: 0.0 } if *x > 0.0 => "positive x-axis",
Point { x: 0.0, y } if *y > 0.0 => "positive y-axis",
_ => "somewhere else",
}
}
#[derive(Debug)]
enum Command {
Move { x: i32, y: i32 },
Write(String),
Color(u8, u8, u8),
}
fn process(cmd: &Command) {
match cmd {
Command::Move { x: dx @ -10..=10, y: dy @ -10..=10 } => {
println!("Small move: ({}, {})", dx, dy);
}
Command::Move { x, y } => {
println!("Large move: ({}, {})", x, y);
}
Command::Color(r @ 200..=255, g @ 200..=255, b @ 200..=255) => {
println!("Bright color: ({}, {}, {})", r, g, b);
}
Command::Color(r, g, b) => {
println!("Color: ({}, {}, {})", r, g, b);
}
Command::Write(text) => {
println!("Write: {}", text);
}
}
}
Or-Patterns
Use | to match multiple patterns in one arm:
fn is_vowel(c: char) -> bool {
matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'A' | 'E' | 'I' | 'O' | 'U')
}
fn http_status_category(status: u16) -> &'static str {
match status {
200 | 201 | 204 => "success",
301 | 302 | 307 | 308 => "redirect",
400 | 401 | 403 | 404 | 422 => "client error",
500 | 502 | 503 | 504 => "server error",
100..=199 => "informational",
200..=299 => "other success",
300..=399 => "other redirect",
400..=499 => "other client error",
500..=599 => "other server error",
_ => "unknown",
}
}
Or-patterns work in combination with destructuring:
enum Shape {
Circle { radius: f64 },
Square { side: f64 },
Rectangle { width: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Square { side } | Shape::Rectangle { width: side, height: side } => {
// This won't actually compile because Rectangle has two fields
// but it illustrates the syntax for when it applies
side * side
}
Shape::Rectangle { width, height } => width * height,
}
}
Note: or-patterns require all alternatives to bind the same set of variables with the same types.
Nested Destructuring
Patterns can reach inside complex, nested structures in a single expression:
#[derive(Debug)]
struct Address {
city: String,
zip: String,
}
#[derive(Debug)]
struct Person {
name: String,
age: u32,
address: Option<Address>,
}
fn describe_person(person: &Person) {
match person {
Person {
name,
age: a @ 0..=17,
address: Some(Address { city, .. }),
} => {
println!("{} is a minor (age {}) from {}", name, a, city);
}
Person {
name,
address: None,
..
} => {
println!("{} has no address on file", name);
}
Person {
name,
age,
address: Some(Address { city, zip }),
} => {
println!("{} (age {}) lives in {}, {}", name, age, city, zip);
}
}
}
fn main() {
let people = vec![
Person {
name: "Alice".into(),
age: 15,
address: Some(Address { city: "Portland".into(), zip: "97201".into() }),
},
Person {
name: "Bob".into(),
age: 30,
address: None,
},
Person {
name: "Charlie".into(),
age: 45,
address: Some(Address { city: "Seattle".into(), zip: "98101".into() }),
},
];
for person in &people {
describe_person(person);
}
}
The .. syntax ignores remaining fields you do not care about. This keeps patterns focused on the fields that matter.
Patterns Beyond match
if let and let else
if let is a concise way to match a single pattern:
fn process_optional(value: Option<&str>) {
if let Some(text) = value {
println!("Got: {}", text);
}
// No else branch needed
}
let else (stabilized in Rust 1.65) handles the “match or bail” pattern:
fn parse_header(line: &str) -> Option<(&str, &str)> {
let (key, value) = line.split_once(':')?;
Some((key.trim(), value.trim()))
}
fn process_request(headers: &[String]) {
for header in headers {
let Some((key, value)) = parse_header(header) else {
eprintln!("Malformed header: {}", header);
continue;
};
println!("{}: {}", key, value);
}
}
The else branch must diverge — it must return, break, continue, or panic!.
while let
fn drain_stack(stack: &mut Vec<i32>) {
while let Some(top) = stack.pop() {
println!("Popped: {}", top);
}
println!("Stack is empty");
}
Patterns in Function Parameters
fn print_coordinates(&(x, y): &(f64, f64)) {
println!("({}, {})", x, y);
}
fn main() {
let point = (3.0, 4.0);
print_coordinates(&point);
}
Patterns in for Loops
fn main() {
let scores = vec![("Alice", 95), ("Bob", 87), ("Charlie", 92)];
for (name, score) in &scores {
println!("{}: {}", name, score);
}
// With enumerate
for (index, (name, score)) in scores.iter().enumerate() {
println!("#{}: {} scored {}", index + 1, name, score);
}
}
The matches! Macro
The matches! macro returns true if a value matches a pattern. It is useful in conditions and closures:
#[derive(Debug)]
enum Token {
Number(f64),
Plus,
Minus,
Multiply,
Divide,
LeftParen,
RightParen,
}
fn is_operator(token: &Token) -> bool {
matches!(token, Token::Plus | Token::Minus | Token::Multiply | Token::Divide)
}
fn main() {
let tokens = vec![
Token::Number(3.0),
Token::Plus,
Token::Number(4.0),
Token::Multiply,
Token::Number(2.0),
];
let operator_count = tokens.iter().filter(|t| is_operator(t)).count();
println!("Operators: {}", operator_count); // 2
// matches! with a guard
let has_large_number = tokens.iter().any(|t| matches!(t, Token::Number(n) if *n > 100.0));
println!("Has large number: {}", has_large_number); // false
}
Refactoring with Patterns
Patterns shine when refactoring conditional logic. Compare these two versions:
// Before: nested ifs
fn process_event_v1(event: &Event) -> Action {
if let Event::Click { x, y, button } = event {
if *button == Button::Left {
if *x > 100 && *y > 100 {
return Action::Select;
} else {
return Action::Move;
}
} else if *button == Button::Right {
return Action::ContextMenu;
}
}
Action::Ignore
}
// After: flat match
fn process_event_v2(event: &Event) -> Action {
match event {
Event::Click { button: Button::Left, x, y } if *x > 100 && *y > 100 => Action::Select,
Event::Click { button: Button::Left, .. } => Action::Move,
Event::Click { button: Button::Right, .. } => Action::ContextMenu,
_ => Action::Ignore,
}
}
The match version is flat, exhaustive, and easier to extend. Each arm is independent and self-describing.
Exhaustiveness and the Wildcard
The compiler’s exhaustiveness checking catches missed cases when you add new enum variants:
enum LogLevel {
Debug,
Info,
Warn,
Error,
// Adding Fatal here would cause a compile error in any
// match that does not have a wildcard
}
fn log_prefix(level: &LogLevel) -> &'static str {
match level {
LogLevel::Debug => "[DEBUG]",
LogLevel::Info => "[INFO]",
LogLevel::Warn => "[WARN]",
LogLevel::Error => "[ERROR]",
}
}
If you add LogLevel::Fatal, the compiler points you to every match that needs updating. This is why wildcard _ arms should be used thoughtfully — they suppress this helpful error.
Use _ when you genuinely want to handle all remaining cases the same way. Avoid it when you want the compiler to remind you about new variants.
Wrapping Up
Rust’s pattern matching goes far beyond simple switch statements. The key techniques covered here are:
- Match guards add conditions to structural matches with
if. - @ bindings let you name a value while testing it against a sub-pattern.
- Or-patterns combine alternatives with
|. - Nested destructuring reaches into complex types in a single expression.
- let else handles the “match or bail” pattern cleanly.
- matches! turns pattern matching into a boolean for use in conditions.
The best pattern matching code reads like a specification of your program’s behavior — each arm is a clear, complete rule. Lean on the compiler’s exhaustiveness checking, keep arms flat, and let patterns replace nested conditionals wherever they can.
Related articles
- Rust Rust Pattern Matching Tutorial
Use Rust's match expression, if-let, while-let, and pattern syntax to destructure data safely with exhaustive compile-time checking.
- Rust Error Handling with Result and Option in Rust
Handle errors idiomatically in Rust — Result, Option, the ? operator, custom error types, and the thiserror/anyhow ecosystem.
- Rust Pattern Matching in Rust
Master Rust's pattern matching — match expressions, if let, while let, destructuring, guards, and patterns in function parameters.
- Rust Traits and Generics in Rust
Master Rust traits and generics — defining traits, trait bounds, default methods, trait objects, and generic data structures.