How to Use Nested Pattern Matching in Rust

Use nested patterns in a match expression to destructure enum variants and access inner data directly.

When flat matching isn't enough

You're processing a network packet. The packet wraps a header and a payload. The header holds a version number. The payload holds a command type. Your logic depends on the combination of version and command. A flat match on the packet gives you the header and payload as opaque blobs. You reach into the header for the version, reach into the payload for the command, and branch. The code becomes a pyramid of intermediate variables. You care about the version and command, not the containers.

Rust lets you match the structure directly. You specify the shape of the packet, the header inside it, and the payload, all in one pattern. The compiler checks every layer at once. If the structure matches, you get the inner values bound to variables immediately. If any layer fails, the arm is skipped.

Patterns that go deep

Patterns describe shape. Simple patterns match a single value. Nested patterns match a value that contains other values and recurse into those values to check their shape.

Think of unpacking a delivery. You receive a box. Inside the box is a bubble-wrapped envelope. Inside the envelope is the item. A nested pattern says: "I expect a box containing an envelope containing a widget." You don't stop at the box. You don't stop at the envelope. You specify the whole chain. If the box contains a book instead of an envelope, the match fails. If the envelope is empty, the match fails. The pattern enforces the entire hierarchy.

This works with structs, tuples, enums, and any combination. You can nest as deep as needed. The syntax stays the same: write the pattern for the outer type, and inside the fields or variants, write the patterns for the inner types.

Patterns are structural assertions. If the data doesn't fit the shape, the code doesn't run.

Minimal example

Here's the smallest case: a rectangle defined by two points, destructured in one go.

struct Point {
    x: i32,
    y: i32,
}

struct Rectangle {
    top_left: Point,
    bottom_right: Point,
}

/// Calculates area by destructuring nested points.
fn area(rect: &Rectangle) -> i32 {
    match rect {
        // Match reference to Rectangle, then drill into Points.
        // &Point { ... } matches the reference without moving data.
        &Rectangle {
            top_left: Point { x: x1, y: y1 },
            bottom_right: Point { x: x2, y: y2 },
        } => {
            // Width and height are absolute differences.
            let width = (x2 - x1).abs();
            let height = (y2 - y1).abs();
            width * height
        }
    }
}

The pattern &Rectangle { top_left: Point { x: x1, ... } } does three things. It checks the value is a Rectangle. It checks top_left is a Point. It binds the x field to x1. You get x1, y1, x2, y2 in a single arm. The & at the start matches the reference. The &Point inside matches the reference to the point. This avoids moving data out of the borrow.

Convention note: &Rectangle { ... } is the "match ref" style. Modern Rust often prefers Rectangle { top_left: &Point { x: x1, ... }, ... } when the match value is &rect. The explicit & inside fields makes it clear you are borrowing the inner value. The community leans toward explicit borrowing in nested patterns for readability.

What the compiler does

When you write a nested pattern, the compiler generates code that checks the structure layer by layer. It starts with the outermost type. If that matches, it moves to the next level. If any level fails, the compiler jumps to the next arm.

The compiler also handles variable binding. Every identifier in the pattern that isn't a literal or a variant name becomes a binding. The binding captures the value at that position. If you use the same name at different levels, the inner binding shadows the outer one. This is consistent with variable shadowing elsewhere.

Nested patterns interact with ownership. By default, patterns move values out of the data structure. If you match an owned value, inner values move into variables. If you match a reference, you cannot move data out of a borrow. The compiler rejects code that tries to move a non-Copy value out of a reference.

The compiler treats patterns as contracts. If the contract can't be fulfilled due to ownership, the code won't compile.

Refutable vs irrefutable patterns

Patterns fall into two categories: refutable and irrefutable. A refutable pattern might fail to match. Some(x) is refutable because the value could be None. An irrefutable pattern always matches. x is irrefutable because it matches anything.

match requires refutable patterns. let requires irrefutable patterns. Nested patterns can be either. Point { x, y } is irrefutable if the value is a Point. Some(Point { x, y }) is refutable because the outer Some might fail.

Understanding this distinction helps you choose between match, if let, and let. If the pattern is irrefutable, let is the right tool. If it's refutable, you need match or if let.

Real-world nesting

Nested patterns shine with layered types like Result<Option<T>, E>. Here's how to flatten the error handling.

enum DbError {
    ConnectionFailed,
    QueryTimeout,
}

struct User {
    id: u32,
    name: String,
}

/// Handles nested Result<Option> from a database fetch.
fn handle_user_fetch(id: u32) {
    // Simulate fetch returning Result<Option<User>, DbError>.
    let result: Result<Option<User>, DbError> = fetch_user(id);

    match result {
        // Match Ok, then match Some inside.
        // Binds User directly to 'user'.
        Ok(Some(user)) => println!("Found: {} (id: {})", user.name, user.id),
        Ok(None) => println!("User {} not found.", id),
        Err(DbError::ConnectionFailed) => eprintln!("DB down."),
        Err(DbError::QueryTimeout) => eprintln!("Timeout."),
    }
}

fn fetch_user(_id: u32) -> Result<Option<User>, DbError> {
    Ok(None)
}

This avoids nested if let or double match. You handle all four cases in a flat structure. The pattern Ok(Some(user)) is concise. It tells you exactly what shape you're expecting.

You can combine nested patterns with guards. A guard is an if condition attached to a pattern. The guard runs only if the pattern matches. This adds runtime checks on top of structural checks.

enum Version { V1, V2 }
struct Header { version: Version }
struct Payload { priority: u8, data: Vec<u8> }
struct Packet { header: Header, payload: Payload }

fn process_packet(packet: Packet) {
    match packet {
        // Match structure, then check condition on bound variable.
        Packet { header: Header { version: V2 }, payload: Payload { priority, data } }
            if priority > 5 => {
            println!("High priority V2: {:?}", data);
        }
        Packet { header: Header { version: V2 }, .. } => {
            println!("Standard V2.");
        }
        _ => println!("Ignored."),
    }
}

The guard if priority > 5 only executes if the packet is V2 and has a payload with a priority field. This keeps logic tight. You don't unwrap version or priority before checking them.

Tuples, slices, and rest patterns

Nested patterns work with tuples and tuple structs too. You can nest patterns inside tuple positions. let (x, (y, z)) = data; destructures a tuple of tuples. This is common when returning multiple results from functions. match (status, result) { (Ok, Some(v)) => ... } combines tuple nesting with enum nesting.

Nested patterns also work with slices. You can match a slice inside a struct. struct Data { values: Vec<i32> }. match data { Data { values: [first, second, ..] } => ... } extracts the first two elements and ignores the rest. Slice patterns are powerful for parsing fixed-format data. You can nest slice patterns inside struct patterns. Packet { payload: [0x01, 0x02, data @ ..] } matches a packet with a specific header and binds the rest of the payload.

Convention note: When using .. in struct patterns, place it at the end. Config { host, port, .. } is preferred over Config { .., host, port }. Placing .. at the end makes it clear which fields are being extracted. It also matches the order of fields in the struct definition, improving readability. The compiler accepts .. anywhere, but the community standard is end-of-pattern.

Pitfalls and errors

Nested patterns trip beginners in two ways. The first is moving data out of a borrow. The second is forgetting that patterns match structure, not just values.

Moving out of borrowed content

If you match a reference, you cannot move inner values out unless they implement Copy. Structs with String or Vec do not implement Copy. If you try to bind them, the compiler rejects you with E0507 (cannot move out of borrowed content).

struct Config {
    host: String,
    port: u16,
}

fn print_host(config: &Config) {
    match config {
        // Error: cannot move out of borrowed content.
        // 'host' is a String, which doesn't implement Copy.
        Config { host, port } => {
            println!("Host: {}", host);
        }
    }
}

The pattern tries to move the String out of the Config into host. But config is a reference. You don't own the Config, so you can't take the String out.

The fix is to destructure the reference directly.

fn print_host_fixed(config: &Config) {
    match config {
        // Match reference at top level.
        // Inner &String matches reference to String field.
        Config { host: &host, port } => {
            println!("Host: {}", host);
        }
    }
}

Config { host: &host, port } means "match the host field against a pattern &host". The pattern &host binds a reference to the value. This is clearer because it shows exactly where the reference is taken. The older ref keyword works too, but explicit & in the pattern is preferred in modern code.

Exhaustiveness with nested enums

The number of combinations grows quickly when you nest enums. Three variants nested inside two variants gives six combinations. The compiler checks exhaustiveness across all combinations.

enum Color { Red, Green, Blue }
enum Shape { Circle, Square(Color) }

fn describe(shape: Shape) {
    match shape {
        Shape::Circle => println!("Circle"),
        // Error: non-exhaustive patterns.
        // Missing Square(Red) and Square(Blue).
        Shape::Square(Color::Green) => println!("Green square"),
    }
}

If you miss Square(Red) or Square(Blue), the compiler complains. You need to match all inner variants or use a wildcard. Shape::Square(_) matches any color. The wildcard _ matches the entire inner value. You can also use .. to ignore remaining fields.

Exhaustiveness is a feature, not a bug. The compiler forces you to handle every case, preventing silent failures when new variants are added.

Decision matrix

Use nested patterns in a match arm when you need to handle multiple levels of structure and have distinct logic for different combinations of inner variants. This is the standard approach for parsing, routing, and error handling where the shape determines the behavior.

Use if let with nested patterns when you only care about one specific shape and want to ignore the rest. This keeps the code flat when you have a single happy path and a fallback. if let Ok(Some(user)) = fetch(id) { ... } is cleaner than a full match when the other cases are trivial.

Use let destructuring when you are certain of the structure and just want to extract fields without branching. let Point { x, y } = origin; is irrefutable. It assumes the value is a Point. If it isn't, the code won't compile. Use this for local variables where the type is known.

Use .. in nested patterns when you want to extract a few fields from a deep structure and discard the rest. This prevents breakage when the struct gains new fields. Config { host, .. } extracts host and ignores everything else. Combine this with nesting: Packet { header: Header { version, .. }, .. }.

Use the @ operator with nested patterns when you need to bind the whole value and parts of it at the same time. Packet { header: Header { version } @ hdr, .. } binds version and the entire header to hdr. This is useful when you need to pass the whole header to a function but also inspect the version.

Use tuple patterns when you are destructuring function returns or tuple types. let (x, y) = point; is the standard way to split a tuple. Combine tuple patterns with nesting for complex returns: let (status, (user, token)) = auth();.

Where to go next

Patterns are your scalpel. Use them to cut straight to the data you need. Don't flatten your data structures to avoid nesting. Rust handles the depth for free. Trust the compiler to enforce the shape. If the pattern doesn't match, the code doesn't run. That's safety.