
Pattern matching is one of Rust’s most expressive and powerful features. Whether you’re unpacking data from enums, destructuring structs, binding values conditionally, or making concise and readable control flow decisions, Rust’s match
, if let
, while let
, and pattern-based bindings help you write clear and safe code.
In this post, we’ll take a deep dive into matching and patterns in Rust—beyond the basics—to help you write more idiomatic and powerful Rust code.
Introduction
Rust’s pattern matching system is deeply integrated into the language and goes far beyond simple switch-like comparisons. Patterns are used not only in match
expressions, but also in let
bindings, function parameters, if let
, while let
, and more.
Mastering patterns means mastering a core part of Rust’s expressive syntax and safety model. In this post, we’ll explore how matching and patterns work in depth, look at their many forms and uses, and demonstrate how they contribute to expressive, error-resistant code.
Understanding Patterns in Rust
Patterns are a foundational part of Rust syntax, appearing in many constructs beyond match
, such as let
bindings, function parameters, loops, and more. Understanding patterns helps you write expressive code that can elegantly destructure complex data, match specific structures, and bind values to names with precision.
In this section, we’ll look at what a pattern actually is, how patterns differ from expressions, and why they matter so much in idiomatic Rust code.
What Is a Pattern?
In Rust, a pattern is a structural blueprint that can match the shape of a value and optionally extract parts of it. Patterns are not evaluated like expressions—they are matched against values. You’ve likely seen patterns used in match
arms or let
bindings, but they also show up in if let
, while let
, function parameters, and even closures.
A pattern can be as simple as a literal or a variable name, or as complex as a nested destructuring of enums and structs.
Let’s get started writing some code and explore this with practical examples.
Open a shell window (Terminal on Mac/Linux, Command Prompt or PowerShell on Windows). Then navigate to the directory where you store Rust packages
for this blog series, and run the following command:
cargo new match_patterns
Next, change into the newly created match_patterns
directory and open it in VS Code (or your favorite IDE).
Note: Using VS Code is highly recommended for following along with this blog series. Be sure to install the Rust Analyzer extension — it offers powerful features like code completion, inline type hints, and quick fixes.
Also, make sure you’re opening the match_patterns
directory itself in VS Code. If you open a parent folder instead, the Rust Analyzer extension might not work properly — or at all.
As we see examples in this post, you can either replace the contents of main.rs or instead comment out the current code for future reference with a multi-line comment:
/*
CODE TO COMMENT OUT
*/
We’ll work through a number of examples throughout this post. Open the file src/main.rs and replace its contents entirely with the code for this example.
Example: Literal and Identifier Patterns
fn main() {
let x = 42;
match x {
0 => println!("Zero"),
1..=10 => println!("Between 1 and 10"),
42 => println!("The answer to life, the universe, and everything"),
_ => println!("Something else"),
}
}
/*
Output:
The answer to life, the universe, and everything
*/
This example uses literal patterns (match 0
, match 42
) and a range pattern (match one thru ten, inclusive 1..=10
). The wildcard _
catches anything not matched explicitly. This ensures our pattern matching is exhaustive, a requirement for match
.
Example: Destructuring a Tuple with Patterns
fn main() {
let pair = (3, "apples");
let (count, item) = pair;
println!("You have {} {}.", count, item);
}
/*
Output:
You have 3 apples.
*/
Here, (count, item)
is a tuple pattern used in a let
binding to destructure the pair. Patterns can bind multiple values at once in a clear, concise way. pair
is a tuple of two items, and we destructure pair
into two variables at once.
Example: Using Patterns in Function Parameters
fn print_coordinates((x, y): (i32, i32)) {
println!("X: {}, Y: {}", x, y);
}
fn main() {
let point = (10, 20);
print_coordinates(point);
}
/*
Output:
X: 10, Y: 20
*/
Function parameters can also use patterns to destructure incoming data directly. This is especially useful when working with tuples or custom struct types. This function takes a tuple of two items, x and y, and we use the two parameters in the function body.
Where Patterns Can Be Used
Patterns in Rust are more versatile than you might expect—they appear in a variety of common and powerful contexts. Once you recognize that many forms of Rust syntax are pattern-based, you can start to leverage them to write more expressive and concise code.
In this subsection, we’ll explore the most common places where patterns can be used: let
bindings, match
expressions, control flow like if let
and while let
, function parameters, and even for
loops.
Example: let
Bindings with Pattern Matching
fn main() {
let (x, y, z) = (1, 2, 3);
println!("x = {}, y = {}, z = {}", x, y, z);
}
/*
Output:
x = 1, y = 2, z = 3
*/
You can use a pattern directly in a let
binding to destructure values such as tuples. Behind the scenes, Rust is matching the tuple pattern (x, y, z)
against the tuple value (1, 2, 3)
, and binding the elements to the corresponding variables.
So this is pattern matching, but in a broader sense: Rust uses patterns in multiple contexts beyond the match
keyword.
Example: if let
for Optional Values
fn main() {
let maybe_name = Some("Greg");
if let Some(name) = maybe_name {
println!("Hello, {}!", name);
} else {
println!("No name provided.");
}
}
/*
Output:
Hello, Greg!
*/
The if let
syntax allows you to match a specific pattern while discarding other possibilities. It’s ideal when you’re only interested in one match arm, such as extracting a value from an Option
.
Example: for
Loops with Destructuring
fn main() {
let points = vec![(1, 2), (3, 4), (5, 6)];
for (x, y) in points {
println!("Point has coordinates: ({}, {})", x, y);
}
}
/*
Output:
Point has coordinates: (1, 2)
Point has coordinates: (3, 4)
Point has coordinates: (5, 6)
*/
In for
loops, patterns are used to destructure items directly in the loop declaration, making it easy to work with collections of tuples or structs. So for each iteration a tuple from the points
vector is destructured into the local x
and y
variables.
These examples highlight the flexibility of patterns across Rust’s syntax. Patterns show up in many places, not just match
arms. Mastering this concept opens the door to cleaner and more idiomatic code.
Simple vs. Complex Patterns
Patterns in Rust can range from the very simple—like matching a single variable or literal—to the very complex, involving nested destructuring, guards, multiple pattern alternatives, or even combining several pattern types at once.
Understanding the distinction between simple and complex patterns helps you write code that is both readable and powerful. Let’s walk through the spectrum of pattern complexity to see how Rust empowers you to match exactly what you need, when you need it.
Example: Simple Pattern Matching with Literals and Bindings
fn main() {
let x = 5;
match x {
1 => println!("One"),
5 => println!("Five"),
other => println!("Something else: {}", other),
}
}
/*
Output:
Five
*/
This example uses simple patterns: literal matches (match 1
, match 5
) and a variable binding (other
). If no pattern matches, other
binds the unmatched value, and we can use the value assigned to other
.
Example: Complex Pattern with Nested Enums and Destructuring
enum Status {
Online { user: String },
Offline,
}
enum Message {
StatusUpdate(Status),
Ping,
}
fn main() {
let msg = Message::StatusUpdate(Status::Online { user: String::from("Greg") });
match msg {
Message::StatusUpdate(Status::Online { user }) => {
println!("{} is online", user);
}
Message::StatusUpdate(Status::Offline) => {
println!("User is offline");
}
Message::Ping => {
println!("Ping received");
}
}
}
/*
Output:
Greg is online
*/
This example shows a complex pattern that matches a deeply nested enum variant and destructures a named field. It’s readable and powerful thanks to Rust’s pattern syntax.
Example: Combining Patterns with |
and Using Guards
fn main() {
let number = 7;
match number {
1 | 2 => println!("One or Two"),
3..=6 => println!("Between 3 and 6"),
x if x % 2 == 1 => println!("Odd number: {}", x),
_ => println!("Something else"),
}
}
/*
Output:
Odd number: 7
*/
Here we combine:
- multiple values using
|
(OR pattern), - a range pattern (
3..=6
), and - a guard (
if x % 2 == 1
) that adds a condition to a match arm.
These examples illustrate how simple patterns provide clarity and how complex patterns provide precision and power. As you become more comfortable with matching, you’ll naturally begin to reach for the right level of pattern complexity for each situation.
The match
Expression Deep Dive
The match
expression is one of Rust’s most powerful control flow constructs. It’s more than a glorified switch
statement, match
in Rust is exhaustive, safe, and expressive. It enables you to destructure and bind data, enforce handling of every possible case, and write more robust logic with cleaner syntax.
In this section, we’ll dig into how match
works, starting with the basics and building up to more advanced pattern features and matching behaviors.
Basic match
Usage
At its core, a match
expression compares a value against a series of patterns and executes the code associated with the first pattern that matches. Each match arm can also bind values, and unlike in some other languages, Rust requires that all possible cases be handled, either explicitly or via a wildcard _
.
Some types (e.g., from external crates) are marked #[non_exhaustive]
, requiring a wildcard _
even if you appear to cover all variants.
Let’s start by looking at how to use match
in its most straightforward form.
Example: Matching Integer Values
fn main() {
let number = 3;
match number {
1 => println!("One"),
2 => println!("Two"),
3 => println!("Three"),
_ => println!("Something else"),
}
}
/*
Output:
Three
*/
This is the most basic usage of match
. Each arm checks if number
equals a specific value. The _
pattern is a catch-all that handles any unmatched cases, ensuring your match
is exhaustive.
Example: Matching Enum Variants
enum Direction {
North,
South,
East,
West,
}
fn main() {
let heading = Direction::West;
match heading {
Direction::North => println!("Heading North"),
Direction::South => println!("Heading South"),
Direction::East => println!("Heading East"),
Direction::West => println!("Heading West"),
}
}
/*
Output:
Heading West
*/
Here, we match against enum variants. Since the enum is non-generic and has no associated data, each match arm is simple and direct. This is an idiomatic way to branch logic based on program state.
Example: Matching with Bindings
fn main() {
let value = Some(100);
match value {
Some(x) => println!("Got value: {}", x),
None => println!("Got nothing"),
}
}
/*
Output:
Got value: 100
*/
In this example, the Some(x)
pattern doesn’t just check if the value is a Some
—it also pulls out the value inside and gives it a name: x
. That way, you can use the value inside Some
directly in your code. This ability to both check the structure and extract data is one of the most powerful things about match
.
These examples lay the foundation for understanding match
. You’ve seen it match literals, enum variants, and wrapped values with bindings, Some(x)
. In the upcoming subsections, we’ll explore exhaustiveness, wildcards, guards, and more advanced binding techniques.
Exhaustiveness and Wildcards
One of the defining features of Rust’s match
expression is that it must be exhaustive. This means you must handle every possible value that the matched expression can take. The compiler enforces this rule to prevent logic errors and ensure your code behaves safely.
When you don’t care about all the possible cases or want a catch-all fallback, Rust provides the _
wildcard pattern. This allows you to match any remaining values without naming or using them.
Let’s explore how Rust ensures match exhaustiveness and how the wildcard pattern can help simplify match logic when appropriate.
Example: Exhaustive Match on an Enum
enum TrafficLight {
Red,
Yellow,
Green,
}
fn main() {
let light = TrafficLight::Yellow;
match light {
TrafficLight::Red => println!("Stop!"),
TrafficLight::Yellow => println!("Caution!"),
TrafficLight::Green => println!("Go!"),
}
}
/*
Output:
Caution!
*/
This match handles all possible TrafficLight
values. If we remove one of the arms, the compiler will produce an error saying the match is not exhaustive.
Example: Using a Wildcard to Handle Remaining Cases
fn main() {
let code = 500;
match code {
200 => println!("OK"),
404 => println!("Not Found"),
_ => println!("Unhandled status code: {}", code),
}
}
/*
Output:
Unhandled status code: 500
*/
Here, _
acts as a fallback arm that matches anything not matched above. This is especially useful when matching against open-ended sets like numbers, strings, or user input.
Example: Match with Missing Arms (Fails to Compile)
fn main() {
let boolean = true;
match boolean {
true => println!("It’s true!"),
// false arm is missing
}
}
/*
Compiler Error:
error[E0004]: non-exhaustive patterns: `false` not covered
--> src/main.rs:4:11
|
4 | match boolean {
| ^^^^^^^ pattern `false` not covered
*/
Rust prevents you from compiling this code because not all possible bool
values are handled. You must add a false
arm or a wildcard arm like _ => ...
.
Key Takeaways
- Rust requires matches to be exhaustive to prevent logic errors.
- The
_
wildcard is used to catch any values not explicitly matched. - Missing arms result in compile-time errors, promoting safer and more complete logic handling.
Guards in Match Arms
Match guards allow you to add extra conditions to a match
arm using an if
clause. These guards make pattern matching even more expressive by letting you match a pattern only if an additional boolean condition is also true.
This is especially useful when:
- You want to match the same structural pattern differently depending on the value inside.
- You want to keep logic clean without nesting conditionals inside the arm body.
Let’s look at how to use match guards effectively in real-world scenarios.
Example: Basic Match Guard with a Value
fn main() {
let number = Some(7);
match number {
Some(x) if x > 5 => println!("Greater than five: {}", x),
Some(x) => println!("Number: {}", x),
None => println!("No number"),
}
}
/*
Output:
Greater than five: 7
*/
In this example, both Some(x)
arms match the same pattern, but the first one has a guard (if x > 5
). Rust tries match arms from top to bottom, so the first guard takes precedence.
Example: Guard with Multiple Patterns
fn main() {
let x = 4;
match x {
1 | 2 | 3 => println!("Small number"),
n if n % 2 == 0 => println!("Even number"),
_ => println!("Other number"),
}
}
/*
Output:
Even number
*/
Here, the second arm uses a guard to check whether x
is even. Note that n if n % 2 == 0
means we match anything and then apply the condition, so be cautious with ordering.
In the following code, the first arm matches against all even numbers, including a value of 2, so the second arm will never be matched. This is an example of how we must use caution in ordering our match arms.
let x = 2;
match x {
n if n % 2 == 0 => println!("Even number"),
2 => println!("Specifically two"),
_ => println!("Something else"),
}
Example: Guard with Enum Destructuring
enum Temperature {
Celsius(i32),
Fahrenheit(i32),
}
fn main() {
let temp = Temperature::Fahrenheit(100);
match temp {
Temperature::Fahrenheit(f) if f > 90 => println!("Hot in Fahrenheit: {}", f),
Temperature::Fahrenheit(f) => println!("Cool Fahrenheit: {}", f),
Temperature::Celsius(c) if c > 30 => println!("Hot in Celsius: {}", c),
Temperature::Celsius(c) => println!("Cool Celsius: {}", c),
}
}
/*
Output:
Hot in Fahrenheit: 100
*/
Each match arm checks not just the variant but also the value inside it using a guard. This makes your logic cleaner than nesting if
inside the block.
Key Points
- Guards are added with
if
after the pattern in a match arm. - They refine pattern matches based on additional conditions.
- Match arms with guards are still evaluated top-to-bottom—ordering matters.
Binding with @
Patterns
Rust’s @
pattern syntax allows you to bind a matched value to a variable while simultaneously testing it against a pattern. This gives you the best of both worlds: you can capture the entire value for later use, but still apply structural or range-based matching to it.
This is especially helpful when:
- You want to match a range or specific sub-pattern,
- But still need access to the original, full value for use in your match arm logic.
Let’s see how this works in practice.
Example: Binding a Value in a Range
fn main() {
let age = 17;
match age {
teenager @ 13..=19 => println!("Teenager: {}", teenager),
adult @ 20..=64 => println!("Adult: {}", adult),
_ => println!("Other age group"),
}
}
/*
Output:
Teenager: 17
*/
Here, teenager @ 13..=19
both tests that the value is in the range and binds it to the variable teenager
. You don’t have to repeat the range value or re-assign age
.
Key Concept:
- Patterns (like
13..=19
) are matched by the compiler at the pattern level. - Guards (like
if x > 12
) are extra runtime checks that refine the match after a pattern is matched.
Our Pattern-Based Example (No Guards)
match age {
teenager @ 13..=19 => println!("Teenager: {}", teenager),
adult @ 20..=64 => println!("Adult: {}", adult),
_ => println!("Other age group"),
}
How it works:
- Each arm has a range pattern (
13..=19
,20..=64
) which is evaluated by the compiler as part of pattern matching. - The compiler generates optimized branching code based on known numeric ranges.
- The
@
lets you both match the pattern and bind the actual value (teenager
,adult
) for use.
This is fully pattern-based, with no runtime boolean checks needed.
Equivalent with Guards
match age {
n if n >= 13 && n <= 19 => println!("Teenager: {}", n),
n if n >= 20 && n <= 64 => println!("Adult: {}", n),
_ => println!("Other age group"),
}
How it works:
- The pattern is just
n
(binds any value). - The conditions (
if n >= 13 && n <= 19
) are evaluated at runtime after the pattern matches. - This means every value matches the pattern (
n
matches anything), but only runs the arm if the guard condition is true.
⚠️ This requires more runtime logic, and may bypass more specific pattern arms if placed above them.
Example: Binding with Enum Destructuring
#[derive(Debug)]enum Message {
Data(i32),
Quit,
}
fn main() {
let msg = Message::Data(42);
match msg {
value @ Message::Data(n) if n > 40 => {
println!("Got high-value data: {:?}, n = {}", value, n);
}
Message::Data(n) => println!("Got other data: {}", n),
Message::Quit => println!("Quitting"),
}
}
/*
Output:
Got high-value data: Data(42), n = 42
*/
In this example, value @ Message::Data(n)
allows you to:
- Destructure
Message::Data(n)
to getn
, - While also binding the entire variant to
value
.
This is useful when you want access to both the internal value and the full outer structure.
Example: Using @
in Nested Patterns
#[derive(Debug)]enum Temperature {
Celsius(i32),
Fahrenheit(i32),
}
fn main() {
let temp = Temperature::Celsius(35);
match temp {
hot @ Temperature::Celsius(c) if c > 30 => {
println!("Hot temperature in Celsius: {:?}, value = {}", hot, c);
}
Temperature::Celsius(c) => println!("Cool Celsius: {}", c),
_ => println!("Other temperature"),
}
}
/*
Output:
Hot temperature in Celsius: Celsius(35), value = 35
*/
This pattern demonstrates how @
can help preserve the full enum variant even when you’re matching a nested field and applying a guard.
Key Points
- The
@
syntax lets you both test a pattern and bind the whole value. - It’s ideal when you need access to both the part and the whole of a value.
- Use it for ranges, enums, nested matches, or any pattern that needs flexible access.
Conditional Matching with if let
and while let
While match
is powerful and expressive, it can sometimes feel verbose—especially when you only care about handling one specific pattern and want to ignore the rest. In those cases, Rust offers if let
and while let
as concise alternatives to full match
statements.
These constructs are ideal for dealing with Option
, Result
, and other enums when:
- You want to act only on one variant (e.g.,
Some
,Ok
), - And don’t need to explicitly handle the others.
In this section, we’ll look at both if let
and while let
, starting with how if let
simplifies conditional matching for common types.
Using if let
for Optional and Result Types
The if let
syntax allows you to match a specific pattern and run code for that case only—without having to write an entire match
block. It’s especially handy when working with Option
and Result
, where you often care about just the Some
or Ok
case.
Let’s look at some practical examples.
Example: Extracting a Value from Option
fn main() {
let name = Some("Greg");
if let Some(n) = name {
println!("Hello, {}!", n);
}
}
/*
Output:
Hello, Greg!
*/
In this example, if let
checks whether name
is a Some
variant. If it is, the inner value is bound to n
, and the block runs. If it’s None
, nothing happens.
Example: Handling a Result
with if let
fn main() {
let result: Result<i32, &str> = Ok(42);
if let Ok(value) = result {
println!("Got successful result: {}", value);
}
}
/*
Output:
Got successful result: 42
*/
This avoids writing a full match
when you only care about the Ok
case. It’s concise and expressive.
Example: Using else
with if let
fn main() {
let status: Option<&str> = None;
if let Some(s) = status {
println!("Status: {}", s);
} else {
println!("No status provided.");
}
}
/*
Output:
No status provided.
*/
You can pair if let
with an else
block if you want to handle both the matching and non-matching cases without using a full match
.
Key Points
if let
is great for concise handling of one match case.- Especially useful with
Option
,Result
, and other enums with one “success” variant. - Can be combined with
else
for basic fallback logic.
Looping with while let
Rust’s while let
is a powerful control flow construct that combines the idea of pattern matching with iteration. It repeatedly matches a value against a pattern and continues looping as long as the match succeeds.
This is especially useful when working with:
- Iterators that return
Option<T>
, like.next()
- Loops that should stop when a certain pattern no longer matches
- Streams or state transitions with enum variants
It’s a cleaner and more idiomatic alternative to writing loop { ... match ... break; }
for simple match-based iteration.
Example: Iterating Through a Vector with while let
fn main() {
let mut numbers = vec![10, 20, 30].into_iter();
while let Some(n) = numbers.next() {
println!("Next number: {}", n);
}
}
/*
Output:
Next number: 10
Next number: 20
Next number: 30
*/
In this example, .next()
returns an Option<i32>
each time it’s called. The loop continues until .next()
returns None
.
Example: Unpacking Enum Variants Until a Stop Condition
enum Action {
Continue(i32),
Stop,
}
fn main() {
let actions = vec![
Action::Continue(1),
Action::Continue(2),
Action::Stop,
Action::Continue(3),
];
let mut iter = actions.into_iter();
while let Some(Action::Continue(val)) = iter.next() {
println!("Continuing with value: {}", val);
}
println!("Stopped!");
}
/*
Output:
Continuing with value: 1
Continuing with value: 2
Stopped!
*/
Here, the loop stops as soon as an Action::Stop
is encountered. Notice that values after the Stop
are not processed, because the loop ends immediately when the pattern no longer matches.
Example: Using while let
for Countdown Logic
fn main() {
let mut count = Some(3);
while let Some(n) = count {
println!("Countdown: {}", n);
if n == 1 {
count = None;
} else {
count = Some(n - 1);
}
}
println!("Liftoff!");
}
/*
Output:
Countdown: 3
Countdown: 2
Countdown: 1
Liftoff!
*/
This shows how while let
can be used for manual state control, repeatedly updating a value and looping while a pattern continues to match.
Key Points
while let
is great for looping while an enum (likeOption
orResult
) matches a particular pattern.- It simplifies iterator-based or enum-driven loops.
- The loop exits automatically once the match fails (e.g., when
None
is returned).
When to Use if let
Instead of match
While match
is more powerful and flexible, it can also be more verbose—especially when you only care about handling a single specific case. That’s where if let
shines: it provides a concise way to match just one pattern and optionally bind values, without requiring a full match
block.
Use if let
when:
- You only care about one variant, like
Some
,Ok
, or a specific enum variant. - You don’t need to handle every possible case.
- You want cleaner, less nested code.
Use match
when:
- You need to handle multiple variants.
- Exhaustiveness matters for safety.
- You’re matching complex patterns or combining multiple cases.
Let’s walk through examples to see the difference in clarity and use cases.
Example: Basic Comparison – if let
vs match
fn main() {
let config = Some("dark");
// Using match
match config {
Some(mode) => println!("Mode set to: {}", mode),
None => {}
}
// Using if let (cleaner here)
if let Some(mode) = config {
println!("Mode set to: {}", mode);
}
}
/*
Output:
Mode set to: dark
Mode set to: dark
*/
The if let
version avoids unnecessary boilerplate when you don’t care about the None
case.
Example: Prefer match
When You Need Multiple Arms
fn main() {
let status = Some("ready");
match status {
Some("ready") => println!("System is ready."),
Some(_) => println!("System is not ready yet."),
None => println!("No status available."),
}
}
/*
Output:
System is ready.
*/
In this case, match
is clearly more appropriate because you’re handling more than one possibility and need to match each case differently.
Example: if let
with else
as a Lightweight Alternative
fn main() {
let user_role = Some("admin");
if let Some("admin") = user_role {
println!("Welcome, administrator.");
} else {
println!("Access denied.");
}
}
/*
Output:
Welcome, administrator.
*/
if let
with else
can sometimes cleanly replace a two-arm match without losing readability.
Key Points
- Use
if let
when you care about only one variant and want to write less code. - Use
match
when you need multiple branches, exhaustiveness, or more complex logic. if let
improves readability for simple conditional binding.
When to Use while let
Instead of match
While match
can be used inside traditional loops for pattern-driven control, it often leads to more verbose and less readable code when you’re simply looping over a value until it no longer matches a pattern. That’s exactly where while let
excels: it lets you concisely loop while a specific pattern continues to match, and automatically stops when it doesn’t.
Use while let
when:
- You want to loop while matching a single variant (e.g.,
Some
,Continue
,Ok
). - You’re dealing with iterators, streams, or state transitions that may stop.
- You want cleaner code without managing a
loop
+match
+break
structure manually.
Use match
inside a loop when:
- You need to handle multiple match arms within the loop body.
- You want more granular control or side effects based on different cases.
Let’s walk through examples to see the difference in clarity and use cases.
Example: while let
vs. loop + match
fn main() {
let mut items = vec![1, 2, 3].into_iter();
// Using while let (idiomatic)
while let Some(x) = items.next() {
println!("Got: {}", x);
}
// Equivalent but more verbose
let mut items2 = vec![4, 5, 6].into_iter();
loop {
match items2.next() {
Some(x) => println!("Got: {}", x),
None => break,
}
}
}
/*
Output:
Got: 1
Got: 2
Got: 3
Got: 4
Got: 5
Got: 6
*/
The while let
version is shorter and easier to read when you’re just iterating while matching a single pattern.
Example: When match
is Still the Better Choice
enum Event {
Data(i32),
Error(String),
Done,
}
fn main() {
let events = vec![
Event::Data(10),
Event::Error("Oops".into()),
Event::Data(20),
Event::Done,
];
let mut iter = events.into_iter();
loop {
match iter.next() {
Some(Event::Data(x)) => println!("Received data: {}", x),
Some(Event::Error(e)) => println!("Error: {}", e),
Some(Event::Done) | None => break,
}
}
}
/*
Output:
Received data: 10
Error: Oops
Received data: 20
*/
Here, match
is more appropriate because you’re handling multiple variants differently.
Key Points
- Prefer
while let
for clean looping over a single expected variant. - Stick with
match
inside a loop when you need more than one pattern. - As with
if let
, usewhile let
to reduce boilerplate and enhance readability.
Destructuring with Patterns
Destructuring is one of the most powerful and expressive uses of patterns in Rust. It allows you to “take apart” complex data types like structs, tuples, and enums and bind their components to local variables in a single, elegant expression.
Destructuring isn’t limited to match
expressions—it works in let
bindings, function parameters, and even loops. In this section, we’ll explore how to destructure various kinds of composite types, starting with structs and tuples.
Destructuring Structs and Tuples
Destructuring lets you pull individual fields or elements out of a struct or tuple all at once, making your code more concise and expressive. Rather than accessing each field or index manually, you can bind multiple variables in a single pattern.
Let’s look at how this works in practice.
Example: Destructuring a Tuple in a let
Binding
fn main() {
let point = (3, 7);
let (x, y) = point;
println!("x: {}, y: {}", x, y);
}
/*
Output:
x: 3, y: 7
*/
Here, (x, y)
is a pattern that matches the shape of the point
tuple. The values are destructured and assigned to x
and y
.
Example: Destructuring a Struct in a let
Binding
struct Person {
name: String,
age: u8,
}
fn main() {
let user = Person {
name: String::from("Frank"),
age: 24,
};
let Person { name, age } = user;
println!("{} is {} years old.", name, age);
}
/*
Output:
Frank is 24 years old.
*/
In this example, Person { name, age }
is a pattern that matches the Person
struct and binds each field to a local variable.
Example: Destructuring with Field Renaming
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 10, y: 20 };
let Point { x: horizontal, y: vertical } = p;
println!("horizontal = {}, vertical = {}", horizontal, vertical);
}
/*
Output:
horizontal = 10, vertical = 20
*/
You can rename fields during destructuring by using field_name: new_name
syntax. This is useful when the original field name might conflict with another name or when a more meaningful name is preferred locally.
Key Points
- Destructuring patterns allow you to unpack tuples and structs efficiently.
- You can use them in
let
bindings, function parameters, and match arms. - Struct fields can be renamed while destructuring for clarity or to avoid naming conflicts.
Nested Destructuring
Nested destructuring lets you unpack values from within other values—such as structs inside tuples, tuples inside enums, or any other combination of nested data. Rust’s pattern matching system supports deep destructuring, allowing you to concisely bind inner values regardless of how deeply they’re nested.
This technique is especially useful when working with complex data structures, such as results from APIs, nested enums, or data transformation pipelines.
Let’s look at a few examples to see how this works in practice.
Example: Destructuring a Tuple Containing a Struct
struct Person {
name: String,
age: u8,
}
fn main() {
let info = (1, Person {
name: String::from("Frank"),
age: 24,
});
let (id, Person { name, age }) = info;
println!("ID: {}, Name: {}, Age: {}", id, name, age);
}
/*
Output:
ID: 1, Name: Frank, Age: 24
*/
Here, the Person
struct is inside a tuple, and we destructure both levels at once in a single pattern.
Example: Nested Destructuring in a Match Expression
enum Status {
Active { user: String, position: (i32, i32) },
Inactive,
}
fn main() {
let s = Status::Active {
user: String::from("Alice"),
position: (100, 200),
};
match s {
Status::Active { user, position: (x, y) } => {
println!("{} is active at ({}, {})", user, x, y);
}
Status::Inactive => println!("User is inactive."),
}
}
/*
Output:
Alice is active at (100, 200)
*/
In this case, the Status
enum contains a struct-like variant, and one of its fields (position
) is a tuple. We’re destructuring both the outer enum and the inner tuple in one go.
Example: Destructuring Struct and Tuple Together in Function Parameters
struct Location {
name: String,
coords: (f64, f64),
}
fn print_location(Location { name, coords: (lat, lon) }: Location) {
println!("{} is located at ({}, {})", name, lat, lon);
}
fn main() {
let city = Location {
name: String::from("Tokyo"),
coords: (35.6895, 139.6917),
};
print_location(city);
}
/*
Output:
Tokyo is located at (35.6895, 139.6917)
*/
Here, destructuring happens directly in the function parameter. This is concise and idiomatic when you need only specific pieces of a value.
Key Points
- Nested destructuring lets you bind values from deeply nested structures in one step.
- It works in
let
bindings,match
arms, and function parameters. - The deeper your data, the more powerful and concise nested destructuring becomes.
Pattern Matching in Function Parameters
Rust allows you to use patterns directly in function parameters. This means you can destructure tuples, structs, and even enums right in the function signature—eliminating the need for separate let
bindings inside the function body.
Pattern matching in parameters is a great way to:
- Keep your code concise and readable.
- Extract values only where they’re needed.
- Signal structure and intent directly at the function’s entry point.
Let’s explore a few practical examples of this powerful feature.
Example: Destructuring a Tuple in Function Parameters
fn print_coords((x, y): (i32, i32)) {
println!("Coordinates are: ({}, {})", x, y);
}
fn main() {
let point = (5, 10);
print_coords(point);
}
/*
Output:
Coordinates are: (5, 10)
*/
Instead of taking the whole tuple and then unpacking it inside the function, this version destructures it in the parameter list directly.
Example: Destructuring a Struct in Function Parameters
struct Rectangle {
width: u32,
height: u32,
}
fn area(Rectangle { width, height }: Rectangle) -> u32 {
width * height
}
fn main() {
let rect = Rectangle { width: 4, height: 7 };
println!("Area is: {}", area(rect));
}
/*
Output:
Area is: 28
*/
Here, the struct is destructured directly in the parameter, making it easy to work with the fields without introducing local bindings.
Example: Nested Destructuring in Parameters
struct Person {
name: String,
location: (f64, f64),
}
fn print_person(Person { name, location: (lat, lon) }: Person) {
println!("{} is at ({}, {})", name, lat, lon);
}
fn main() {
let p = Person {
name: String::from("Greg"),
location: (35.0, 139.0),
};
print_person(p);
}
/*
Output:
Greg is at (35, 139)
*/
This example shows that even nested patterns can be matched directly in the function signature, streamlining both clarity and brevity.
Key Points
- You can use tuple, struct, and nested destructuring in function parameters.
- This leads to concise and expressive code when you only need parts of a data structure.
- It’s most useful in smaller or utility functions where full structure unpacking makes the logic easier to follow.
Advanced Pattern Features
Rust’s pattern system is not only expressive but also fine-grained in how it evaluates and enforces pattern use. Once you’re comfortable with basic matching and destructuring, it’s important to understand some of the advanced features and rules that govern how and where patterns can be used.
In this section, we’ll cover subtle yet powerful topics like refutable vs. irrefutable patterns, ignored fields, rest patterns, and combining multiple patterns. These advanced features will help you write cleaner, more idiomatic, and more robust Rust code.
Refutable vs. Irrefutable Patterns
Rust makes a key distinction between irrefutable and refutable patterns. This distinction determines where you can use a pattern based on whether it can fail to match.
- Irrefutable pattern: A pattern that always matches. Required in
let
bindings and function parameters. - Refutable pattern: A pattern that might not match. Required in constructs like
if let
,while let
, andmatch
arms.
Understanding this helps explain why some patterns work in certain places and not others.
Rust divides patterns into two categories based on whether they always match or might fail to match:
Irrefutable Patterns
These are patterns that will always match the value they’re used with—there’s no way for them to fail.
- You must use irrefutable patterns in places where Rust expects a match to always succeed, like:
let
bindings- Function parameters
Examples of irrefutable patterns:
let x = 5;
— always succeeds.let (a, b) = (1, 2);
— always succeeds if the tuple shapes match.fn print_point((x, y): (i32, i32))
— always matches the tuple.
Refutable Patterns
These are patterns that might fail to match, because they’re only valid for certain variants or values.
- You must use refutable patterns in places where Rust can handle a potential failure, like:
match
armsif let
while let
Examples of refutable patterns:
Some(x)
— only matches if the value isSome
; fails onNone
.Ok(val)
— only matches if the value isOk
; fails onErr
.
Why Does This Matter?
Because Rust won’t let you write code that could silently fail to match in places where failure isn’t expected.
For example:
rustCopyEditlet Some(x) = maybe_value; // ❌ Not allowed if maybe_value is an Option
This fails to compile because Some(x)
might not match (what if it’s None
?). Rust forces you to be explicit about that possibility by writing:
rustCopyEditif let Some(x) = maybe_value { ... } // ✅ Allowed
🧠 Visual Summary
Pattern Type | Can It Fail? | Used In |
---|---|---|
Irrefutable | ❌ Never | let , function parameters |
Refutable | ✅ Yes | match , if let , while let |
Example: Irrefutable Pattern in a let
Binding
fn main() {
let (a, b) = (1, 2);
println!("a = {}, b = {}", a, b);
}
/*
Output:
a = 1, b = 2
*/
The pattern (a, b)
always matches a 2-element tuple, so it’s irrefutable and allowed in a let
binding.
Example: Invalid Refutable Pattern in let
(Compiler Error)
fn main() {
let Some(x) = Some(5);
println!("x = {}", x);
}
/*
Compiler Error:
error[E0005]: refutable pattern in local binding
--> src/main.rs:2:9
|
2 | let Some(x) = Some(5);
| ^^^^^^^ pattern `None` not covered
|
= note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
*/
This fails to compile because Some(x)
is refutable—it doesn’t cover None
. Since let
requires irrefutable patterns, the compiler rejects this.
Example: Correct Usage with if let
fn main() {
let value = Some(5);
if let Some(x) = value {
println!("x = {}", x);
} else {
println!("No value found.");
}
}
/*
Output:
x = 5
*/
Since Some(x)
might not match (value
could be None
), it’s refutable—so it belongs inside if let
, where fallbacks are allowed.
Key Points
- Irrefutable patterns match every time and are required in
let
and function parameters. - Refutable patterns may fail to match and must be used in
if let
,while let
, ormatch
. - The compiler enforces this distinction to guarantee pattern safety and correctness.
Ignoring Parts of a Pattern with _
In many pattern matching situations, you may only care about a few values and want to ignore the rest. Rust provides the _
wildcard for exactly this purpose. It matches any value but does not bind it to a variable.
You can use _
to:
- Ignore entire values in a pattern (e.g.,
Some(_)
) - Skip fields or elements in structs, tuples, or enums
- Silence unused variables to avoid compiler warnings
Let’s explore how _
can simplify your match logic and make your intent clearer.
Example: Ignoring an Unused Value in a Tuple
fn main() {
let coords = (10, 20);
let (x, _) = coords;
println!("x = {}", x);
}
/*
Output:
x = 10
*/
In this example, we only care about the x
coordinate, so we use _
to ignore y
.
Example: Ignoring Unused Fields in a Struct
struct Person {
name: String,
age: u8,
}
fn main() {
let p = Person {
name: String::from("Frank"),
age: 24,
};
let Person { name, .. } = p;
println!("Name: {}", name);
}
/*
Output:
Name: Frank
*/
Here, ..
acts as a shorthand to ignore all other fields not explicitly mentioned—in this case, age
. This is useful when working with structs that have many fields.
Example: Matching Enum Variants and Ignoring Inner Data
enum Status {
Success(i32),
Error(String),
Unknown,
}
fn main() {
let result = Status::Success(200);
match result {
Status::Success(_) => println!("Success!"),
Status::Error(_) => println!("Error occurred."),
Status::Unknown => println!("Unknown status."),
}
}
/*
Output:
Success!
*/
In this match, we don’t care about the exact value inside Success
or Error
, so we use _
to ignore the inner data while still handling the case.
Key Points
_
matches anything but does not bind the value.- Use it to avoid unnecessary variables or compiler warnings.
- The
..
shorthand in structs expands to “ignore all other fields.”
Using ..
to Match Remaining Fields
When working with structs or tuples that have multiple fields or elements, you often want to extract only a few of them and ignore the rest. In such cases, Rust allows you to use the ..
syntax (known as the rest pattern) to match all remaining fields or values without explicitly listing them.
This is especially useful in:
- Structs with many fields, where you only need one or two.
- Nested destructuring where most data is irrelevant.
- Future-proof code that avoids breakage when fields are added.
Let’s explore how and when to use the ..
pattern effectively.
Example: Using ..
in a Struct Pattern
struct Config {
theme: String,
font_size: u8,
show_line_numbers: bool,
}
fn main() {
let settings = Config {
theme: String::from("dark"),
font_size: 14,
show_line_numbers: true,
};
let Config { theme, .. } = settings;
println!("Theme selected: {}", theme);
}
/*
Output:
Theme selected: dark
*/
Here, we only care about the theme
field and ignore the rest using ..
. This keeps the pattern concise and avoids unnecessary bindings.
Example: Using ..
in a Match Expression
struct Packet {
source: String,
destination: String,
payload: Vec<u8>,
}
fn main() {
let packet = Packet {
source: String::from("192.168.0.1"),
destination: String::from("192.168.0.2"),
payload: vec![1, 2, 3],
};
match packet {
Packet { source, .. } => println!("Incoming packet from: {}", source),
}
}
/*
Output:
Incoming packet from: 192.168.0.1
*/
The match
arm uses ..
to ignore both destination
and payload
, focusing only on source
.
Example: Using ..
in Nested Struct Destructuring
struct User {
username: String,
details: Details,
}
struct Details {
email: String,
age: u8,
location: String,
}
fn main() {
let user = User {
username: String::from("frank"),
details: Details {
email: String::from("frank@example.com"),
age: 24,
location: String::from("USA"),
},
};
let User {
details: Details { age, .. },
..
} = user;
println!("User's age: {}", age);
}
/*
Output:
User's age: 24
*/
In this example, we destructure into a nested struct but ignore all other fields with ..
at each level. This keeps the syntax tidy, even in complex data structures.
Key Points
- Use
..
to ignore the rest of a struct or tuple’s fields. - Helps avoid unnecessary variable declarations and keeps code flexible.
- Especially useful when only one or two fields are relevant.
Combining Patterns with |
Rust allows you to combine multiple patterns into a single match arm using the |
operator (logical OR). This is useful when you want multiple values or shapes to be handled the same way, without repeating code across multiple match arms.
You can use |
in:
match
expressionsif let
andwhile let
(with caution)- Anywhere patterns are accepted, as long as the surrounding context is compatible
The |
operator must be used with patterns of the same structure. You can’t mix unrelated patterns unless they share a common form.
Example: Matching Multiple Values
fn main() {
let number = 3;
match number {
1 | 2 => println!("One or Two"),
3 => println!("Three"),
_ => println!("Something else"),
}
}
/*
Output:
Three
*/
This is a straightforward use of |
to match either 1
or 2
in a single arm.
Example: Combining Enum Variants with Similar Structure
enum Command {
Start,
Resume,
Pause,
Stop,
}
fn main() {
let cmd = Command::Start;
match cmd {
Command::Start | Command::Resume => println!("Start or Resume action"),
Command::Pause => println!("Pausing"),
Command::Stop => println!("Stopping"),
}
}
/*
Output:
Start or Resume action
*/
Here, both Start
and Resume
are handled the same way, so we group them with |
to reduce redundancy.
Example: Combining Patterns with Binding and Guard
fn main() {
let code = Some(500);
match code {
Some(200 | 201) => println!("Success"),
Some(400 | 404) => println!("Client error"),
Some(n) if n >= 500 => println!("Server error"),
_ => println!("Unknown response"),
}
}
/*
Output:
Server error
*/
Here we:
- Combine
200 | 201
and400 | 404
for shared logic, - Use a guard
if n >= 500
for theSome(n)
fallback, - End with a wildcard to cover all other cases.
Key Points
- Use
|
to combine multiple patterns that share the same action. - Patterns must be structurally compatible (e.g., all literals, or all of the same enum variant).
- Helps reduce repetition and makes your
match
arms more concise.
Common Mistakes and Gotchas
While Rust’s pattern matching system is powerful and expressive, it’s also strict. That strictness helps you write safer code, but it can sometimes lead to confusion, especially for developers new to Rust or coming from more permissive languages.
In this section, we’ll cover some of the most common mistakes and subtle traps developers fall into when using pattern matching in Rust. Understanding these early will help you write more idiomatic and error-free code.
Non-Exhaustive Matches
Rust requires all match
expressions to be exhaustive, meaning every possible input must be handled by one of the match arms. If your match doesn’t cover all cases, the compiler will emit an error to protect you from runtime surprises.
This is a core safety feature in Rust, but it can catch you off guard—especially when working with:
- Enums with multiple variants
bool
,Option
, orResult
types- Patterns that don’t include wildcards (
_
)
Example: Match Missing an Enum Variant (Compiler Error)
enum Direction {
North,
South,
East,
West,
}
fn main() {
let dir = Direction::East;
match dir {
Direction::North => println!("Going north"),
Direction::South => println!("Going south"),
}
}
/*
Compiler Error:
error[E0004]: non-exhaustive patterns: `East` and `West` not covered
--> src/main.rs:9:11
|
9 | match dir {
| ^^^ pattern `East` not covered
|
= help: ensure that all possible cases are being handled, possibly by adding a wildcard arm: `_ => { /* ... */ }`
*/
This fails because East
and West
aren’t handled. The compiler refuses to let this pass.
Example: Fixing the Error with a Wildcard _
fn main() {
enum Direction {
North,
South,
East,
West,
}
let dir = Direction::East;
match dir {
Direction::North => println!("Going north"),
Direction::South => println!("Going south"),
_ => println!("Going in another direction"),
}
}
/*
Output:
Going in another direction
*/
The _
pattern acts as a catch-all and makes the match exhaustive. This is useful when you don’t need to handle every case explicitly.
Example: Non-Exhaustive Match on bool
fn main() {
let flag = true;
match flag {
true => println!("Flag is set"),
}
}
/*
Compiler Error:
error[E0004]: non-exhaustive patterns: `false` not covered
--> src/main.rs:4:11
|
4 | match flag {
| ^^^^ pattern `false` not covered
*/
Even with primitive types like bool
, you must handle all possibilities. Rust enforces this rule everywhere match
is used.
Key Points
- Rust requires
match
expressions to be exhaustive. - Omitting possible patterns causes a compile-time error, not a runtime panic.
- Use
_
to handle “everything else” when you don’t need specific handling.
Overuse of if let
The if let
construct is a concise way to match a single pattern—most commonly used with Option
and Result
. But like any tool, it can be overused or misapplied, especially when you start nesting it or using it to replace what would be clearer as a match
.
Overusing if let
can lead to:
- Omitted cases you forgot to handle
- Reduced readability when combining multiple conditions
- More verbose or error-prone logic when nesting patterns or branching further
Let’s look at some examples that demonstrate both overuse and better alternatives.
Example: Overused if let
with Missed Case
fn main() {
let response = Some("OK");
if let Some("OK") = response {
println!("Success!");
}
// No else or fallback — silently ignores all other cases
}
/*
Output:
Success!
*/
This works, but it’s fragile—if response
had been None
or Some("Error")
, nothing would happen and you’d get no feedback.
Example: Better: Use match
for Clarity and Coverage
fn main() {
let response = Some("Error");
match response {
Some("OK") => println!("Success!"),
Some(msg) => println!("Other response: {}", msg),
None => println!("No response received."),
}
}
/*
Output:
Other response: Error
*/
Using match
here makes the logic explicit and ensures you don’t accidentally ignore valid states.
Example: Overusing Nested if let
for Multiple Checks
fn main() {
let config = Some(("theme", "dark"));
if let Some((key, val)) = config {
if key == "theme" {
println!("Theme set to {}", val);
}
}
}
/*
Output:
Theme set to dark
*/
This works, but the nested logic quickly becomes harder to follow—especially if more branches are needed.
Alternative: Combine Matching with match
fn main() {
let config = Some(("theme", "dark"));
match config {
Some(("theme", value)) => println!("Theme set to {}", value),
Some((key, _)) => println!("Unhandled config key: {}", key),
None => println!("No config found."),
}
}
/*
Output:
Theme set to dark
*/
This version is clearer and more maintainable, especially if new cases are added later.
Key Points
if let
is great for one pattern, but can become messy when overused.- Prefer
match
when:- You need to handle multiple cases,
- You want exhaustiveness and safety,
- Or when the logic involves nesting or branching.
- Always ask: “Would a match block be clearer here?”
Misusing Bindings in Complex Patterns
Rust allows you to bind matched values to variables while matching against patterns. This is powerful, but when patterns become more complex—especially when nested or combined—it’s easy to accidentally:
- Bind incorrectly (e.g., shadow a value unintentionally),
- Use binding where it’s not needed,
- Forget that bindings occur after a pattern matches, not before.
These mistakes can lead to subtle bugs, confusing logic, or even compilation errors.
Let’s walk through a few examples of common binding misuse and how to do it correctly.
Example: Binding Instead of Matching a Value
fn main() {
let status = Some(200);
match status {
Some(code) => {
if code == 404 {
println!("Not Found");
} else {
println!("Code: {}", code);
}
}
None => println!("No response received."),
}
}
/*
Output:
Code: 200
*/
This works—but we had to do a second check after binding to code
. A cleaner approach is to match directly in the pattern.
Example: Cleaner Match with Nested Pattern Matching
fn main() {
let status = Some(404);
match status {
Some(404) => println!("Not Found"),
Some(code) => println!("Other code: {}", code),
None => println!("No response"),
}
}
/*
Output:
Not Found
*/
Here, we match the specific value 404
before binding, eliminating the need for an if
condition inside the arm.
Example: Accidental Shadowing with @
Bindings
#[derive(Debug)]enum Response {
Ok(u32),
Err(String),
}
fn main() {
let res = Response::Ok(200);
match res {
val @ Response::Ok(val) => println!("Received status: {}, full: {:?}", val, val),
Response::Err(e) => println!("Error: {}", e),
}
}
/*
error[E0416]: identifier `val` is bound more than once in the same pattern
--> src/main.rs:10:28
|
10 | val @ Response::Ok(val) => println!("Received status: {}, full: {:?}", val, val),
| ^^^ used in a pattern more than once*/
This results in a compile-time error because the identifier val
is bound more than once.
Fixed Version with Unique Bindings
#[derive(Debug)]
enum Response {
Ok(u32),
Err(String),
}
fn main() {
let res = Response::Ok(200);
match res {
full @ Response::Ok(code) => println!("Received status: {}, full: {:?}", code, full),
Response::Err(e) => println!("Error: {}", e),
}
}
/*
Output:
Received status: 200, full: Ok(200)
*/
Now each binding has a unique name and the logic is clear.
Key Points
- Don’t bind too early—match the value directly in the pattern when possible.
- Avoid redundant or conflicting variable names in complex patterns.
- Use
@
patterns with care: they bind the whole and part of a structure, so naming clarity is crucial.
Pattern matching in Rust is more than just a control flow construct—it’s a powerful language feature that promotes clarity, safety, and expressiveness. From simple match
statements to advanced destructuring and binding techniques, Rust’s pattern system helps you write code that’s both concise and robust.
By understanding how and where patterns can be used—and avoiding common pitfalls—you’re well on your way to writing more idiomatic and maintainable Rust. Keep practicing, explore patterns in real-world code, and let the compiler be your guide.
Patterns are not just syntax—they’re a core part of Rust’s safety model. Let them do the heavy lifting so you can focus on logic, not plumbing.
We hope you’ve enjoyed this deep dive into Rust matching and patterns! Thank you so much for stopping by and for allowing ByteMagma to join you as you master Rust programming!
Leave a Reply