Skip to main content
Rust beginner Lesson 13 of 30

Pattern Matching in Rust

Master match expressions, if let, while let, destructuring, and match guards in Rust.

match Expressions

match is one of Rust’s most powerful features. It compares a value against a series of patterns and executes the first matching arm — and crucially, it is an expression that produces a value. Unlike switch in C or Java, match in Rust is exhaustive: the compiler rejects any match that does not cover every possible value. This means you can never accidentally forget a case.

fn main() {
    let number = 7;

    let description = match number {
        1       => "one",
        2 | 3   => "two or three",   // | matches either pattern
        4..=9   => "between four and nine", // inclusive range pattern
        10      => "ten",
        _       => "something else", // _ is the catch-all (required to be exhaustive)
    };

    println!("{} is {}", number, description);
}

Rules:

  • Arms are checked top-to-bottom; the first match wins.
  • match must be exhaustive — all possible values must be covered.
  • _ is the wildcard that matches anything (like default in other languages).
  • Each arm is pattern => expression. Multi-line bodies use {}.

Matching Enums

match is at its most powerful when used with enums — it destructures each variant and binds its data to local names in one step. The compiler enforces that every variant is handled, so adding a new variant to an enum immediately produces compile errors in every match that needs updating.

#[derive(Debug)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

fn process(msg: Message) {
    match msg {
        Message::Quit                   => println!("quit"),
        Message::Move { x, y }          => println!("move to ({}, {})", x, y),
        Message::Write(text)            => println!("write: {}", text),
        Message::ChangeColor(r, g, b)   => println!("color: #{:02X}{:02X}{:02X}", r, g, b),
    }
}

fn main() {
    process(Message::Move { x: 10, y: 20 });
    process(Message::Write(String::from("hello")));
    process(Message::ChangeColor(255, 128, 0));
    process(Message::Quit);
}

Matching Option<T> and Result<T, E>

match is the standard way to handle Option and Result. The compiler ensures both the success and failure cases are addressed, eliminating the forgotten-null-check class of bugs.

fn safe_div(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

fn main() {
    // Matching Option — handles both Some and None
    match safe_div(10.0, 3.0) {
        Some(result) => println!("{:.4}", result), // 3.3333
        None         => println!("division by zero"),
    }

    // Matching Result — handles both Ok and Err
    let parsed: Result<i32, _> = "42".parse();
    match parsed {
        Ok(n)  => println!("parsed: {}", n),  // parsed: 42
        Err(e) => println!("error: {}", e),
    }
}

Destructuring in Patterns

Pattern matching is not limited to enums — you can destructure tuples, structs, and nested data in any match arm, binding the inner values to names. This eliminates a lot of the field-access boilerplate you would otherwise write.

Tuples

fn main() {
    let point = (3, -5);

    match point {
        (0, 0) => println!("origin"),
        (x, 0) => println!("on x-axis at {}", x),  // y matches 0, x is bound
        (0, y) => println!("on y-axis at {}", y),  // x matches 0, y is bound
        (x, y) => println!("at ({}, {})", x, y),   // both bound
    }
}

Structs

struct Point { x: i32, y: i32 }

fn main() {
    let p = Point { x: 3, y: 7 };

    match p {
        Point { x: 0, y } => println!("on y-axis at {}", y),
        Point { x, y: 0 } => println!("on x-axis at {}", x),
        Point { x, y }    => println!("at ({}, {})", x, y), // at (3, 7)
    }
}

Nested Enums

Patterns can be nested arbitrarily deep, which lets you match on deeply structured data in a single arm without intermediate variables:

enum Color {
    Rgb(u8, u8, u8),
    Hsv(u16, u8, u8),
}

enum Background {
    Solid(Color),
    Gradient(Color, Color),
}

fn main() {
    let bg = Background::Solid(Color::Rgb(255, 0, 128));

    match bg {
        // One arm can destructure through two levels of nesting
        Background::Solid(Color::Rgb(r, g, b)) =>
            println!("solid RGB: {} {} {}", r, g, b),
        Background::Solid(Color::Hsv(h, s, v)) =>
            println!("solid HSV: {} {} {}", h, s, v),
        Background::Gradient(_, _) =>
            println!("gradient"),
    }
}

Match Guards

A match guard is an extra if condition appended after a pattern. The arm only fires if both the pattern matches and the guard condition is true. Guards let you express conditions that cannot be encoded in the pattern syntax alone.

fn main() {
    let num = Some(7);

    match num {
        Some(x) if x < 0  => println!("negative: {}", x),
        Some(x) if x == 0 => println!("zero"),
        Some(x) if x % 2 == 0 => println!("positive even: {}", x),
        Some(x)            => println!("positive odd: {}", x),  // 7 matches here
        None               => println!("none"),
    }
}

Binding with @

The @ operator lets you bind a name to a matched value while simultaneously applying a pattern to it. Without @ you would need either a guard (losing the range check in the type) or a separate variable. This is particularly useful with range patterns where you want both the constraint and the value.

fn main() {
    let age = 17;

    let category = match age {
        n @ 0..=12  => format!("child ({})", n),    // n is bound AND must be in 0..=12
        n @ 13..=17 => format!("teenager ({})", n),
        n @ 18..=64 => format!("adult ({})", n),
        n           => format!("senior ({})", n),   // catch-all, n is bound
    };

    println!("{}", category); // teenager (17)
}

if let — Single-Pattern Match

if let is syntactic sugar for a match with exactly one meaningful arm and an ignored catch-all. Use it when you care about one specific variant and want to silently skip everything else. It produces more readable code than a full match when only one case needs handling.

fn main() {
    let config: Option<&str> = Some("verbose");

    // Full match — wordy when you only care about one case
    match config {
        Some(val) => println!("config: {}", val),
        None => (), // explicitly doing nothing
    }

    // if let — cleaner for the single-case scenario
    if let Some(val) = config {
        println!("config: {}", val);
    }

    // if let with else — when the missing case also needs handling
    if let Some(val) = config {
        println!("set to: {}", val);
    } else {
        println!("not configured");
    }
}

while let — Loop Until Pattern Fails

while let loops as long as a pattern matches, and exits cleanly when it does not. It is the idiomatic way to drain a stack, consume an iterator manually, or process items until a channel closes.

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

    // Loops until pop() returns None (empty vec)
    while let Some(top) = stack.pop() {
        println!("{}", top); // 5 4 3 2 1
    }
}

let and Function Parameters Destructure Too

Pattern matching is not limited to match, if let, and while let — it is built into let bindings and function parameters. This means you can destructure values wherever you introduce a binding.

fn print_point(&(x, y): &(i32, i32)) {
    // The parameter itself is a pattern — destructures the tuple reference on entry
    println!("({}, {})", x, y);
}

fn main() {
    // let with a tuple — destructures immediately
    let (a, b, c) = (1, 2, 3);
    println!("{} {} {}", a, b, c);

    // let with a struct — destructures named fields
    struct Point { x: i32, y: i32 }
    let Point { x, y } = Point { x: 5, y: 10 };
    println!("{} {}", x, y);

    // function parameter destructuring
    print_point(&(3, 4)); // (3, 4)
}

Ignoring Values

Rust provides several ways to ignore parts of a matched value when you do not need them:

fn main() {
    let numbers = (1, 2, 3, 4, 5);

    // .. ignores all middle elements — matches first and last
    match numbers {
        (first, .., last) => println!("first: {}, last: {}", first, last),
    }

    // _ ignores specific positions
    let (_, second, _, fourth, _) = numbers;
    println!("{} {}", second, fourth); // 2 4

    // Prefix _ suppresses the unused variable warning without binding
    let _unused = 42;
}

Practical: Parsing Commands

This example shows how match on a slice of string parts produces clean, readable command dispatch — a common pattern in CLI tools and simple interpreters:

#[derive(Debug)]
enum Command {
    Quit,
    Go(String),
    Set { key: String, value: String },
}

fn parse_command(input: &str) -> Option<Command> {
    let parts: Vec<&str> = input.trim().splitn(3, ' ').collect();
    match parts.as_slice() {
        ["quit"]           => Some(Command::Quit),
        ["go", dest]       => Some(Command::Go(dest.to_string())),
        ["set", key, val]  => Some(Command::Set {
            key: key.to_string(),
            value: val.to_string(),
        }),
        _ => None, // unrecognised input
    }
}

fn main() {
    let inputs = ["quit", "go north", "set speed 10", "unknown"];

    for input in &inputs {
        match parse_command(input) {
            Some(cmd) => println!("{:?}", cmd),
            None      => println!("unknown command: {}", input),
        }
    }
}

Frequently Asked Questions

Does match in Rust need to be exhaustive?
Yes. Every possible value must be handled. The compiler rejects non-exhaustive matches. Use _ as a catch-all arm.
What is if let?
if let is syntactic sugar for a match with one pattern and an optional else. Use it when you only care about one variant.
Can you match on multiple patterns at once?
Yes. Use the | operator to match multiple patterns in one arm: 1 | 2 | 3 => ...