The match Control Flow

How Rust's match expression works with enums, pattern binding, exhaustiveness, and the _ placeholder to handle every possible value safely

Rust’s match control flow construct is not a simple switch statement. It is a pattern‑matching engine that the compiler uses to prove your code handles all possible inputs. When you combine match with an enum, you get a guarantee that no variant will ever be silently ignored — and that guarantee makes entire categories of runtime bugs impossible.

You can think of match as a coin‑sorting machine: a value drops in, slides past a series of patterns, and falls through the first hole it fits. The code connected to that hole runs, and the result of the whole match expression is whatever that code produces.

Expression, not statement:

match always produces a value. Every arm must return the same type, and the compiler checks that the arms collectively cover every possible pattern the matched value could have.

Basic Syntax and the Expression Nature of match

A match expression begins with the match keyword, followed by the value you are inspecting. Then come the arms. Each arm has a pattern, a => arrow, and the code to run when that pattern matches.

enum TrafficLight {
    Red,
    Yellow,
    Green,
}
fn light_action(light: TrafficLight) -> &'static str {
    match light {
        TrafficLight::Red => "Stop",
        TrafficLight::Yellow => "Slow down",
        TrafficLight::Green => "Go",
    }
}

The value after the arrow is the arm’s result. If you need multiple statements, wrap them in curly braces — the last expression inside the block becomes the arm’s return value.

fn describe(light: TrafficLight) -> String {
    match light {
        TrafficLight::Red => {
            println!("The light is red.");
            String::from("Stop immediately")
        }
        TrafficLight::Yellow => String::from("Prepare to stop"),
        TrafficLight::Green => String::from("Proceed"),
    }
}

Because match is an expression, you can bind its result directly to a variable or use it as the final value of a function.

How match Works with Enums

When you match on an enum, each arm’s pattern is a variant. The power comes from the fact that variants can carry data, and match can bind that data to variables you use inside the arm.

Binding to Inner Values

Take an enum that represents the result of a calculation — some operations succeed, others fail with a message:

enum CalcResult {
    Ok(u64),
    Invalid(String),
}
fn describe_result(result: CalcResult) -> String {
    match result {
        CalcResult::Ok(value) => format!("Success: {}", value),
        CalcResult::Invalid(msg) => format!("Failure: {}", msg),
    }
}

When result is CalcResult::Ok(42), the pattern CalcResult::Ok(value) matches, and value gets bound to 42. That binding exists only inside that arm’s code block. If the variant carries multiple pieces of data — say, a tuple — you destructure them right in the pattern:

enum Operation {
    Add(u64, u64),
    Subtract(u64, u64),
}
fn perform(op: Operation) -> u64 {
    match op {
        Operation::Add(a, b) => a + b,
        Operation::Subtract(a, b) => a.saturating_sub(b),
    }
}

Patterns that match data:

The ability to extract data while choosing a code path is what makes match so different from a chain of if statements. You don’t need to manually test which variant you have and then retrieve values — the pattern does both in one step.

The same mechanism works for named fields, so if an enum variant uses struct‑like syntax, you can bind by name:

enum Message {
    Move { x: i32, y: i32 },
    Quit,
}
fn process(msg: Message) {
    match msg {
        Message::Move { x, y } => {
            println!("Moving to ({}, {})", x, y);
        }
        Message::Quit => println!("Shutting down"),
    }
}

For variants where you don’t need the inner value, _ acts as a placeholder that discards it:

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}
fn is_ipv4(addr: IpAddr) -> bool {
    match addr {
        IpAddr::V4(..) => true,
        IpAddr::V6(_) => false,
    }
}

Exhaustiveness — Why the Compiler Demands Every Possibility

The Rust compiler enforces that a match expression handles every possible variant of the matched type. If you omit an arm, your code will not compile.

fn broken_example(light: TrafficLight) -> &'static str {
    match light {
        TrafficLight::Red => "Stop",
        TrafficLight::Yellow => "Slow down",
        // TrafficLight::Green is missing
    }
}

This produces an error like:

error[E0004]: non-exhaustive patterns: `Green` not covered

Compile-time rejection of incomplete matches:

Exhaustiveness checking is not a lint — it is a hard error. This means you cannot ship code that accidentally ignores a variant when a new state is added to an enum later. If you add a variant, every match that isn’t using a wildcard must be updated, and the compiler will show you exactly where.

The Wildcard _ and Catch‑All Arms

When you genuinely want to say “for anything else, do this,” use the _ wildcard pattern. It matches any value and discards it.

fn rough_size(n: u32) -> &'static str {
    match n {
        0 => "zero",
        1..=10 => "small",
        _ => "large",
    }
}

Inside enum match expressions, _ is often used to handle a subset of variants while giving a single fallback for the rest:

enum Media {
    Book { pages: u32 },
    Movie { minutes: u32 },
    Podcast { minutes: u32 },
    Unknown,
}
fn duration(media: Media) -> String {
    match media {
        Media::Book { pages } => format!("{} pages", pages),
        Media::Movie { minutes } | Media::Podcast { minutes } => {
            format!("{} minutes", minutes)
        }
        _ => String::from("unknown"),
    }
}

Wildcards can hide new variants:

A catch‑all _ arm will silently absorb any future variant you add to the enum. If you want the compiler to tell you about every new variant, prefer listing all variants explicitly and only use _ when you are certain you will never need to handle new cases differently. For public enums that others might depend on, this is especially important.

Matching on Option and Result — Enums You Already Use

Two of Rust’s most common enums, Option<T> and Result<T, E>, are designed to be used with match. They replace null pointers and error‑code checks with explicit handling that the compiler verifies.

Option

fn maybe_add_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

The pattern Some(i) binds the contained i32 to i. If x is Some(5), i becomes 5, and the arm returns Some(6). If x is None, the first arm matches and the function returns None. There is no way to accidentally use a potentially‑absent value — the match forces you to answer the question “what if there is nothing?” in every case.

Result

Result is another enum with two variants: Ok(T) for success and Err(E) for failure. Matching on it lets you handle errors without exceptions.

fn parse_and_double(input: &str) -> Result<i32, String> {
    match input.trim().parse::<i32>() {
        Ok(n) => Ok(n * 2),
        Err(e) => Err(format!("Could not parse: {}", e)),
    }
}

A common pattern is to match a Result and return early on the Err variant, leaving the rest of the function to work with the success value. This is so common that the ? operator was built for it, but the underlying mechanism is exactly what you see above: pattern matching that binds the inner data on the Ok arm.

Common Mistakes and Misconceptions

Forgetting that match arms must return the same type. Every arm in a match expression must produce a value of the same type. If one arm returns a String and another returns an i32, the compiler will reject it. Use enums like Result or Option to represent different outcomes if necessary.

Over‑relying on _ early. Beginners sometimes use _ => () to silence compiler complaints without thinking about whether the unhandled case actually represents a valid state they should react to. If the enum has a variant that means “error,” that error probably shouldn’t be silently ignored.

Assuming match can only work with enums. match works with any type — integers, strings, booleans, structs, tuples, and nested combinations of them. The exhaustiveness guarantee still applies: for types like bool the compiler knows there are exactly two values, so both must be covered.

Trying to bind a reference to the inner value incorrectly. When matching on a reference to an enum (e.g., &option), patterns automatically adjust to match through the reference, so Some(i) binds i as a reference to the inner value. If you need to move the value out, you must match on the owned value, not a reference.

The most consequential early mistake:

Neglecting to handle every enum variant (or not including a wildcard) is the most frequent compile error you’ll see when starting with match. The compiler error tells you exactly which pattern is missing. Instead of skipping the error with _, read the missing variant and ask: “What should actually happen when this state occurs?” Often the answer reveals a real logic gap.

Summary

match does more than replace ifelse chains. It turns Rust’s enum types into a system for modelling program states that can never be partially handled. When you match on an enum, you get:

  • Compile‑time exhaustion — every variant must be accounted for.
  • Data extraction through pattern binding — you don’t need separate checks to pull values out.
  • A clear expression that returns a value, making control flow easy to follow.

Because Option and Result are enums, every Rust program that avoids null‑pointer crashes or uncaught errors does so through the same match mechanism you just learned.