Skip to content
rusthacks.dev
Rust

Rust Pattern Matching From Simple Match to Slice Patterns

Rust match is exhaustive: the compiler checks every case. Learn literals, ranges, guards, bindings, tuple, struct and slice patterns with working examples.

7 min read
Summarize in AI
Flat illustration for the post “Rust Pattern Matching From Simple Match to Slice Patterns”, composed around architecture.

Pattern matching is where Rust's design pays off the most often: you describe the shapes a value can take, and the compiler checks that you handled all of them. This post goes from the simplest match to destructuring and slice patterns, with the error you hit when a case is missing and the one you hit when a match moves a value you still need. All programs ran on Rust 1.99, edition 2024.

Literals, ranges, bindings and guards

src/main.rsrust
fn describe(n: i32) -> String {
    match n {
        0 => "zero".to_string(),
        1 | 2 | 3 => "small".to_string(),
        x @ 4..=9 => format!("medium ({x})"),
        x if x < 0 => format!("negative ({x})"),
        x => format!("large ({x})"),
    }
}

fn main() {
    for n in [0, 2, 7, -5, 100] {
        println!("{n:>4} -> {}", describe(n));
    }
}
Text
   0 -> zero
   2 -> small
   7 -> medium (7)
  -5 -> negative (-5)
 100 -> large (100)

Arms are tried from the top and the first match wins. 0 is a literal. 1 | 2 | 3 is an or-pattern. x @ 4..=9 does two things at once: it tests that the number is in the range and it binds the number to x so the arm can use it. x if x < 0 is a guard, an extra boolean condition checked after the pattern matches. The last arm, a bare x, matches anything left over and gives it a name.

Order matters. If the guard arm came before the range arm, the negative numbers would still reach it correctly, but a pattern like x => ... placed first would swallow everything and the arms below it could never run; the compiler warns about unreachable patterns when it can see this.

Exhaustiveness: the compiler counts the cases

The rule that makes match safe is that it must cover every possible value. Leave a gap and the program does not build:

src/main.rsrust
fn main() {
    let n: u8 = 7;
    let size = match n {
        0 => "zero",
        1..=9 => "digit",
        10..=99 => "two digits",
    };
    println!("{size}");
}
Text
error[E0004]: non-exhaustive patterns: `100_u8..=u8::MAX` not covered
 --> src/main.rs:3:22
  |
3 |     let size = match n {
  |                      ^ pattern `100_u8..=u8::MAX` not covered
  |
  = note: the matched value is of type `u8`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |

The type is u8, so the values run from 0 to 255, and the arms cover 0, 1 to 9 and 10 to 99. The error names the missing range exactly, 100_u8..=u8::MAX, and the help block shows the arm to add. For integers the usual answer is a catch-all _ arm. For enums the better answer is to list every variant and leave out _, because then adding a variant later breaks the build at every match that needs to learn about it, which is the whole point of the next post, Rust enums.

Destructuring structs, enums and tuples

Patterns can be as deep as the data. Here a match looks inside an enum, inside a struct inside it, and tests a field against a literal:

src/main.rsrust
#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

enum Shape {
    Circle { center: Point, radius: u32 },
    Rect(Point, Point),
    Empty,
}

fn area_hint(shape: &Shape) -> String {
    match shape {
        Shape::Circle { radius: 0, .. } => "a dot".to_string(),
        Shape::Circle { center: Point { x: 0, y: 0 }, radius } => format!("circle at origin, r={radius}"),
        Shape::Circle { radius, .. } => format!("circle, r={radius}"),
        Shape::Rect(Point { x: x1, y: y1 }, Point { x: x2, y: y2 }) => {
            format!("rect {}x{}", (x2 - x1).abs(), (y2 - y1).abs())
        }
        Shape::Empty => "nothing".to_string(),
    }
}

fn main() {
    let shapes = [
        Shape::Circle { center: Point { x: 0, y: 0 }, radius: 3 },
        Shape::Circle { center: Point { x: 4, y: 4 }, radius: 0 },
        Shape::Circle { center: Point { x: 1, y: 2 }, radius: 5 },
        Shape::Rect(Point { x: 1, y: 1 }, Point { x: 4, y: 5 }),
        Shape::Empty,
    ];
    for s in &shapes {
        println!("{}", area_hint(s));
    }

    let pair = (3, -3);
    match pair {
        (x, y) if x + y == 0 => println!("opposites"),
        (x, _) if x % 2 == 1 => println!("odd first"),
        _ => println!("something else"),
    }
}
Text
circle at origin, r=3
a dot
circle, r=5
rect 3x4
nothing
opposites

Read the arms in order. Shape::Circle { radius: 0, .. } matches a circle whose radius is zero and ignores the other field with ... The next arm matches a circle whose center is the origin by nesting a Point { x: 0, y: 0 } pattern inside; the one after catches any other circle. Shape::Rect(Point { x: x1, y: y1 }, Point { x: x2, y: y2 }) pulls four numbers out of two nested structs in one line. The tuple match at the end uses guards to compare values that the patterns bind: (x, y) if x + y == 0 printed opposites for (3, -3).

Notice match shape with shape: &Shape. Matching a reference lets the patterns bind references into the data, so nothing is moved and the shapes can still be used afterwards. That detail is the next section's error.

Slice patterns

Patterns also match the shape of a slice, including its length:

src/main.rsrust
fn summarize(items: &[i32]) -> String {
    match items {
        [] => "empty".to_string(),
        [only] => format!("one item: {only}"),
        [first, second] => format!("pair: {first}, {second}"),
        [first, .., last] if first == last => format!("starts and ends with {first}"),
        [first, rest @ ..] => format!("first {first}, then {} more", rest.len()),
    }
}

fn main() {
    println!("{}", summarize(&[]));
    println!("{}", summarize(&[7]));
    println!("{}", summarize(&[1, 2]));
    println!("{}", summarize(&[5, 8, 5]));
    println!("{}", summarize(&[1, 2, 3, 4]));

    let cmd = "git commit -m fix";
    let parts: Vec<&str> = cmd.split_whitespace().collect();
    match parts.as_slice() {
        ["git", "commit", "-m", msg] => println!("commit with message {msg:?}"),
        ["git", sub, ..] => println!("some other git command: {sub}"),
        _ => println!("not git"),
    }
}
Text
empty
one item: 7
pair: 1, 2
starts and ends with 5
first 1, then 3 more
commit with message "fix"

[] is the empty slice, [only] exactly one element, [first, second] exactly two. [first, .., last] matches any slice with at least two elements, and the guard if first == last narrows it to those that start and end with the same value; 5, 8, 5 matched it. [first, rest @ ..] binds the first element and the remaining slice. The bottom half is the practical use: matching a command line split into words, ["git", "commit", "-m", msg], picks out the message in one pattern instead of four length and index checks. The compiler also checks these for exhaustiveness, so the five arms in summarize provably cover every possible length.

Matching moves things

A match on a value whose type does not implement Copy moves out of it when a pattern binds by value:

src/main.rsrust
fn main() {
    let name: Option<String> = Some("ferris".to_string());
    match name {
        Some(n) => println!("hello {n}"),
        None => println!("nobody"),
    }
    println!("{name:?}");
}
Text
error[E0382]: borrow of partially moved value: `name`
 --> src/main.rs:7:16
  |
4 |         Some(n) => println!("hello {n}"),
  |              - value partially moved here
...
7 |     println!("{name:?}");
  |                ^^^^ value borrowed here after partial move
  |
  = note: partial move occurs because value has type `String`, which does not implement the `Copy` trait

Some(n) took the String out of name, so name is partially moved and the later println! cannot borrow it. The compiler's suggestion, Some(ref n), works, and the cleaner fix is to match on a reference:

src/main.rsrust
fn main() {
    let name: Option<String> = Some("ferris".to_string());
    match &name {
        Some(n) => println!("hello {n}"),
        None => println!("nobody"),
    }
    println!("{name:?}");
}
Text
hello ferris
Some("ferris")

With match &name the pattern Some(n) binds n as a &String, the match does not consume the option, and name is intact afterwards. The same reasoning is why for x in &v borrows and for x in v consumes. Ownership is the full story, and Option is the type you will match on most.

When you only care about one case

Writing a full match with a throwaway _ => {} arm is noise. Three forms handle the single-case version:

src/main.rsrust
fn main() {
    let input = ["42", "x", "7"];

    for text in input {
        if let Ok(n) = text.parse::<i32>() {
            println!("{text} -> {}", n * 2);
        } else {
            println!("{text} is not a number");
        }
    }

    let value = "17";
    let Ok(n) = value.parse::<u32>() else {
        panic!("bad config value {value:?}");
    };
    println!("n = {n}");

    println!("{}", matches!(n, 10..=20));
}
Text
42 -> 84
x is not a number
7 -> 14
n = 17
true

if let Ok(n) = text.parse::<i32>() runs the first block for a successful parse and the else for anything else, which is why x printed as not a number. let Ok(n) = value.parse::<u32>() else { panic!(...) }; binds n for the rest of the function and diverges in the else block, a pattern for configuration values that must be valid. matches!(n, 10..=20) returns a bool and is the neat way to test a range or a variant inside a condition. Use a full match when two or more cases matter, because exhaustiveness checking is what you give up with if let.

Habits

  • Prefer matching on every variant over a _ arm when the type is an enum you own.
  • Put the most specific patterns first.
  • Match on &value when you need the value afterwards.
  • Use guards for conditions a pattern cannot express, and keep them cheap.
  • Keep arms short. If an arm grows past a few lines, call a function.

Where patterns are the wrong tool

Patterns test structure, not behaviour. A long chain of guards that each call a method is an if/else if chain in disguise, and writing it as one reads better. Patterns also cannot match on values computed at run time, only on literals and constants known at compile time; to compare against a variable, use a guard (x if x == limit). And a match over a type you do not control, such as another crate's non-exhaustive enum, needs a wildcard arm by design, because the crate may add variants without breaking you.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.