How to Use the match Expression with Enums

Use the `match` keyword to handle every variant of an enum explicitly, ensuring exhaustive and safe control flow.

The vending machine that checks its wiring

You are building a simple game loop. Every frame, the engine produces an event: a key press, a mouse movement, or a quit signal. Your code needs to react differently to each one. In Python, you might write a chain of if isinstance checks or a match statement. In JavaScript, you'd check a type property or use a switch. In Rust, you define the event as an enum and use match to handle it.

The difference is not syntax. The difference is safety. Rust's match forces you to handle every possible variant of the enum. If you add a new event type later, the compiler refuses to build your code until you update every match that touches that enum. This prevents bugs where new states are silently ignored. You cannot forget a case. The compiler guarantees you handled everything.

Pattern matching in plain words

Think of match like a vending machine with labeled slots. Each slot corresponds to a specific variant of your enum. When you feed a value into the machine, it checks the label. If the value matches a slot, that slot activates and runs its code. If you leave a slot empty, the machine won't work. Rust enforces this: you must wire up every slot, or the code won't compile. This is called exhaustive matching.

Enums in Rust are more than lists of names. They can carry data. A KeyPressed variant might hold the character and a shift flag. A MouseMoved variant might hold coordinates. match can extract that data automatically. When a pattern matches, the data inside the variant becomes available as local variables in that arm. You don't need to call getters or check for null. The structure of the pattern mirrors the structure of the data.

Minimal example

Start with a simple enum and a match that handles every variant. The code below defines a Direction enum and matches a value to produce a string.

enum Direction {
    North,
    South,
    East,
    West,
}

/// Converts a Direction enum to a string description.
fn describe_direction(dir: Direction) -> &'static str {
    // match evaluates the value against patterns top to bottom.
    // Every variant must be covered, or the compiler rejects the code.
    // The result of the matching arm becomes the result of the match.
    match dir {
        Direction::North => "Up",
        Direction::South => "Down",
        Direction::East => "Right",
        Direction::West => "Left",
    }
}

fn main() {
    let dir = Direction::North;
    println!("Result: {}", describe_direction(dir));
}

The match expression takes dir and compares it against each arm. The first arm checks for Direction::North. Since dir is North, that arm matches and returns "Up". The other arms are skipped. If dir were South, the first arm would fail, the second would match, and "Down" would be returned.

The compiler checks exhaustiveness. If you remove the West arm, the compiler produces error E0004 (non-exhaustive patterns). It tells you exactly which variant is missing. This check happens at compile time. There is no runtime overhead for exhaustiveness. The compiler generates a jump table or a series of comparisons based on the enum's discriminant.

Write the match. Let the compiler verify the logic. If the pattern doesn't cover all cases, the code is incomplete.

Extracting data with destructuring

Enums often hold data. match can destructure the variant and bind the inner data to variables. This works for tuple variants and struct variants.

enum Event {
    KeyPressed { key: char, shift: bool },
    MouseMoved { x: i32, y: i32 },
    Quit,
}

/// Handles an event by printing a description.
fn handle_event(event: Event) {
    match event {
        // Destructure the struct-like variant.
        // `key` and `shift` become local variables in this arm.
        // The fields are moved out of the enum.
        Event::KeyPressed { key, shift } => {
            if shift {
                println!("Shift+{}", key);
            } else {
                println!("Pressed {}", key);
            }
        }
        // Destructure another struct variant.
        // `x` and `y` are bound to the coordinates.
        Event::MouseMoved { x, y } => println!("Mouse at ({}, {})", x, y),
        // Unit variant has no data.
        // Just run the code block.
        Event::Quit => println!("Shutting down"),
    }
}

fn main() {
    let e1 = Event::KeyPressed { key: 'a', shift: true };
    handle_event(e1);

    let e2 = Event::MouseMoved { x: 100, y: 200 };
    handle_event(e2);
}

When event is KeyPressed, the pattern Event::KeyPressed { key, shift } matches. The key and shift fields are moved out of the enum and bound to variables named key and shift. You can use these variables inside the arm. The names in the pattern must match the field names in the enum definition.

By default, match moves the value. If the enum contains types that don't implement Copy, the value is consumed. After the match, the original variable is no longer valid. This is usually what you want. If you need to keep the value, borrow it in the match.

Convention aside: When destructuring, Rust allows you to rename bindings. Event::KeyPressed { key: k, shift: s } binds key to k and shift to s. Use this when you need a shorter name or want to avoid shadowing. The community often prefers the concise form { key, shift } when names match, as it reduces noise.

Trust the borrow checker. It usually has a point. If you try to use the value after a match that moves it, the compiler will stop you.

Guards and the @ operator

Sometimes a pattern matches, but you need an extra condition. Guards let you add a boolean expression to an arm. The arm only runs if the pattern matches and the guard evaluates to true.

enum Number {
    Value(i32),
    Zero,
}

/// Classifies a number variant with a guard.
fn classify(num: Number) -> &'static str {
    match num {
        // The guard `if n > 0` runs only if the pattern matches.
        // `n` is bound to the inner value.
        Number::Value(n) if n > 0 => "Positive",
        Number::Value(n) if n < 0 => "Negative",
        // This arm catches Value(0) because the guards failed.
        Number::Value(_) => "Zero value",
        Number::Zero => "Explicit zero",
    }
}

Guards are evaluated after the pattern matches. They can access variables bound in the pattern. Guards do not affect exhaustiveness. The compiler still requires you to handle all variants. If a guard filters out some values, you must handle the remaining values in other arms.

The @ operator lets you bind a value to a variable while still matching a pattern. This is useful when you need the whole value and a part of it.

enum Message {
    Text(String),
    Error(String),
}

/// Logs a message, capturing the full variant and the content.
fn log_message(msg: Message) {
    match msg {
        // `content` binds to the String inside the variant.
        // `full` binds to the entire Message variant.
        // This lets you use both the part and the whole.
        Message::Text(content) @ Message::Text(_) => {
            println!("Text message: {}", content);
            println!("Full variant: {:?}", full);
        }
        Message::Error(content) @ Message::Error(_) => {
            println!("Error: {}", content);
            println!("Full variant: {:?}", full);
        }
    }
}

The @ syntax places the binding before the pattern. content @ Message::Text(_) binds content to the inner string and checks that the variant is Text. The wildcard _ in the pattern ensures the match succeeds for any Text variant. Use @ when you need to pass the full value to a function while also using the extracted data.

Guards add flexibility without breaking exhaustiveness. Use them when logic depends on values inside the variant.

Pitfalls and compiler errors

Missing arms trigger E0004 (non-exhaustive patterns). This is the most common error when learning match. If you add a new variant to an enum, every match on that enum must be updated. The compiler lists the missing variants. Fix the error by adding arms or using a wildcard.

Using a wildcard _ catches all remaining variants. This satisfies exhaustiveness. Use _ when you truly don't care about the rest.

match value {
    ImportantCase => do_thing(),
    _ => {} // Ignore everything else.
}

Convention aside: An empty block {} in a match arm is allowed but often signals a missing implementation. The community prefers _ => {} with a comment explaining why the cases are ignored, or _ => unreachable!() if the cases are impossible. unreachable!() tells the compiler and readers that the code should never run there. If it does, the program panics. This is safer than silently ignoring data.

Borrowing issues arise when you match on a reference. If you have &Option<T>, the match binds references to the inner data.

let opt = Some(5);
match &opt {
    Some(x) => println!("{}", x), // x is &i32
    None => println!("None"),
}

Here, x is &i32, not i32. The pattern matches the reference, so the binding is also a reference. This prevents moving data out of a borrowed value. If you try to move a non-Copy type out of a reference, the compiler rejects it with E0507 (cannot move out of borrowed content). Fix this by dereferencing or by matching on the owned value.

Shadowing can cause confusion. If you bind a variable in a match arm, it shadows any outer variable with the same name. The shadowing is limited to the arm. This is usually fine, but be careful when the name hides a value you need later.

The compiler is your safety net. Don't cut the holes. If you can't handle the case, the code shouldn't run.

Decision matrix

Use match when you need to handle every variant of an enum or pattern. Use match when the logic differs significantly between variants and you want the compiler to verify completeness. Use match when you need to return a value from the expression based on the variant. Use match when you are destructuring complex nested structures and need to extract multiple fields.

Use if let when you only care about one specific variant and want to ignore the rest. Use if let for simple checks where writing a full match adds noise. Use if let when the fallback case is trivial, like doing nothing or returning early.

Use while let when you are iterating over a sequence that returns Option or Result and want to loop until the value is exhausted. Use while let for consuming iterators or draining collections without writing an explicit loop condition.

Use guards when you need to filter variants based on their inner data. Use guards when the condition is simple and doesn't warrant a separate function. Use the @ operator when you need both the extracted data and the full value.

Pick the tool that matches your intent. match is the general solution. if let is the shortcut. while let is the loop. The compiler helps you choose by rejecting incomplete patterns.

Where to go next