How to Use Match Guards in Rust

Match guards are boolean expressions appended to match arms using `if` that allow you to refine pattern matching without nesting additional `if` statements.

Match guards: conditions attached to patterns

You're building a CLI tool that accepts a deploy command. The command takes a target environment string. You want to run the deployment only if the environment is production and a feature flag is enabled. A plain match on the environment string gets you partway, but checking the feature flag forces you to nest an if statement inside the arm. Add error handling, and the indentation grows into a pyramid. Match guards solve this by letting you attach the flag check directly to the production pattern. The code stays flat. The logic stays close to the data shape.

A match guard is a boolean expression appended to a match arm using if. It refines the pattern without adding nesting. The guard runs only if the pattern matches. If the pattern fails, the guard is skipped entirely. If the pattern matches but the guard returns false, the match engine moves to the next arm as if the pattern never matched.

Think of a match arm as a two-stage security checkpoint. The pattern is the first gate: it checks the shape of the data. If the shape fits, the guard is the second gate: it checks a specific value or property. Both gates must open for the arm to execute. The guard gives you the power to filter matches by content while keeping the structure of your code clean.

Minimal example

Here is a simple function that classifies a number based on ranges. The pattern binds the value, and the guard checks the condition.

fn classify(n: i32) -> &'static str {
    match n {
        // Pattern binds `n` to the value. Guard checks the range.
        n if n > 100 => "High",
        n if n > 50 => "Medium",
        // Fallback arm catches everything else.
        _ => "Low",
    }
}

fn main() {
    println!("{}", classify(150)); // High
    println!("{}", classify(75));  // Medium
    println!("{}", classify(10));  // Low
}

The pattern n matches any i32. The guard if n > 100 filters the match. If n is 150, the pattern matches, the guard returns true, and the arm runs. If n is 75, the pattern matches, the guard returns false, and the match continues to the next arm. The second arm's pattern matches, its guard returns true, and the arm runs.

Variables bound in the pattern are available in the guard. The guard expression can use any variable introduced by the pattern. This lets you inspect the data you just destructured. The guard is evaluated lazily. It never runs if the pattern fails. This matters when the guard calls a function or performs expensive computation. The compiler generates code that checks the pattern first, then evaluates the guard only if necessary.

Write the pattern first, then add the guard. The compiler will tell you if you're missing a case or if a guard makes an arm unreachable.

Ownership and the drop trap

Match guards interact with ownership in a way that trips up beginners. When you bind a value in the pattern, you might move it. If the guard fails, the moved value is dropped. The match cannot fall through to another arm that expects the same variant.

Consider an Option<String>. You want to handle long strings differently from short strings. If you move the string in the pattern, a failed guard destroys the data.

fn process_bad(opt: Option<String>) {
    match opt {
        // `s` is moved out of the Option.
        Some(s) if s.len() > 5 => {
            println!("Long string: {}", s);
        }
        // This arm is unreachable.
        // If the guard above fails, `s` is dropped.
        // The Option is consumed, so this arm can never match.
        Some(s) => {
            println!("Short string: {}", s);
        }
        None => println!("Empty"),
    }
}

The compiler warns about the unreachable pattern. The first arm moves the String out of the Option. If the guard fails, the String is dropped. The Option is gone. The second arm tries to match Some, but the value has been consumed. The match engine cannot recover the data.

To preserve the value for fallback arms, match by reference. Use ref or & in the pattern. The guard checks the reference, and the original data stays alive.

fn process_good(opt: Option<String>) {
    match opt {
        // `s` is a reference. No move occurs.
        Some(ref s) if s.len() > 5 => {
            println!("Long string: {}", s);
        }
        // This arm is reachable.
        // If the guard fails, the Option is still Some.
        Some(s) => {
            println!("Short string: {}", s);
        }
        None => println!("Empty"),
    }
}

Here, the first arm binds s as a reference. The guard checks the length. If the guard fails, the Option remains intact. The match continues to the second arm, which moves the string. This pattern is essential when you need to validate data without consuming it.

Match by reference when the guard might fail and you need the value later. Moving in the pattern locks the value to that arm, even if the guard rejects it.

Real-world validation

Match guards shine when you need to validate enum variants with complex data. You can destructure the variant, check the fields, and handle invalid cases without nesting.

enum Config {
    Dev,
    Prod { region: String, replicas: u32 },
    Test { seed: u64 },
}

fn apply_config(cfg: Config) {
    match cfg {
        // Guard validates region length and replica count.
        Config::Prod { ref region, replicas }
            if !region.is_empty() && replicas > 0 =>
        {
            println!("Deploying to {} with {} replicas", region, replicas);
        }
        // Fallback for invalid Prod config.
        Config::Prod { region, .. } => {
            println!("Invalid Prod config: region='{}'", region);
        }
        // Guard checks seed range.
        Config::Test { seed } if seed < 1000 => {
            println!("Running test with seed {}", seed);
        }
        Config::Test { seed } => {
            println!("Seed {} is too large for testing", seed);
        }
        Config::Dev => println!("Running in dev mode"),
    }
}

The Prod arm uses ref region to keep the string alive for the fallback arm. The guard checks both region and replicas. If either check fails, the match falls through to the next Prod arm, which handles the error. The Test arm moves seed because there is no fallback that needs the value. The guard filters valid seeds. Invalid seeds hit the second Test arm.

Put validation in the guard, not the arm. Keep the arm focused on action. If the guard fails, the match engine handles the routing. You don't need manual if checks inside the arm body.

Pitfalls and compiler errors

Match guards have a few traps. The most dangerous is panicking in the guard. If your guard expression panics, the entire match panics. The match engine does not catch panics in guards and move to the next arm. Write guards that cannot panic, or handle the panic risk before the match.

fn risky(opt: Option<String>) {
    match opt {
        // If `s` is empty, this panics.
        // The match does not fall through to the next arm.
        Some(ref s) if s.parse::<i32>().unwrap() > 0 => {
            println!("Positive number");
        }
        Some(_) => println!("Fallback"),
        None => println!("None"),
    }
}

If s is "abc", the parse fails and unwrap panics. The function crashes. The fallback arm never runs. Use if let or match on the parse result instead of panicking in the guard.

Another common error is trying to move out of a borrowed value in the pattern. If you match on a reference, you cannot move the inner value unless you dereference. The compiler rejects this with E0507 (cannot move out of borrowed content).

fn borrow_error(opt: &Option<String>) {
    match opt {
        // Error E0507: cannot move out of borrowed content.
        Some(s) => println!("{}", s),
        None => println!("None"),
    }
}

Fix this by matching by reference or using if let with a reference. The compiler is strict about ownership for a reason. Moving from a borrow would leave the original data in an invalid state.

If the guard panics, the match panics. Write guards that are pure and safe. Never put fallible operations in a guard without handling the failure first.

Conventions and syntax sugar

The community has settled on a few conventions for match guards. When you need to bind a value to a name while matching a specific pattern, use the @ operator. This is useful when you need both the matched piece and the whole value in the guard or arm.

enum Message {
    Data { id: u32, payload: Vec<u8> },
    Control,
}

fn handle(msg: Message) {
    match msg {
        // Bind the whole variant to `msg` and the id to `id`.
        Message::Data { id, .. } @ msg if id > 100 => {
            println!("High priority message: {:?}", msg);
        }
        Message::Data { id, .. } => {
            println!("Normal message id: {}", id);
        }
        Message::Control => println!("Control"),
    }
}

The @ binding lets you name the entire matched value. Use @ when you need to pass the whole enum variant to a function while also inspecting a field in the guard.

For single-arm checks, prefer if let with a guard. The syntax is cleaner and conveys intent better.

if let Some(ref s) = opt && s.len() > 5 {
    println!("Long string: {}", s);
}

This is syntactic sugar for a match with one guarded arm and a wildcard. Use if let with && for single checks. Use match with guards for multiple branches. The community reads if let as "I only care about this case." It reduces noise when you don't need exhaustive matching.

Use if let with && for single checks. Use match with guards for multiple branches. The syntax should match the shape of your logic.

When to use match guards

Use match guards when you need to filter a pattern by a value or property without nesting logic inside the arm. Use match guards when you want to preserve ownership of a value if the condition fails, allowing the match to fall through to a more general arm. Use if let with a guard when you only care about one specific pattern and want to ignore the rest of the enum. Use a plain if inside the match arm when the condition is complex, involves side effects, or you need to compute a value before deciding what to do. Reach for if let chains when you have multiple independent checks that don't share a common enum structure.

Pick the tool that matches the shape of your data, not the shape of your habit. Match guards keep your patterns expressive and your arms focused.

Where to go next