Pattern matching

Use exhaustive matches, destructuring, guards, and binding patterns while avoiding hidden wildcard cases and accidental moves.

level intermediate time 11 min at Standard depth
version Rust 1.98
what

Pattern matching checks a value’s shape and contents and can extract its data in the same operation; match uses it for exhaustive branch selection.

trap

_ lets code keep compiling after an enum gains a variant, but it may silently choose the wrong branch; destructuring by value can also move a non-Copy field and make the whole original value unavailable.

fix

List every variant of a closed enum when practical, decide whether to borrow or take ownership first, then choose match, if let, let-else, or matches!.

What it is and why it exists

A Rust pattern describes the structure a value must have and may create bindings for data inside that structure. Patterns can match enum variants, struct fields, tuples, slices, literals, and ranges; they aren’t merely switch labels that compare one scalar.

match compares one subject with its arms from top to bottom and executes only the first successful arm. The compiler requires every possible value to go somewhere and warns about arms that can never be reached, so adding an enum variant immediately exposes omissions wherever variants are listed explicitly.

A pattern combines checking and extraction. Some(order) proves that an Option isn’t None and binds its inner value as order; there’s no separate state query followed by an extraction that might fail.

You encounter patterns in match arms, let, function parameters, for, if let, while let, let-else, and matches!. They share one pattern language but permit failure in different ways, so they aren’t interchangeable based only on code length. Macro-generated patterns, heavily nested patterns, and API-evolution strategies belong in rust/pattern-matching-advanced and aren’t repeated here.

How it works

Arm selection and exhaustiveness

match subject { pattern => expression, ... } evaluates subject once, then tries arms in source order. The selected arm supplies the value of the entire match expression, so all reachable arms must produce compatible result types.

Exhaustiveness checking reasons from the type and the coverage of each pattern; it doesn’t execute code to infer conditions. A Boolean has two possible values, an ordinary enum has its declared variants, and integers normally need ranges or a catch-all; an arbitrary Boolean expression in a match guard doesn’t count as static coverage.

Order changes behavior. A broad range, variable binding, or _ placed early masks more specific patterns below it, and the compiler normally points out such arms with an unreachable_patterns warning.

Destructuring and binding

A pattern can test and bind at once. Request::Read { id } first requires the Read variant, then creates the local binding id; Request::Read { id: 0 } tests the field without creating a same-named variable.

_ matches and ignores one value, while .. ignores the remaining parts of a structure or sequence. name @ subpattern requires subpattern to succeed and binds the complete matched part as name, which is useful when you need both a range check and the original value.

You can join alternatives with |, but every alternative must create the same names with the same types and binding modes. 1 | 2 | 3 creates no variable; a bare lowercase identifier normally introduces a new binding rather than referring to a same-named outer variable.

Refutability determines where a pattern fits

An irrefutable pattern matches every possible value of its type. let (x, y) = pair must succeed, so ordinary let, function parameters, and for bindings can use it directly.

A refutable pattern can fail, as with Some(value), 1..=9, or a fixed-length slice pattern. It belongs somewhere with a failure path, such as a match arm, if let, while let, or let-else; a let-else else block must leave the current path with return, break, continue, or a diverging expression.

FormBest fitWhat failure doesExhaustiveness check
matchSeveral meaningful cases or a returned valueSelects another armYes
if letHandle one successful shapeOptional elseNo
while letRepeat while extraction succeedsEnds the loopNo
let-elseLeave early on failureelse must leaveFor that pattern
matches!Produce only a BooleanReturns falseNo

Binding modes and ownership

Pattern bindings follow Rust’s move, copy, and borrow rules. When an owned value is matched by value, a non-Copy field moves by default and a Copy field is copied; moving only some fields creates a partial move , after which remaining fields may be used separately but the original structure can’t be used as a whole.

When the subject is borrowed, match ergonomics automatically dereferences common structural patterns and makes the bindings references. In match &job { Job { owner, .. } => ... }, owner is therefore borrowed rather than moving the String out of job.

Read the ownership choice from the subject first: match value can take its contents, match &value borrows for reading, and match &mut value borrows for mutation. ref and ref mut can still borrow individual fields explicitly, but borrowing the complete subject is usually easier to review in new code.

Guards and failure paths

A match guard is the if condition after an arm’s pattern. The guard runs only after the pattern succeeds; when it returns false, matching continues with later arms.

A guard can use bindings created by its pattern and can read outside variables. It doesn’t add its condition to the exhaustiveness proof, so an arm for Some(x) if x > 0 still needs coverage for other Some values and for None.

In A | B if condition, the guard applies to the complete A | B, not only to B. If the alternatives need different conditions, split them into separate arms so both the ordering and condition boundaries are explicit.

Examples

These four examples cover exhaustive branching, slice parsing, borrowing versus moving, and repeated matching. Every output shown below came from compiling and running the corresponding program with local rustc.

Seal the state space with an enum

The routing function handles every Request variant explicitly and returns its match expression directly. The guard on Write handles only an oversized request; the following Write arm remains responsible for every other size.

route.rs
enum Request {
    Health,
    Read { id: u32 },
    Write { bytes: usize },
}

fn route(request: Request) -> (&'static str, u16) {
    match request {
        Request::Health => ("health", 200),
        Request::Read { id: 0 } => ("invalid id", 400),
        Request::Read { .. } => ("read", 200),
        Request::Write { bytes } if bytes > 1_024 => ("too large", 413),
        Request::Write { .. } => ("write", 202),
    }
}

fn main() {
    let requests = [
        Request::Health,
        Request::Read { id: 0 },
        Request::Read { id: 7 },
        Request::Write { bytes: 2_048 },
    ];

    for request in requests {
        let (label, status) = route(request);
        println!("{label}: {status}");
    }
}
health: 200
invalid id: 400
read: 200
too large: 413

Adding Request::Delete makes this function stop compiling because the match is no longer exhaustive. Replacing the last two explicit variant arms with _ removes that reminder, which is why a wildcard can weaken evolution safety.

id: 0 is a field subpattern and doesn’t bind id. The next arm uses .. to say that the variant’s remaining fields don’t affect this decision; it also makes no promise to read fields added later.

Parse a command with slice patterns

split_first() removes empty input from the main path, and let-else makes verb and args available afterward. The match then checks the verb and the argument slice’s length as one command shape.

commands.rs
#![allow(dead_code)]

#[derive(Debug)]
enum Command<'a> {
    Show { id: u32 },
    Tag { id: u32, label: &'a str },
}

fn parse_command<'a>(parts: &[&'a str]) -> Result<Command<'a>, String> {
    let Some((verb, args)) = parts.split_first() else {
        return Err(String::from("empty command"));
    };

    match (*verb, args) {
        ("show", [id]) => id
            .parse::<u32>()
            .map(|id| Command::Show { id })
            .map_err(|_| String::from("invalid id")),
        ("tag", [id, label @ ("urgent" | "normal")]) => id
            .parse::<u32>()
            .map(|id| Command::Tag { id, label })
            .map_err(|_| String::from("invalid id")),
        _ => Err(String::from("unknown command")),
    }
}

fn main() {
    for parts in [
        &[][..],
        &["show", "17"][..],
        &["tag", "17", "urgent"][..],
        &["tag", "17", "later"][..],
    ] {
        println!("{:?}", parse_command(parts));
    }
}
Err("empty command")
Ok(Show { id: 17 })
Ok(Tag { id: 17, label: "urgent" })
Err("unknown command")

[id] matches exactly one argument, while [id, label] matches exactly two, so extra arguments aren’t silently ignored. label @ ("urgent" | "normal") restricts the allowed values and retains the chosen label.

The catch-all is appropriate here because input strings form an open state space and the function’s contract groups unsupported shapes into one error. That differs from a closed internal enum; whether _ is right depends on the type boundary and error policy.

Borrow for inspection, then extract by value

inspect() matches a &Job, so its field bindings are borrows. The main function still owns job afterward and can pass it to take_owner(), where destructuring by value moves the String field.

binding_modes.rs
struct Job {
    owner: String,
    retries: u8,
}

fn inspect(job: &Job) {
    match job {
        Job {
            owner,
            retries: attempts @ 1..=3,
        } => println!("retry {attempts} for {owner}"),
        Job { owner, retries: 0 } => println!("first attempt for {owner}"),
        Job { owner, retries } => println!("retry {retries} for {owner}"),
    }
}

fn take_owner(job: Job) -> String {
    let Job { owner, .. } = job;
    owner
}

fn main() {
    let job = Job {
        owner: String::from("Mina"),
        retries: 2,
    };

    inspect(&job);
    println!("owner: {}", take_owner(job));
}
retry 2 for Mina
owner: Mina

attempts @ 1..=3 tests an inclusive range and retains the actual retry count. Because the subject is a shared reference, owner doesn’t move the string; that borrow ends when inspect() returns.

take_owner() consumes the complete Job, so moving owner agrees with its function contract. If the caller still needs job, the usual fix is to accept &Job or &str, not to clone the entire value merely to keep the old signature compiling.

Process consecutive successful states in a loop

while let fits a stateful operation that repeatedly returns Option or Result. This example pops one string at a time, uses let-else to skip parse failures, then classifies valid numbers with a range and guard.

readings.rs
fn main() {
    let mut pending = vec!["101", "bad", "42", "7"];

    while let Some(text) = pending.pop() {
        let Ok(value) = text.parse::<i32>() else {
            println!("skip: {text}");
            continue;
        };

        match value {
            even @ 0..=100 if even % 2 == 0 => println!("even: {even}"),
            odd @ 0..=100 => println!("odd: {odd}"),
            outside => println!("outside: {outside}"),
        }
    }

    println!("empty: {}", pending.is_empty());
}
odd: 7
even: 42
skip: bad
outside: 101
empty: true

The vector is last-in, first-out, so output begins with "7". The parse failure uses continue to satisfy the requirement that let-else leave the current path; whether invalid input should be skipped is a caller contract decision.

The first arm’s guard selects only even values, so the second range arm must still catch odd values in the interval. Removing the second arm doesn’t make the first guard stand for all of 0..=100, and the compiler doesn’t include an arbitrary % condition in its coverage proof.

Pitfalls

Deep Refutability is not a runtime guess

Refutability is not a runtime guess

The compiler decides whether a pattern can fail from its type and form. A variable binding, a tuple made only of variable bindings, and a complete pattern for a one-variant struct are normally irrefutable; enum variants, literals, ranges, and fixed-length slice patterns are normally refutable.

The same pattern has different requirements in different syntactic positions. Ordinary let has no failure arm and accepts only an irrefutable pattern; if let permits failure and decides whether to run its block; let-else permits failure but requires that path to leave, which guarantees the bindings are initialized afterward.

Using an irrefutable pattern with if let normally produces a warning because the condition is always true. It may compile, but it hides what the author intended to test; replace it with ordinary let or restore a genuinely refutable subpattern.

let-else scope

After let PATTERN = expression else { ... }; succeeds, bindings created by the pattern are available in the following outer scope. That differs from if let, whose bindings exist only inside its successful block.

The else must diverge so that reaching the next statement proves the pattern succeeded. Returning an error, continuing the current loop, or calling a function that returns ! all work; logging and falling back into the main path doesn’t.

Complex validation doesn’t need to fit into one pattern. Patterns are good at expressing type structure, while business predicates fit ordinary conditions or validation functions returning Result; keeping that boundary makes failure details easier to preserve.

Match ergonomics and partial moves

Patterns operate on places, not only temporary scalar values. With match value, an arm can take ownership of fields inside value; with match &value, automatic dereferencing keeps structural patterns concise while the default bindings become shared references.

That automatic adjustment is match ergonomics. It removes layers of & and ref but doesn’t change the underlying ownership rules; when a binding’s type isn’t obvious, add a local type annotation or inspect the inferred type in your editor instead of guessing from the spelling.

A by-value pattern can mix copies and moves. If a structure contains a u32 and a String, binding the former copies while binding the latter moves; untouched fields remain individually accessible, but a method call on the full structure normally needs the complete value and is rejected.

Borrowing the whole subject usually avoids a partial move. The more precise alternative is ref name or ref mut name on individual field patterns, but mixing binding modes requires reviewers to trace every field; unless only a few fields need borrowing, prefer a function signature and subject that state the borrowing intent.

A type that implements Drop can’t have arbitrary fields moved out through a pattern because its destructor must receive a complete &mut self. To extract a resource deliberately, provide a consuming method for the whole value or store the field in Option and use take() to leave a valid state.

Or-patterns, @ bindings, and guards

An or-pattern left | right is the union of its alternatives. Every alternative must create the same bindings, because the arm expression needs one stable set of local variables; mismatched binding types or borrowing modes are rejected too.

The left side of @ creates a binding while its right side constrains the same matched part. id @ 1..=9 retains the actual id in addition to checking the range; in a nested structure, you can likewise bind a complete subobject while testing its fields.

Precedence is easy to misread. A guard follows the complete arm pattern, so A | B if ready means (A | B) if ready; if only B requires ready, write two arms.

A guard is an ordinary expression and can call functions, read outside state, or even cause side effects. Side effects make branch selection depend on evaluation timing and complicate tests, so guards should normally remain short and pure; move complicated decisions into clearly named functions.

Exhaustiveness, reachability, and API boundaries

Exhaustiveness guarantees that every type-level possibility can select an arm; it doesn’t guarantee that every arm has the right business behavior. _ => Ok(()) passes the check but may report an unhandled event as success, so coverage completeness and semantic completeness need separate review.

Reachability is calculated top to bottom. A variable pattern matches any value of its type and is especially dangerous near the top; a wide range can also cover a narrower range completely. Don’t suppress the warning mechanically, because it usually reveals an ordering or naming mistake.

A public API can mark an enum #[non_exhaustive], requiring callers in other crates to retain a fallback arm. The wildcard is then part of the compatibility contract, but it should still return an explicit error or conservative behavior rather than defaulting to unreachable!().

Strings, integers, and fields from external protocols have open input spaces by nature. Those matches need a safe fallback; for a closed enum you control, explicit variants usually let the compiler help more during refactoring.

Choose the narrowest form that preserves information

match is the right default when several branches affect the result or every enum variant deserves explicit treatment. It also suits state transitions because an input state and event can be matched together as a tuple.

if let works when one shape matters and failure genuinely requires no action. Once both sides matter, match is often clearer and restores an exhaustiveness reminder when the type changes.

let-else moves a failure path out early and keeps the main path flat. It isn’t meant to describe several recovery strategies; when failure reasons differ, match the Result or use ? to preserve error information.

while let repeats while a stateful operation succeeds, such as repeated pop() calls or channel receives. It treats the first mismatch as normal termination, so when one Err type represents both closure and failure, an explicit match may be necessary to avoid swallowing an error.

matches!(value, pattern if guard) returns only a Boolean and doesn’t expose bindings afterward. It suits filtering and assertions; if you immediately need data from the same value, matching twice is usually redundant, so use match or if let directly.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

before this Ownership Option result soon
next up Pattern matching advanced soon Borrowing rules Error handling soon Closures
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?