Skip to main content

Guard Clauses in Pattern Matching: Adding Conditions

Guard clauses are conditional expressions that add runtime logic to patterns. A guard is an if condition placed after a pattern that must evaluate to True for the case to execute. Syntax: case pattern if condition:. Guards let you express complex matching logic without nesting multiple match statements or resorting to if-elif chains. They're essential for filtering matches by value ranges, type checks, string patterns, or relationships between captured variables. Guards are evaluated only after a pattern matches, so they're efficient: Python doesn't evaluate the condition for patterns that don't match structurally.

I discovered the power of guards when parsing event streams at scale. Instead of writing six separate match statements to handle different message types under different time windows, a single statement with guards on timestamps reduced code by 60% and made the intent crystal clear. This article covers guard syntax, common use cases, guard ordering, and how guards interact with capture patterns.

Understanding Guard Clause Syntax

A guard is the keyword if followed by any Python expression that returns a boolean. The pattern matches only if both the pattern structure succeeds and the guard evaluates to True. If the guard is False, Python moves to the next case.

def validate_age(age: int) -> str:
"""Validate age using guard clauses."""
match age:
case age if age < 0:
return "Age cannot be negative"
case age if age < 13:
return "Child"
case age if age < 18:
return "Teenager"
case age if age < 65:
return "Adult"
case age:
return "Senior"

print(validate_age(25)) # Output: Adult
print(validate_age(15)) # Output: Teenager
print(validate_age(-5)) # Output: Age cannot be negative
print(validate_age(70)) # Output: Senior

The pattern age matches any integer (it's a capture pattern). The guard then checks the value. If the guard is False, Python tries the next case. Note that case age: at the end (without a guard) catches any remaining values. This ensures the function handles all cases.

Common Guard Use Cases: Ranges and Comparisons

Guards are perfect for numeric filtering. Instead of writing a separate case for each value, a single pattern with a guard expression handles ranges and comparisons elegantly.

def categorize_temperature(temp: float) -> str:
"""Categorize temperature using guard ranges."""
match temp:
case temp if temp < -40:
return "Extremely cold (dangerous)"
case temp if temp < 0:
return "Freezing"
case temp if temp < 10:
return "Cold"
case temp if 10 <= temp < 20:
return "Cool"
case temp if 20 <= temp < 30:
return "Comfortable"
case temp if temp >= 30:
return "Hot"

# Test edge cases
print(categorize_temperature(-50)) # Output: Extremely cold (dangerous)
print(categorize_temperature(0)) # Output: Freezing
print(categorize_temperature(15)) # Output: Cool
print(categorize_temperature(25)) # Output: Comfortable

Each guard is a boolean expression. Complex expressions like 10 <= temp < 20 work exactly as in Python conditionals. The pattern temp captures the matched value so the guard can reference it.

Guards with Multiple Captured Variables

Guards become powerful when you capture multiple variables and add conditions on their relationships. This is ideal for processing structured data like database records or configuration objects.

def process_order(order: dict) -> str:
"""Process an order with guards on multiple fields."""
match order:
case {"amount": amount, "quantity": qty} if amount > 0 and qty > 0:
return f"Valid order: {qty} items for ${amount}"
case {"amount": amount, "quantity": qty} if amount > 0 and qty <= 0:
return f"Invalid: positive amount but non-positive quantity"
case {"amount": amount, "quantity": qty} if amount <= 0 and qty > 0:
return f"Invalid: positive quantity but non-positive amount"
case {"amount": amount, "quantity": qty}:
return f"Invalid: both amount ({amount}) and quantity ({qty}) are non-positive"
case _:
return "Missing 'amount' or 'quantity' key"

# Test with different order states
print(process_order({"amount": 99.99, "quantity": 5}))
# Output: Valid order: 5 items for $99.99

print(process_order({"amount": -10, "quantity": 3}))
# Output: Invalid: positive quantity but non-positive amount

print(process_order({"amount": 50}))
# Output: Missing 'amount' or 'quantity' key

The second case captures both amount and qty, then the guard checks both in a logical expression. This is much cleaner than nested if statements and clearly documents the expected state.

Type Checking and String Pattern Guards

Guards can check types, string patterns, or any callable. Combined with captures, they let you validate data while matching.

import re

def process_user(data: dict) -> str:
"""Validate and process user data with type and pattern guards."""
match data:
case {"name": name, "email": email} if isinstance(name, str) and "@" in email:
return f"Valid user: {name} ({email})"
case {"name": name, "email": email} if not isinstance(name, str):
return f"Name must be string, got {type(name).__name__}"
case {"name": name, "email": email} if "@" not in email:
return f"Invalid email: {email}"
case {"name": name}:
return f"User {name} is missing email"
case _:
return "Missing 'name' key"

# Test cases
print(process_user({"name": "Alice", "email": "[email protected]"}))
# Output: Valid user: Alice ([email protected])

print(process_user({"name": 123, "email": "bad"}))
# Output: Name must be string, got int

print(process_user({"name": "Bob", "email": "no-at-sign"}))
# Output: Invalid email: no-at-sign

The guard isinstance(name, str) checks the type of the captured variable. You can use any Python function or expression. This is especially useful for validating API responses where types might be wrong.

Guard Evaluation Order and Short-Circuiting

Guard expressions are evaluated in order, so structure your cases from most specific (narrow guards) to least specific (broader guards). Python stops at the first successful match—if a pattern matches but the guard is False, the next case is tried immediately.

def route_request(request: dict) -> str:
"""Route requests based on method and path guards."""
match request:
case {"method": "GET", "path": path} if path.startswith("/api/"):
return f"API GET request: {path}"
case {"method": "GET", "path": path}:
return f"Static GET request: {path}"
case {"method": "POST", "path": path} if path.startswith("/api/"):
return f"API POST request: {path}"
case {"method": "POST", "path": path}:
return f"Form POST request: {path}"
case {"method": method, "path": path}:
return f"Other {method} request: {path}"
case _:
return "Invalid request"

# Test routing
print(route_request({"method": "GET", "path": "/api/users"}))
# Output: API GET request: /api/users

print(route_request({"method": "GET", "path": "/static/index.html"}))
# Output: Static GET request: /static/index.html

print(route_request({"method": "POST", "path": "/api/orders"}))
# Output: API POST request: /api/orders

Order matters. The first case matches GET requests to /api/ paths. The second case matches all other GET requests. If we reversed them, the second case would always match GET requests and the first would never be reached. Structure cases from most specific to least specific to avoid this pitfall.

Comparison Table: When to Use Guards vs. When Not To

ScenarioUse Guard?Example
Comparing against a constantNo; use literal patterncase 42: not case x if x == 42:
Numeric range filteringYescase x if 0 < x < 100:
Type checkingMaybe; type patterns are cleaner (article 4)Use case int(x): instead of case x if isinstance(x, int):
Relationship between variablesYescase {"a": a, "b": b} if a > b:
Calling functions for validationYescase email if is_valid_email(email):
# Inefficient: guards on literal patterns
# match value:
# case x if x == "success": # Don't do this
# pass

# Efficient: use literal patterns directly
match value:
case "success": # Better
pass

Key Takeaways

  • Guard clauses are if conditions placed after patterns that must evaluate to True for the case to execute.
  • Guards are evaluated only after a pattern matches structurally, making them efficient for filtering.
  • Numeric ranges, boolean expressions, and callable checks are all valid guards.
  • Multiple captured variables can be combined in a guard expression for validating relationships.
  • Order cases from most specific guards to least specific to ensure correct matching.
  • Guards are not needed for literal comparisons—use literal patterns instead.
  • Combine guards with capture patterns for powerful, readable validation logic.

Frequently Asked Questions

Can I use multiple conditions in a guard?

Yes, use and, or, and not to combine conditions: case value if value > 0 and value < 100:. Complex guards are fine if they fit on one line; if a guard becomes too long, consider factoring it into a helper function.

What happens if a guard raises an exception?

If a guard raises an exception, the entire match statement fails and the exception propagates. Guards should not raise—design them to return boolean. If you need to validate something that might raise, wrap it: case value if safe_check(value): where safe_check returns True or False.

Can I use a function call in a guard?

Yes, any callable that returns a boolean works: case email if is_valid_email(email):. Keep the function simple and deterministic. Avoid side effects in guards—they're for filtering, not for I/O or state changes.

Is a guard checked if the pattern doesn't match?

No. Guards are evaluated only after the pattern matches structurally. If a pattern fails, Python moves to the next case without evaluating the guard. This makes guards efficient.

Can I guard against multiple values like case x if x in [1, 2, 3]:?

Yes, but use an or-pattern instead: case 1 | 2 | 3: is simpler. Guards are better for ranges and relationships.

Further Reading