Skip to content
Codeloom
Rust

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.

·10 min read · By Codeloom
Intermediate 13 min read

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.