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
| Scenario | Use Guard? | Example |
|---|---|---|
| Comparing against a constant | No; use literal pattern | case 42: not case x if x == 42: |
| Numeric range filtering | Yes | case x if 0 < x < 100: |
| Type checking | Maybe; type patterns are cleaner (article 4) | Use case int(x): instead of case x if isinstance(x, int): |
| Relationship between variables | Yes | case {"a": a, "b": b} if a > b: |
| Calling functions for validation | Yes | case 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
ifconditions placed after patterns that must evaluate toTruefor 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.