3 min read
On this page

Pattern Matching

Pattern matching is Rust's control flow superpower. The match expression is exhaustive, type-safe, and compiles to efficient code. It replaces switch statements, if-else chains, and type checks found in other languages — and does it better. Once you internalize pattern matching, you will wonder how you ever wrote code without it.

match Expressions

A match expression compares a value against a series of patterns and runs the code for the first match:

fn http_status(code: u16) -> &'static str {
    match code {
        200 => "OK",
        201 => "Created",
        301 => "Moved Permanently",
        400 => "Bad Request",
        401 => "Unauthorized",
        404 => "Not Found",
        500 => "Internal Server Error",
        _ => "Unknown",
    }
}

fn main() {
    for code in [200, 404, 500, 999] {
        println!("{}: {}", code, http_status(code));
    }
}
200: OK
404: Not Found
500: Internal Server Error
999: Unknown

_ is the wildcard pattern — it matches anything. Every match must be exhaustive: every possible value must be handled. The compiler enforces this.

Destructuring Enums

Pattern matching shines when extracting data from enums:

#[derive(Debug)]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        Shape::Triangle { base, height } => 0.5 * base * height,
    }
}

fn describe(shape: &Shape) -> String {
    match shape {
        Shape::Circle { radius } => format!("circle with radius {}", radius),
        Shape::Rectangle { width, height } => {
            format!("{}x{} rectangle", width, height)
        }
        Shape::Triangle { .. } => String::from("a triangle"),
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle { radius: 5.0 },
        Shape::Rectangle { width: 4.0, height: 6.0 },
        Shape::Triangle { base: 3.0, height: 8.0 },
    ];

    for shape in &shapes {
        println!("{}: area = {:.2}", describe(shape), area(shape));
    }
}
circle with radius 5: area = 78.54
4x6 rectangle: area = 24.00
a triangle: area = 12.00

{ .. } ignores fields you do not care about. Each arm can contain a block {} for multi-line logic.

if let & while let

When you only care about one variant, if let is cleaner than a full match:

fn main() {
    let config_value: Option<String> = Some(String::from("production"));

    // Full match — verbose for a single case:
    match &config_value {
        Some(env) => println!("Environment: {}", env),
        None => {}
    }

    // if let — same thing, less noise:
    if let Some(env) = &config_value {
        println!("Environment: {}", env);
    }

    // if let with else:
    if let Some(env) = &config_value {
        println!("Running in: {}", env);
    } else {
        println!("No environment configured");
    }
}
Environment: production
Environment: production
Running in: production

while let is useful for consuming iterators or channels:

fn main() {
    let mut stack = vec![1, 2, 3, 4, 5];

    while let Some(top) = stack.pop() {
        println!("Popped: {}", top);
    }
    println!("Stack is empty");
}
Popped: 5
Popped: 4
Popped: 3
Popped: 2
Popped: 1
Stack is empty

Destructuring Structs

Pattern matching works on structs, tuples, and nested data:

struct Point {
    x: f64,
    y: f64,
}

fn classify_point(p: &Point) -> &str {
    match (p.x == 0.0, p.y == 0.0) {
        (true, true) => "origin",
        (true, false) => "on y-axis",
        (false, true) => "on x-axis",
        (false, false) => "general point",
    }
}

fn main() {
    let points = vec![
        Point { x: 0.0, y: 0.0 },
        Point { x: 0.0, y: 5.0 },
        Point { x: 3.0, y: 0.0 },
        Point { x: 1.0, y: 2.0 },
    ];

    for p in &points {
        println!("({}, {}): {}", p.x, p.y, classify_point(p));
    }
}
(0, 0): origin
(0, 5): on y-axis
(3, 0): on x-axis
(1, 2): general point

Tuple matching is particularly useful for handling pairs of values without nested if-else chains.

Guard Clauses

Match arms can include if guards for additional conditions:

fn classify_temperature(celsius: f64) -> &'static str {
    match celsius {
        t if t < -40.0 => "extreme cold",
        t if t < 0.0 => "below freezing",
        t if t < 10.0 => "cold",
        t if t < 25.0 => "comfortable",
        t if t < 35.0 => "warm",
        t if t < 45.0 => "hot",
        _ => "extreme heat",
    }
}

fn main() {
    let temps = [-50.0, -10.0, 5.0, 22.0, 30.0, 40.0, 55.0];
    for t in temps {
        println!("{:.0}C: {}", t, classify_temperature(t));
    }
}
-50C: extreme cold
-10C: below freezing
5C: cold
22C: comfortable
30C: warm
40C: hot
55C: extreme heat

Guards are checked at runtime after the pattern matches. They do not affect exhaustiveness — the compiler may still require a _ arm.

Matching on References

When you match on a reference, the patterns bind references too:

fn process_names(names: &[String]) {
    for name in names {
        match name.as_str() {
            "admin" => println!("{}: elevated privileges", name),
            "guest" => println!("{}: read-only access", name),
            n if n.starts_with("bot_") => println!("{}: automated account", name),
            _ => println!("{}: standard user", name),
        }
    }
}

fn main() {
    let names = vec![
        String::from("alice"),
        String::from("admin"),
        String::from("bot_monitor"),
        String::from("guest"),
    ];
    process_names(&names);
}
alice: standard user
admin: elevated privileges
bot_monitor: automated account
guest: read-only access

Nested Pattern Matching

Patterns can be nested to handle complex data structures:

#[derive(Debug)]
enum Expr {
    Num(f64),
    Add(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Neg(Box<Expr>),
}

fn eval(expr: &Expr) -> f64 {
    match expr {
        Expr::Num(n) => *n,
        Expr::Add(a, b) => eval(a) + eval(b),
        Expr::Mul(a, b) => eval(a) * eval(b),
        Expr::Neg(e) => -eval(e),
    }
}

fn simplify_display(expr: &Expr) -> String {
    match expr {
        Expr::Num(n) => format!("{}", n),
        Expr::Add(a, b) => format!("({} + {})", simplify_display(a), simplify_display(b)),
        Expr::Mul(a, b) => format!("({} * {})", simplify_display(a), simplify_display(b)),
        Expr::Neg(e) => format!("-({})", simplify_display(e)),
    }
}

fn main() {
    // (2 + 3) * -(4)
    let expr = Expr::Mul(
        Box::new(Expr::Add(
            Box::new(Expr::Num(2.0)),
            Box::new(Expr::Num(3.0)),
        )),
        Box::new(Expr::Neg(Box::new(Expr::Num(4.0)))),
    );

    println!("{} = {}", simplify_display(&expr), eval(&expr));
}
((2 + 3) * -(4)) = -20

This pattern is the foundation of interpreters, compilers, and any tree-processing code.

Or Patterns & Binding

Multiple patterns can share an arm with |:

fn is_vowel(c: char) -> bool {
    matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'A' | 'E' | 'I' | 'O' | 'U')
}

fn categorize(value: i32) -> &'static str {
    match value {
        0 => "zero",
        1 | 2 | 3 => "small",
        4..=9 => "medium",
        10..=99 => "large",
        _ => "huge",
    }
}

fn main() {
    println!("'a' is vowel: {}", is_vowel('a'));
    println!("'b' is vowel: {}", is_vowel('b'));

    for n in [0, 2, 7, 42, 1000] {
        println!("{}: {}", n, categorize(n));
    }
}
'a' is vowel: true
'b' is vowel: false
0: zero
2: small
7: medium
42: large
1000: huge

The matches! macro returns a bool — useful when you just need to check if a value matches a pattern.

Real-World Example: Command Parser

#[derive(Debug)]
enum Token {
    Get { key: String },
    Set { key: String, value: String },
    Delete { key: String },
    List,
    Quit,
    Unknown(String),
}

fn parse_command(input: &str) -> Token {
    let parts: Vec<&str> = input.trim().splitn(3, ' ').collect();
    match parts.as_slice() {
        ["GET", key] => Token::Get { key: key.to_string() },
        ["SET", key, value] => Token::Set {
            key: key.to_string(),
            value: value.to_string(),
        },
        ["DEL", key] => Token::Delete { key: key.to_string() },
        ["LIST"] => Token::List,
        ["QUIT"] | ["EXIT"] => Token::Quit,
        _ => Token::Unknown(input.to_string()),
    }
}

fn execute(token: &Token) {
    match token {
        Token::Get { key } => println!("Getting value for '{}'", key),
        Token::Set { key, value } => println!("Setting '{}' = '{}'", key, value),
        Token::Delete { key } => println!("Deleting '{}'", key),
        Token::List => println!("Listing all keys"),
        Token::Quit => println!("Goodbye"),
        Token::Unknown(cmd) => println!("Unknown command: {}", cmd.trim()),
    }
}

fn main() {
    let commands = ["GET user:1", "SET user:2 Alice", "DEL user:3", "LIST", "QUIT", "HELP"];
    for cmd in commands {
        let token = parse_command(cmd);
        execute(&token);
    }
}
Getting value for 'user:1'
Setting 'user:2' = 'Alice'
Deleting 'user:3'
Listing all keys
Goodbye
Unknown command: HELP

Slice patterns ([first, second, ..]) are powerful for parsing structured input.

Common Pitfalls

  • Writing if-else chains when match would be clearer. If you are comparing one value against multiple possibilities, use match. It is more readable and the compiler checks exhaustiveness.
  • Forgetting exhaustiveness. Removing the _ arm and adding a new enum variant forces you to handle it everywhere. This is a feature — use it. Avoid _ => panic!() when you could handle each case.
  • Overusing if let for multiple cases. If you have 2+ patterns to match, use match. if let is for the single-case shortcut.
  • Not using guard clauses. Complex conditions in match arms are cleaner as guards (if condition) than as nested if-else inside the arm body.
  • Ignoring the matches! macro. When you just need a boolean check against a pattern, matches!(value, pattern) is cleaner than a full match that returns true/false.
  • Pattern matching without destructuring. If you match on an enum but do not use the inner data, check if you actually need the match or if a method on the enum would be more appropriate.

Key Takeaways

  • match is exhaustive: the compiler ensures you handle every case. This catches bugs when you add new variants.
  • if let and while let are shortcuts for matching a single pattern.
  • Patterns can destructure enums, structs, tuples, and slices — even nested ones.
  • Guard clauses add runtime conditions to pattern arms.
  • Or patterns (|) and ranges (..=) keep match arms concise.
  • Pattern matching replaces switch statements, type checks, and if-else chains with something that is both more powerful and safer.