Pattern Matching
Pattern matching is Rust's control flow superpower. The match expression is exhaustive, type-safe, and compiles to efficient code. It replaces switch statements, if-else chains, and type checks found in other languages — and does it better. Once you internalize pattern matching, you will wonder how you ever wrote code without it.
match Expressions
A match expression compares a value against a series of patterns and runs the code for the first match:
fn http_status(code: u16) -> &'static str {
match code {
200 => "OK",
201 => "Created",
301 => "Moved Permanently",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
}
}
fn main() {
for code in [200, 404, 500, 999] {
println!("{}: {}", code, http_status(code));
}
}
200: OK
404: Not Found
500: Internal Server Error
999: Unknown
_ is the wildcard pattern — it matches anything. Every match must be exhaustive: every possible value must be handled. The compiler enforces this.
Destructuring Enums
Pattern matching shines when extracting data from enums:
#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
fn describe(shape: &Shape) -> String {
match shape {
Shape::Circle { radius } => format!("circle with radius {}", radius),
Shape::Rectangle { width, height } => {
format!("{}x{} rectangle", width, height)
}
Shape::Triangle { .. } => String::from("a triangle"),
}
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 5.0 },
Shape::Rectangle { width: 4.0, height: 6.0 },
Shape::Triangle { base: 3.0, height: 8.0 },
];
for shape in &shapes {
println!("{}: area = {:.2}", describe(shape), area(shape));
}
}
circle with radius 5: area = 78.54
4x6 rectangle: area = 24.00
a triangle: area = 12.00
{ .. } ignores fields you do not care about. Each arm can contain a block {} for multi-line logic.
if let & while let
When you only care about one variant, if let is cleaner than a full match:
fn main() {
let config_value: Option<String> = Some(String::from("production"));
// Full match — verbose for a single case:
match &config_value {
Some(env) => println!("Environment: {}", env),
None => {}
}
// if let — same thing, less noise:
if let Some(env) = &config_value {
println!("Environment: {}", env);
}
// if let with else:
if let Some(env) = &config_value {
println!("Running in: {}", env);
} else {
println!("No environment configured");
}
}
Environment: production
Environment: production
Running in: production
while let is useful for consuming iterators or channels:
fn main() {
let mut stack = vec![1, 2, 3, 4, 5];
while let Some(top) = stack.pop() {
println!("Popped: {}", top);
}
println!("Stack is empty");
}
Popped: 5
Popped: 4
Popped: 3
Popped: 2
Popped: 1
Stack is empty
Destructuring Structs
Pattern matching works on structs, tuples, and nested data:
struct Point {
x: f64,
y: f64,
}
fn classify_point(p: &Point) -> &str {
match (p.x == 0.0, p.y == 0.0) {
(true, true) => "origin",
(true, false) => "on y-axis",
(false, true) => "on x-axis",
(false, false) => "general point",
}
}
fn main() {
let points = vec![
Point { x: 0.0, y: 0.0 },
Point { x: 0.0, y: 5.0 },
Point { x: 3.0, y: 0.0 },
Point { x: 1.0, y: 2.0 },
];
for p in &points {
println!("({}, {}): {}", p.x, p.y, classify_point(p));
}
}
(0, 0): origin
(0, 5): on y-axis
(3, 0): on x-axis
(1, 2): general point
Tuple matching is particularly useful for handling pairs of values without nested if-else chains.
Guard Clauses
Match arms can include if guards for additional conditions:
fn classify_temperature(celsius: f64) -> &'static str {
match celsius {
t if t < -40.0 => "extreme cold",
t if t < 0.0 => "below freezing",
t if t < 10.0 => "cold",
t if t < 25.0 => "comfortable",
t if t < 35.0 => "warm",
t if t < 45.0 => "hot",
_ => "extreme heat",
}
}
fn main() {
let temps = [-50.0, -10.0, 5.0, 22.0, 30.0, 40.0, 55.0];
for t in temps {
println!("{:.0}C: {}", t, classify_temperature(t));
}
}
-50C: extreme cold
-10C: below freezing
5C: cold
22C: comfortable
30C: warm
40C: hot
55C: extreme heat
Guards are checked at runtime after the pattern matches. They do not affect exhaustiveness — the compiler may still require a _ arm.
Matching on References
When you match on a reference, the patterns bind references too:
fn process_names(names: &[String]) {
for name in names {
match name.as_str() {
"admin" => println!("{}: elevated privileges", name),
"guest" => println!("{}: read-only access", name),
n if n.starts_with("bot_") => println!("{}: automated account", name),
_ => println!("{}: standard user", name),
}
}
}
fn main() {
let names = vec![
String::from("alice"),
String::from("admin"),
String::from("bot_monitor"),
String::from("guest"),
];
process_names(&names);
}
alice: standard user
admin: elevated privileges
bot_monitor: automated account
guest: read-only access
Nested Pattern Matching
Patterns can be nested to handle complex data structures:
#[derive(Debug)]
enum Expr {
Num(f64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Neg(Box<Expr>),
}
fn eval(expr: &Expr) -> f64 {
match expr {
Expr::Num(n) => *n,
Expr::Add(a, b) => eval(a) + eval(b),
Expr::Mul(a, b) => eval(a) * eval(b),
Expr::Neg(e) => -eval(e),
}
}
fn simplify_display(expr: &Expr) -> String {
match expr {
Expr::Num(n) => format!("{}", n),
Expr::Add(a, b) => format!("({} + {})", simplify_display(a), simplify_display(b)),
Expr::Mul(a, b) => format!("({} * {})", simplify_display(a), simplify_display(b)),
Expr::Neg(e) => format!("-({})", simplify_display(e)),
}
}
fn main() {
// (2 + 3) * -(4)
let expr = Expr::Mul(
Box::new(Expr::Add(
Box::new(Expr::Num(2.0)),
Box::new(Expr::Num(3.0)),
)),
Box::new(Expr::Neg(Box::new(Expr::Num(4.0)))),
);
println!("{} = {}", simplify_display(&expr), eval(&expr));
}
((2 + 3) * -(4)) = -20
This pattern is the foundation of interpreters, compilers, and any tree-processing code.
Or Patterns & Binding
Multiple patterns can share an arm with |:
fn is_vowel(c: char) -> bool {
matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'A' | 'E' | 'I' | 'O' | 'U')
}
fn categorize(value: i32) -> &'static str {
match value {
0 => "zero",
1 | 2 | 3 => "small",
4..=9 => "medium",
10..=99 => "large",
_ => "huge",
}
}
fn main() {
println!("'a' is vowel: {}", is_vowel('a'));
println!("'b' is vowel: {}", is_vowel('b'));
for n in [0, 2, 7, 42, 1000] {
println!("{}: {}", n, categorize(n));
}
}
'a' is vowel: true
'b' is vowel: false
0: zero
2: small
7: medium
42: large
1000: huge
The matches! macro returns a bool — useful when you just need to check if a value matches a pattern.
Real-World Example: Command Parser
#[derive(Debug)]
enum Token {
Get { key: String },
Set { key: String, value: String },
Delete { key: String },
List,
Quit,
Unknown(String),
}
fn parse_command(input: &str) -> Token {
let parts: Vec<&str> = input.trim().splitn(3, ' ').collect();
match parts.as_slice() {
["GET", key] => Token::Get { key: key.to_string() },
["SET", key, value] => Token::Set {
key: key.to_string(),
value: value.to_string(),
},
["DEL", key] => Token::Delete { key: key.to_string() },
["LIST"] => Token::List,
["QUIT"] | ["EXIT"] => Token::Quit,
_ => Token::Unknown(input.to_string()),
}
}
fn execute(token: &Token) {
match token {
Token::Get { key } => println!("Getting value for '{}'", key),
Token::Set { key, value } => println!("Setting '{}' = '{}'", key, value),
Token::Delete { key } => println!("Deleting '{}'", key),
Token::List => println!("Listing all keys"),
Token::Quit => println!("Goodbye"),
Token::Unknown(cmd) => println!("Unknown command: {}", cmd.trim()),
}
}
fn main() {
let commands = ["GET user:1", "SET user:2 Alice", "DEL user:3", "LIST", "QUIT", "HELP"];
for cmd in commands {
let token = parse_command(cmd);
execute(&token);
}
}
Getting value for 'user:1'
Setting 'user:2' = 'Alice'
Deleting 'user:3'
Listing all keys
Goodbye
Unknown command: HELP
Slice patterns ([first, second, ..]) are powerful for parsing structured input.
Common Pitfalls
- Writing if-else chains when match would be clearer. If you are comparing one value against multiple possibilities, use
match. It is more readable and the compiler checks exhaustiveness. - Forgetting exhaustiveness. Removing the
_arm and adding a new enum variant forces you to handle it everywhere. This is a feature — use it. Avoid_ => panic!()when you could handle each case. - Overusing
if letfor multiple cases. If you have 2+ patterns to match, usematch.if letis for the single-case shortcut. - Not using guard clauses. Complex conditions in match arms are cleaner as guards (
if condition) than as nested if-else inside the arm body. - Ignoring the
matches!macro. When you just need a boolean check against a pattern,matches!(value, pattern)is cleaner than a fullmatchthat returnstrue/false. - Pattern matching without destructuring. If you match on an enum but do not use the inner data, check if you actually need the match or if a method on the enum would be more appropriate.
Key Takeaways
matchis exhaustive: the compiler ensures you handle every case. This catches bugs when you add new variants.if letandwhile letare shortcuts for matching a single pattern.- Patterns can destructure enums, structs, tuples, and slices — even nested ones.
- Guard clauses add runtime conditions to pattern arms.
- Or patterns (
|) and ranges (..=) keep match arms concise. - Pattern matching replaces switch statements, type checks, and if-else chains with something that is both more powerful and safer.