Matching on Enums

Rust’s enum type lets a value be one of several different variants, and match is the tool built to handle every one of those variants safely. Instead of null checks or casting through a base type, you enumerate every possible shape a value can take, and the compiler forces you to account for each one. This lesson covers matching on enums — including variants that carry data — how the compiler’s exhaustiveness checking prevents unhandled cases, and the borrowing and shadowing mistakes that trip up newcomers.

Overview: How Matching on Enums Works

An enum in Rust groups several possible “shapes” of value under one type name. Each option is called a variant, and a variant can be a bare label with no data, a tuple of unnamed values, or a set of named fields much like a small struct. Unlike enums in C, which are essentially labeled integers, Rust enums are what other languages call tagged unions or sum types: a value of an enum type is exactly one variant at a time, and if that variant carries data, the data travels together with the tag that identifies which variant it is.

match is how you interrogate that tag. You give it a value and a list of patterns — usually one per variant — and the compiler checks, at compile time, whether those patterns cover every case the value’s type can produce. Leave a variant unhandled and compilation fails with a “non-exhaustive patterns” error instead of the program silently doing nothing, or crashing, on that case at runtime the way a missing switch case or a missing elif would in other languages.

A useful mental model: think of an enum as a labeled parcel that can be one of a few kinds of package, and match as a clerk who is required, by law, to know what to do with every kind of package the parcel could be. If a new package type is added to the enum later, the compiler re-checks every clerk (every match) that handles that enum and refuses to compile the ones that don’t have instructions for the new kind. That is exactly what happens when you add a variant to an enum used throughout a codebase — every match that no longer covers all cases is flagged, pointing you to every place that needs updating.

Trace a small example before writing any code: suppose a variable heading holds Direction::East, and a match lists arms for North, South, East, and West in that order. Rust reads the tag stored in heading, compares it against each pattern from top to bottom, and stops at the first one that matches — here, the third arm, East. Only that arm’s code runs; the others are skipped entirely. Because match is an expression, not just a control-flow statement, the value produced by the matching arm can be used directly — assigned to a variable, returned from a function, or passed to another expression — without a mutable placeholder variable declared beforehand.

The table below summarizes the three variant shapes you’ll see and how a pattern mirrors each one:

Variant kind Declared as Matched with
Unit variant Quit Message::Quit
Tuple variant Write(String) Message::Write(text)
Struct-like variant Move { x: i32, y: i32 } Message::Move { x, y }

One more piece of the mental model matters before the examples: when you match on a reference to an enum (for example &Message instead of an owned Message), Rust’s match ergonomics automatically adjusts the bindings inside each pattern to be references too. You don’t need to sprinkle & or * everywhere — writing the same pattern you would for an owned value works, and the bindings it introduces (like text in Message::Write(text)) come out as &String rather than String. This is exactly what avoids moving data you don’t own, a mistake covered later in this lesson.

Syntax

The general form of matching on an enum looks like this:

match VALUE {
    EnumName::UnitVariant => EXPRESSION,
    EnumName::TupleVariant(field0, field1) => EXPRESSION,
    EnumName::StructVariant { field_a, field_b } => EXPRESSION,
    other_variant_or_wildcard => EXPRESSION,
}
  • VALUE — the enum value being matched; it can be owned or a reference (&EnumName).
  • EnumName::UnitVariant — matches only that exact tag; no data to destructure.
  • EnumName::TupleVariant(field0, field1) — destructures a tuple variant’s positional data into new local bindings.
  • EnumName::StructVariant { field_a, field_b } — destructures a struct-like variant’s named fields; add .. to ignore the rest.
  • _ — the wildcard pattern; matches anything without binding it, often used as a catch-all.
  • pattern if condition — a match guard; the arm only runs when the bound values also satisfy the boolean condition.
  • PatternA | PatternB — an or-pattern; a single arm that matches either pattern.
  • Every arm’s EXPRESSION must produce the same type, since the whole match evaluates to one value.
  • Arms are tried top-to-bottom, and the full list must be exhaustive — the compiler rejects a match that misses a possible variant.

Examples

Example 1: Matching Unit Variants

The simplest enums have variants that carry no data at all — just a name. Matching on them is a direct comparison against each possible tag.

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

fn main() {
    let heading = Direction::East;

    let description = match heading {
        Direction::North => "heading up",
        Direction::South => "heading down",
        Direction::East => "heading right",
        Direction::West => "heading left",
    };

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

Output:

heading right

Because heading holds Direction::East, the third arm matches and its string becomes the value bound to description. Note that match here is used as an expression: the whole block evaluates to a &'static str that is assigned directly, with no mutable variable needed.

Example 2: Destructuring Tuple Variants

When variants carry data, the pattern in each arm names the fields you want to use. This example defines a Shape enum whose variants each store different numbers of dimensions, and computes the area for each one.

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Square(f64),
}

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

fn main() {
    let shapes = vec![
        Shape::Circle(2.0),
        Shape::Rectangle(3.0, 4.0),
        Shape::Square(5.0),
    ];

    for shape in &shapes {
        println!("Area: {:.2}", area(shape));
    }
}

Output:

Area: 12.57
Area: 12.00
Area: 25.00

area takes shape: &Shape, so inside the match, radius, width, height, and side are all bound as references (&f64) thanks to match ergonomics — no manual dereferencing is required to multiply them. Each arm’s expression evaluates to an f64, which becomes the return value of area for that particular shape.

Example 3: Struct-Like Variants and Mixed Enums

Real enums often mix all three variant kinds in one type. This example models a small set of UI events with a unit variant, a struct-like variant, and two tuple variants.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn process(msg: &Message) {
    match msg {
        Message::Quit => println!("Quit received, shutting down"),
        Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
        Message::Write(text) => println!("Writing: {}", text),
        Message::ChangeColor(r, g, b) => println!("Changing color to ({}, {}, {})", r, g, b),
    }
}

fn main() {
    let messages = vec![
        Message::Move { x: 10, y: 20 },
        Message::Write(String::from("hello")),
        Message::ChangeColor(255, 0, 0),
        Message::Quit,
    ];

    for msg in &messages {
        process(msg);
    }
}

Output:

Moving to (10, 20)
Writing: hello
Changing color to (255, 0, 0)
Quit received, shutting down

process takes &Message, and each arm’s pattern shape matches the variant’s declaration shape exactly: Move { x, y } destructures the named fields, Write(text) and ChangeColor(r, g, b) destructure tuple fields, and Quit needs no pattern data at all. The four messages are processed in the order they appear in the Vec, and each one’s arm decides what gets printed.

How It Works Step by Step

Using the Shape example from above, here is what happens from source code to running program:

  • At compile time, rustc looks at the type of shape (&Shape) and enumerates every variant Shape can have: Circle, Rectangle, and Square.
  • It checks the arms inside area‘s match and confirms all three variants are covered. If a fourth variant, say Triangle, were added to the enum without updating this match, compilation would fail right here with a non-exhaustive patterns error — this is the safety net that catches forgotten cases.
  • In memory, a Shape value stores a small discriminant (an implicit tag saying which variant it is) alongside enough space for the largest variant’s payload.
  • At runtime, match reads that discriminant and jumps straight to the matching arm’s code, similar to a compiled switch statement, rather than testing each pattern one condition at a time.
  • Once the arm is selected, Rust destructures the payload according to the pattern — binding radius, or width and height, or side — as new local variables that exist only for the duration of that arm.
  • Because the function matched on &Shape, those bindings are references rather than owned values; the multiplication in each arm works through Rust’s reference-aware arithmetic operator implementations, so no explicit dereference is needed.
  • The arm’s expression evaluates to an f64, which becomes the value the whole match evaluates to, and in turn the value area returns to its caller.

Common Mistakes

Mistake 1: Non-Exhaustive Match

Forgetting a variant is the single most common enum-matching error, and Rust will not let it compile.

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

fn describe(dir: Direction) -> &'static str {
    match dir {
        Direction::North => "up",
        Direction::South => "down",
        Direction::East => "right",
    }
}

This function is missing an arm for Direction::West, so the compiler rejects it with an error like error[E0004]: non-exhaustive patterns: `Direction::West` not covered. The fix is to add the missing arm (or an explicit _ catch-all if “anything else” is truly meant to be handled the same way):

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

fn describe(dir: Direction) -> &'static str {
    match dir {
        Direction::North => "up",
        Direction::South => "down",
        Direction::East => "right",
        Direction::West => "left",
    }
}

fn main() {
    println!("{}", describe(Direction::West));
}

Output:

left

Adding the West arm makes the match exhaustive again, and the program compiles and runs.

Mistake 2: Moving a Field Out of a Borrowed Enum

Matching against a dereferenced enum behind a shared reference tries to move its data out, which the borrow checker refuses.

enum Message {
    Write(String),
    Quit,
}

fn handle(msg: &Message) {
    match *msg {
        Message::Write(text) => println!("{}", text),
        Message::Quit => println!("quit"),
    }
}

Here, msg is &Message, but match *msg dereferences it first, so the pattern Message::Write(text) tries to move the String out of a value you only borrowed. The compiler rejects this with an error like cannot move out of `*msg` which is behind a shared reference, because String does not implement Copy. The fix is to match on the reference directly instead of dereferencing it first, so match ergonomics binds text as &String rather than moving it:

enum Message {
    Write(String),
    Quit,
}

fn handle(msg: &Message) {
    match msg {
        Message::Write(text) => println!("{}", text),
        Message::Quit => println!("quit"),
    }
}

fn main() {
    let messages = vec![
        Message::Write(String::from("hello")),
        Message::Quit,
    ];

    for msg in &messages {
        handle(msg);
    }
}

Output:

hello
quit

Matching on msg instead of *msg means nothing is moved — text becomes a borrowed &String, which is enough to print it with {}.

Mistake 3: Using a Variable Name as a Pattern Instead of Comparing to It

A lowercase identifier in a match arm is always a new binding, never a comparison against an existing variable — this catches almost everyone once.

fn main() {
    let target = 5;
    let value = 3;

    match value {
        target => println!("matched target: {}", target),
    }
}

Output:

matched target: 3

This compiles, but almost certainly not as intended. The identifier target in the match arm does not refer to the outer let target = 5; — it shadows it and introduces a brand-new binding that matches absolutely anything, capturing value‘s data (3) into it. The outer target is never actually compared against value. To really compare against an existing variable’s value, use a match guard:

fn main() {
    let target = 5;
    let value = 3;

    match value {
        v if v == target => println!("matched target: {}", v),
        v => println!("no match, got: {}", v),
    }
}

Output:

no match, got: 3

The guard if v == target performs a real equality comparison between the bound value v and the outer target, giving the correct result.

Best Practices

  • Match on a reference (match &value, or iterate with for item in &collection) when you only need to read fields, so ownership stays where it started and match ergonomics binds fields as references automatically.
  • Prefer writing an explicit arm for every variant over a catch-all _ when it’s practical — an exhaustive listing means the compiler reminds you the moment a new variant needs handling; a stray _ silently swallows it.
  • Use match guards (pattern if condition) when a condition determines whether an arm should run at all, instead of matching broadly and then nesting an if inside the arm’s body.
  • Combine arms that should behave identically with or-patterns (PatternA | PatternB) instead of duplicating the same body twice.
  • Let match be an expression — bind its result directly to a variable rather than declaring a mutable variable up front and assigning it inside every arm.
  • Reach for if let instead of a full match with an empty _ => {} arm when you only care about one variant; it reads more clearly and signals intent.
  • Name bindings after what the data represents (radius, width) rather than generic letters, so the pattern documents the shape of the variant at a glance.

Practice Exercises

  1. Define an enum TrafficLight with variants Red, Yellow, and Green. Write a function duration_secs(light: &TrafficLight) -> u32 that uses match to return how many seconds each color stays on (for example, 30, 5, and 25). Print the duration for all three colors.
  2. Extend the Shape enum from Example 2 with a new variant, Triangle(f64, f64), storing a base and a height. Update area to handle it (area = 0.5 * base * height). Try removing the new arm temporarily and confirm the compiler refuses to build until you add it back.
  3. Define an enum Command with a struct-like variant SetVolume { level: u8 }, a tuple variant Rename(String), and a unit variant Mute. Write a match where the SetVolume arm uses a guard so that a level over 100 prints “invalid volume” while a valid level prints its value, and the other variants print their own messages.

Summary

  • Rust enums group unit, tuple, and struct-like variants under one type; a value is exactly one variant plus its data at a time — a tagged union.
  • match compares a value against a list of patterns and runs the first one that fits; since it’s an expression, its result can be bound directly to a variable.
  • The compiler requires match arms to be exhaustive, so adding a new enum variant surfaces every match in the codebase that still needs updating.
  • Patterns destructure tuple and struct-like variants into new local bindings scoped to that arm.
  • Matching on a reference (&EnumName) uses match ergonomics to bind fields as references automatically, which avoids moving data you don’t own.
  • Match guards (if condition) and or-patterns (|) express extra conditions and combined arms without duplicating code.
  • Prefer if let for single-variant checks and reserve full match for when every variant truly needs handling.