Skip to content

Pattern Matching

match compares a value against a series of patterns and executes the arm whose pattern matches. Unlike a C switch, Rust’s match is an expression — it produces a value — and it is checked exhaustively by the compiler.

let x: i32 = 3;
let description = match x {
1 => "one",
2 => "two",
3 => "three",
_ => "something else",
};
println!("{}", description); // three

The _ wildcard matches any value you have not listed explicitly. Without it (and without covering all cases), the code will not compile.

flowchart TD
  V["match value"] --> P1{"pattern 1 matches?"}
  P1 -->|yes| A1["run arm 1, produce its value"]
  P1 -->|no| P2{"pattern 2 matches?"}
  P2 -->|yes| A2["run arm 2, produce its value"]
  P2 -->|no| PN{"... every remaining pattern?"}
  PN -->|none match, no wildcard| CE["compile error: E0004 non-exhaustive"]
  PN -->|"_ wildcard present"| AW["run wildcard arm"]
match decision flow — every branch, or a compile error

The real power of match comes from destructuring enum variants and binding their data to local names:

enum Coin {
Penny,
Quarter(String),
}
let coin = Coin::Quarter(String::from("Texas"));
match coin {
Coin::Penny => println!("Penny"),
Coin::Quarter(state) => println!("Quarter from {}", state),
}

The identifier state in Coin::Quarter(state) is a binding — it receives the String value carried by that variant. You can use it freely inside the arm body.

Patterns can match integer ranges with ..=:

let score = 85;
let grade = match score {
90..=100 => "A",
80..=89 => "B",
_ => "C or below",
};
println!("{}", grade); // B

@ Bindings — Bind and Test in One Pattern

Section titled “@ Bindings — Bind and Test in One Pattern”

A plain binding (x @ pattern) both matches a pattern and gives the matched value a name, letting a range check and a usable binding live in the same arm instead of re-reading the value inside the arm body:

fn main() {
let n = 5;
match n {
x @ 1..=5 => println!("small: {}", x),
x @ 6..=10 => println!("medium: {}", x),
x => println!("other: {}", x),
}
enum Msg { Hello { id: u32 } }
let msg = Msg::Hello { id: 7 };
match msg {
Msg::Hello { id: id_var @ 3..=7 } => println!("in range: {}", id_var),
Msg::Hello { id } => println!("out of range: {}", id),
}
}
small: 5
in range: 7

Without @, 1..=5 => println!("small") matches the range but gives you no name for which value in that range matched — you would have to re-read n from the outer scope instead of the pattern itself.

_ is a catch-all that matches anything without binding it. Place it last; any arm after _ would be unreachable and the compiler warns you.

When you only care about one variant, if let is more concise than a full match:

let temperature = Some(22);
if let Some(t) = temperature {
println!("Temperature: {}C", t);
}

This binds t to the inner value if the pattern matches, and does nothing on None. You can attach an else branch for the non-matching case.

A guard is an extra if condition on a match arm. It further narrows which values the arm accepts:

let number = 5;
match number {
n if n < 0 => println!("negative: {}", n),
0 => println!("zero"),
n => println!("positive: {}", n),
}

Guards run after the structural pattern matches, so the binding (n) is available inside the guard condition.

Match Ergonomics and the Rust 2024 Edition

Section titled “Match Ergonomics and the Rust 2024 Edition”

When you match a reference to an enum (&Option<i32> instead of Option<i32>) with a plain, non-reference pattern (Some(x)), Rust automatically shifts into a “default binding mode”: bindings inside that pattern become references, without you writing & or ref anywhere. This is why Some(x) => ... on a &Option<i32> gives you x: &i32 for free — it is called match ergonomics.

The Rust 2024 edition tightens the rules for what happens when you mix an explicit & pattern or a mut binding into a spot the compiler has already put into that implicit by-reference mode. On Rust 2021, both were quietly accepted:

// @skip-verify edition-specific: this compiles under --edition 2021; the harness always checks --edition 2024, where the identical code is the hard error shown below
fn main() {
let opt_ref: &Option<&i32> = &Some(&5);
match opt_ref {
Some(&val) => println!("val={val}"),
None => println!("none"),
}
}

On the 2024 edition, the same code is a hard compile error:

// @skip-verify no E-code (plain hard error, not error[EXXXX] — verified with rustc 1.96.1 --edition 2024)
fn main() {
let opt_ref: &Option<&i32> = &Some(&5);
match opt_ref {
Some(&val) => println!("val={val}"), // ERROR on 2024
None => println!("none"),
}
}
error: cannot explicitly dereference within an implicitly-borrowing pattern
--> src/main.rs:4:14
|
4 | Some(&val) => println!("val={val}"),
| ^ reference pattern not allowed when implicitly borrowing
|
= note: for more information, see <https://doc.rust-lang.org/reference/patterns.html#binding-modes>
note: matching on a reference type with a non-reference pattern implicitly borrows the contents
--> src/main.rs:4:9
|
4 | Some(&val) => println!("val={val}"),
| ^^^^^^^^^^ this non-reference pattern matches on a reference type `&_`
help: match on the reference with a reference pattern to avoid implicitly borrowing
|
4 | &Some(&val) => println!("val={val}"),
| +
error: aborting due to 1 previous error

The fix the compiler suggests is exactly the point of the 2024 rule: be explicit about which level you’re dereferencing, instead of mixing an implicit borrow (from matching opt_ref itself, a reference) with an explicit & pattern one level deeper:

fn main() {
let opt_ref: &Option<&i32> = &Some(&5);
match opt_ref {
&Some(&val) => println!("val={val}"),
&None => println!("none"),
}
}
val=5

The exact same tightening applies to mut bindings written inside an implicitly-borrowing pattern (Some(mut y) matching a &Option<i32>), which now also fails on 2024 with error: cannot mutably bind by value within an implicitly-borrowing pattern. In practice this rarely affects code that matches values directly (match some_option { Some(x) => ... }, the overwhelming majority of real code) — it only surfaces when a pattern already relies on the compiler’s implicit borrow and adds an explicit &/mut one level inside that same pattern, which the 2024 edition now asks you to write unambiguously instead.

Leaving out a variant entirely is a compile error, not a warning:

// @expect-error E0004
enum Coin {
Penny,
Quarter(String),
}
fn value(coin: Coin) -> u32 {
match coin {
Coin::Penny => 1,
}
}
fn main() {}
error[E0004]: non-exhaustive patterns: `Coin::Quarter(_)` not covered
--> src/main.rs:8:11
|
8 | match coin {
| ^^^^ pattern `Coin::Quarter(_)` not covered
|
note: `Coin` defined here
--> src/main.rs:2:6
|
2 | enum Coin {
| ^^^^
3 | Penny,
4 | Quarter(String),
| ------- not covered
= note: the matched value is of type `Coin`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
9 ~ Coin::Penny => 1,
10 ~ Coin::Quarter(_) => todo!(),
|

That “cannot forget a case” safety net only holds while every variant is matched by name. The _ wildcard is itself a valid pattern, and it silently covers every variant that arm doesn’t otherwise handle — including variants added to the enum later. The compiler cannot warn you, because from its point of view nothing is missing: _ is a match.

enum Coin {
Penny,
Nickel,
Quarter, // added after value_in_cents() was written and reviewed
}
fn value_in_cents(coin: &Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
_ => 0, // "handles" every future variant -- including Quarter, worth 25
}
}
fn main() {
for coin in [Coin::Penny, Coin::Nickel, Coin::Quarter] {
println!("{}", value_in_cents(&coin));
}
}

This compiles cleanly — no error, no warning — and prints 1, 5, 0. Quarter is worth 25 cents, but nothing told you value_in_cents needed updating when it was added; _ quietly absorbed it and returned the wrong answer. If you want the “new variant must be handled somewhere” guarantee, match every variant by name and only add _ where “everything else” is truly the intended behavior, not as a default reflex.

#[non_exhaustive] — Forcing the Wildcard For Everyone Else

Section titled “#[non_exhaustive] — Forcing the Wildcard For Everyone Else”

The _ arm caveat above is something you opt into inside your own code. #[non_exhaustive], covered in depth in the error-handling module’s custom errors lesson, is the same E0004 machinery turned around: placed on a public enum, it forces every crate other than yours to write a wildcard arm, so you can add variants later without immediately breaking their build. It has no effect on code inside the crate that defines the enum — only external callers are required to add _.

#[derive(Debug)]
enum Coin {
Penny,
Nickel,
Dime,
Quarter(String),
}
fn value_in_cents(coin: &Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("Quarter from {}!", state);
25
}
}
}
fn describe_number(n: i32) -> &'static str {
match n {
0 => "zero",
1..=9 => "single digit",
10..=99 => "double digit",
_ => "large",
}
}
fn main() {
let coins = vec![
Coin::Penny,
Coin::Nickel,
Coin::Quarter(String::from("Alaska")),
Coin::Dime,
];
for coin in &coins {
println!("{:?} = {} cents", coin, value_in_cents(coin));
}
for n in [0, 7, 42, 100] {
println!("{} is {}", n, describe_number(n));
}
let temperature = Some(22);
if let Some(t) = temperature {
println!("Temperature: {}C", t);
}
}

match is a single, static proof obligation: “every possible shape of this value is handled somewhere in this expression.” Guards, @ bindings, and range patterns all narrow which values an arm accepts, but none of them weaken that obligation — only _ does, because as far as the exhaustiveness checker is concerned, _ genuinely does cover everything, including shapes that do not exist yet.

Matching every variant by name is more text to write and to update when a new variant appears, but that update burden is exactly the point — it turns “did anyone remember to handle the new case?” into a compile error instead of a silent wrong answer. _ trades that guarantee for brevity, which is the right call when “everything else” genuinely is intended to behave identically forever, and the wrong call the moment a future variant might need different handling. The 2024 match-ergonomics tightening trades a small amount of previously-accepted flexibility (explicit &/mut nested inside an implicitly-borrowing pattern) for patterns whose binding mode is unambiguous just by reading them, without tracing through the compiler’s implicit-borrow rules.

  • “If my match compiles, it correctly handles every future variant of the enum too.” Only if there is no _ arm. A _ arm compiles today and will keep compiling — silently, with no warning — after a new variant is added, potentially handling that new variant wrong.
  • “Match ergonomics means I never need to write & or ref in a pattern.” It means you usually don’t need to, when matching a reference with an otherwise plain pattern. The moment you mix an explicit & pattern into a spot Rust has already put into implicit-reference mode, the 2024 edition asks you to be fully explicit about the whole pattern instead.
  • “@ bindings are rarely-used syntax with no real use case.” They are the standard way to combine a range or structural check with a usable name for the value that matched, avoiding a second, redundant read of the original expression inside the arm body.

AI-generated match arms over Option<&String> (or any reference-to-owned-type Option) frequently .clone() the inner value in every arm just to get an owned String to return, even when a borrowed &str would satisfy every caller.

🐛 Spot the bug

fn describe(name: Option<&String>) -> String {
match name {
Some(n) => n.clone(),
None => String::from("anonymous"),
}
}
Show the answer

This compiles and works, but n.clone() allocates a brand-new String on every call where a name is present, purely to satisfy a return type of String that didn’t need to be owned in the first place. If callers only ever print or compare the result, the function can return &str instead: fn describe(name: Option<&String>) -> &str { match name { Some(n) => n.as_str(), None => "anonymous" } } — no allocation on the Some path, and "anonymous" is already a &'static str literal. Reach for .clone() here only if the caller genuinely needs to own the result past the lifetime of the borrowed name.

What happens if a match expression does not cover all possible values of the matched type?
What does the _ pattern do in a match arm?
When should you prefer if let over a full match expression?
What is a match guard?
What does x @ 1..=5 let you do that a plain 1..=5 pattern does not?
What does this print, given this enum was written before Quarter existed and never updated?
enum Coin { Penny, Nickel, Quarter }
fn value(coin: &Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        _ => 0,
    }
}
fn main() {
    println!("{}", value(&Coin::Quarter));
}
On the Rust 2024 edition, why does Some(&val) => ... fail to compile when matching a &Option<&i32>?
let opt_ref: &Option<&i32> = &Some(&5);
match opt_ref {
    Some(&val) => println!("{val}"),
    None => {}
}