Mapping Patterns: Working with Dictionaries
Mapping patterns match dictionaries and extract values by key in a single operation. Syntax: case {"key": pattern, "another": pattern}: matches dictionaries containing those keys and destructures their values. Mapping patterns are ideal for processing JSON, API responses, configuration files, and any data that arrives as a dictionary. Instead of checking if a key exists, accessing it with .get(), and then validating the value, a mapping pattern does all three atomically. This eliminates KeyError bugs and makes code more concise. Mapping patterns scale to arbitrary nesting depth and can coexist with type checking—you can extract a value and check its type in a single pattern.
I used mapping patterns to build a real-time event processor that consumed millions of JSON events daily. Replacing nested .get() chains with patterns cut validation code by 70% and eliminated entire categories of KeyError exceptions. This article covers basic dictionary matching, nested mappings, type-checked keys, rest patterns for unmatched keys, and handling optional keys.
Basic Mapping Pattern Syntax
A mapping pattern is a dictionary literal in a case clause. Python matches keys and destructures values in one step. Any value extracted becomes a bound variable in the case body.
def process_command(cmd: dict) -> str:
"""Process a command dictionary."""
match cmd:
case {"action": "start", "target": target}:
return f"Starting {target}"
case {"action": "stop", "target": target}:
return f"Stopping {target}"
case {"action": action}:
return f"Unknown action: {action}"
case _:
return "Missing 'action' key"
# Test with dictionaries
print(process_command({"action": "start", "target": "server"}))
# Output: Starting server
print(process_command({"action": "stop", "target": "database"}))
# Output: Stopping database
print(process_command({"action": "restart"}))
# Output: Unknown action: restart
The pattern {"action": "start", "target": target} matches dictionaries with action == "start" and captures the value of the "target" key as target. If the dictionary is missing either key, the pattern doesn't match and Python tries the next case.
Nested Mapping Patterns and Deep Extraction
Mapping patterns nest arbitrarily deep, extracting values from nested dictionaries without manual key lookups. This is especially valuable for JSON parsing and handling API responses.
def process_api_response(response: dict) -> str:
"""Extract user information from a nested API response."""
match response:
case {
"status": "success",
"data": {
"user": {
"id": user_id,
"profile": {"email": email, "name": name}
}
}
}:
return f"User {user_id}: {name} ({email})"
case {
"status": "success",
"data": {"user": {"id": user_id}}
}:
return f"User {user_id} with incomplete profile"
case {"status": "error", "error": {"message": msg}}:
return f"API error: {msg}"
case {"status": status}:
return f"Unknown status: {status}"
case _:
return "Invalid response format"
# Test with nested JSON-like data
response = {
"status": "success",
"data": {
"user": {
"id": 42,
"profile": {"email": "[email protected]", "name": "Alice"},
"created_at": "2026-01-01"
}
}
}
print(process_api_response(response))
# Output: User 42: Alice ([email protected])
The pattern nests three levels deep, extracting user_id, email, and name from the nested structure. If any key is missing or the structure is wrong, the pattern fails silently and the next case is tried. This is far safer than chaining .get() calls.
Type-Checked Mapping Patterns
Combine mapping patterns with type patterns to validate both structure and types. This ensures data integrity when processing external JSON.
def validate_user_data(data: dict) -> str:
"""Validate user data, checking both structure and types."""
match data:
case {
"name": str(name),
"age": int(age),
"email": str(email)
} if len(name) > 0 and age >= 0 and "@" in email:
return f"Valid user: {name}, {age}, {email}"
case {
"name": str(name),
"age": age,
"email": str(email)
} if not isinstance(age, int):
return f"Age must be int, got {type(age).__name__}"
case {
"name": name,
"age": int(age),
"email": str(email)
} if not isinstance(name, str):
return f"Name must be str, got {type(name).__name__}"
case {"name": name, "age": age, "email": email}:
return f"Data types invalid"
case _:
return "Missing required keys: name, age, email"
# Test with various inputs
print(validate_user_data({"name": "Alice", "age": 30, "email": "[email protected]"}))
# Output: Valid user: Alice, 30, [email protected]
print(validate_user_data({"name": "Bob", "age": "thirty", "email": "[email protected]"}))
# Output: Age must be int, got str
The pattern "age": int(age) matches if the "age" key exists and its value is an integer, binding it to age. This prevents type errors and is cleaner than manual isinstance() checks.
The Rest Pattern for Matching Unmatched Keys
The **rest pattern in a mapping captures all key-value pairs not explicitly matched, storing them in a dictionary called rest. This is useful when you want to extract specific keys but preserve the rest.
def process_event(event: dict) -> str:
"""Extract event metadata and preserve extra fields."""
match event:
case {
"type": "user_login",
"user_id": user_id,
"timestamp": timestamp,
**extra
}:
return f"Login: user {user_id} at {timestamp}, extra: {extra}"
case {
"type": "error",
"message": msg,
"code": code,
**extra
}:
return f"Error {code}: {msg}, metadata: {extra}"
case {"type": event_type, **data}:
return f"Event {event_type}: {data}"
case _:
return "Invalid event"
# Test with events containing extra fields
event1 = {
"type": "user_login",
"user_id": 123,
"timestamp": 1000,
"ip": "192.168.1.1",
"user_agent": "Chrome"
}
print(process_event(event1))
# Output: Login: user 123 at 1000, extra: {'ip': '192.168.1.1', 'user_agent': 'Chrome'}
event2 = {"type": "error", "message": "Timeout", "code": 504, "retry_after": 60}
print(process_event(event2))
# Output: Error 504: Timeout, metadata: {'retry_after': 60}
The **extra syntax captures unmatched keys. This is valuable when processing extensible data formats where additional fields may be present but aren't required.
Optional Keys and Default Values
Mapping patterns require keys to exist. To match dictionaries where a key might be missing, provide a fallback pattern. Use an or-pattern with a capture to match either the key or a default value.
def process_config(config: dict) -> str:
"""Process configuration with optional keys."""
match config:
case {
"host": str(host),
"port": int(port),
"ssl": True
}:
return f"Secure connection to {host}:{port}"
case {
"host": str(host),
"port": int(port),
"ssl": False
}:
return f"Insecure connection to {host}:{port}"
case {
"host": str(host),
"port": int(port)
}:
return f"Connection to {host}:{port} (SSL not specified, assume False)"
case {"host": str(host)}:
return f"Host {host} without port (invalid)"
case _:
return "Missing required 'host' key"
# Test configurations
config1 = {"host": "localhost", "port": 8080, "ssl": True}
config2 = {"host": "example.com", "port": 443}
print(process_config(config1))
# Output: Secure connection to localhost:8080
print(process_config(config2))
# Output: Connection to example.com:443 (SSL not specified, assume False)
The first case requires all three keys; the second case drops the "ssl" requirement. Ordering cases from most specific (most keys) to least specific (fewest keys) ensures correct matching.
Comparison: Mapping Patterns vs. Dictionary Access
| Approach | Safety | Readability | Code Length |
|---|---|---|---|
Manual: data['key'] | Low; raises KeyError if missing | Low; hard to validate nesting | Short but fragile |
.get() chaining: data.get('key', {}).get('nested') | Medium; returns None silently | Medium; still verbose | Moderate; repetitive |
Mapping pattern: case {"key": {"nested": val}}: | High; pattern doesn't match if missing | High; intent is clear | Concise; intent-focused |
# Manual approach: error-prone
def get_user_email_manual(response: dict):
try:
return response['data']['user']['email']
except KeyError:
return None
# Mapping pattern approach: safe
def get_user_email_pattern(response: dict):
match response:
case {"data": {"user": {"email": email}}}:
return email
case _:
return None
# Both return None on missing keys, but pattern version is clearer
test_response = {"data": {"user": {"email": "[email protected]"}}}
print(get_user_email_manual(test_response)) # Output: [email protected]
print(get_user_email_pattern(test_response)) # Output: [email protected]
Key Takeaways
- Mapping patterns match dictionary keys and extract values in a single operation:
case {"key": pattern}:. - Nested mapping patterns extract values from deeply nested dictionaries without manual key lookups.
- Type patterns in mappings validate both structure and type:
case {"age": int(age)}:. - The
**restpattern captures unmatched keys, useful for extensible data formats. - Mapping patterns are safer than
.get()chaining—they fail fast and clearly when structure is wrong. - Order cases from most specific (most required keys) to least specific (fewer keys) to avoid incomplete matches.
Frequently Asked Questions
What happens if a required key is missing?
The pattern doesn't match, and Python tries the next case. This is safe—if no pattern matches and there's a default case _:, it catches the error. There's no silent None value or KeyError.
Can I use a capture pattern for a key name?
No, key names must be literals (strings). To match dictionaries with any keys, use the **rest pattern: case {**rest}: captures all key-value pairs into a dictionary called rest.
Can I match keys with special characters or spaces?
Yes, use a string literal as the key: case {"special key!": value}: works fine. Keys can be any valid string.
How do I match dictionaries with specific key order?
Mapping patterns don't care about order. case {"a": x, "b": y}: matches {"b": 1, "a": 2} equally. If order matters, use a sequence pattern on dict.items() instead.
Can I use a variable as a dictionary key in a pattern?
No, dictionary keys in patterns must be literals. To check if a specific variable is a key, use a guard: case d if var_name in d: after matching the dictionary structure.