Welcome to the third lesson in the Functional Patterns & Pattern Matching in Python course! You've made excellent progress so far. In the first lesson, we built production-ready decorators with retry logic and exponential backoff. In the second lesson, we explored single-dispatch generic functions, creating a type-aware JSON serializer that adapts behavior based on input types.
Today, we're diving into structural pattern matching, a feature introduced in Python 3.10 that transforms how we handle complex conditional logic. While single dispatch selects implementations based on types, pattern matching inspects the structure and content of data itself. This is particularly powerful when working with dictionaries, sequences, and nested data structures. We'll build a command router that processes user management operations, validates inputs, handles multiple action aliases, captures extra parameters, and routes bulk requests. By the end of this lesson, you'll be able to write expressive, maintainable code that clearly describes what data shapes your functions expect.
When building systems that handle multiple command types, we often end up with long chains of if-elif statements that check keys, validate types, and extract values. Consider a user management API that processes commands like creating users, updating profiles, or deleting accounts.
The traditional approach might look like this: check if the action is "create_user", then verify that "name" and "email" keys exist, validate their types, extract optional fields, and finally execute the logic. Each command type requires its own set of checks, leading to deeply nested conditionals that are hard to read and maintain. Missing a validation check or adding a new command type means carefully navigating this nested structure.
Structural pattern matching provides a declarative alternative. Instead of imperatively checking conditions one by one, we describe the structure we expect and let Python match against it. This approach makes the code more readable, reduces errors, and clearly documents what data shapes are valid.
The match statement introduces a new control flow mechanism in Python. It takes an expression and compares it against a series of patterns defined in case clauses. When a pattern matches, the corresponding block executes, and the match statement completes:
Each case clause contains a pattern that describes a data structure. The first pattern matches any dictionary with exactly {"action": "ping"}. The second pattern matches dictionaries with an "action" key set to "echo" and a "payload" key, capturing the payload value in the variable data. The final case _: is a catch-all wildcard that matches anything, similar to else in if-elif chains.
Dictionary patterns are particularly useful for command routing. They allow us to specify required keys, capture values, and ignore extra keys in a single, readable pattern:
This pattern matches dictionaries that contain "action", "name", and "email" keys with those specific values or bindings. The literal string "create_user" must match exactly, while name and email are capture variables that bind to whatever values are present. Importantly, the pattern still matches if the dictionary contains additional keys beyond these three.
If we only want to match dictionaries with no extra keys, we'd need to explicitly check for that using guards or by capturing remaining keys, which we'll see later. This flexible matching behavior makes patterns less brittle than exact dictionary comparisons.
Often, different action names should trigger the same logic. For example, both "create_user" and "register" might represent user creation. OR patterns allow multiple alternatives using the | operator:
The pattern ("create_user" | "register") matches if the action is either string. The as act clause captures whichever value actually matched, allowing us to include it in the response. This is useful when callers want to know which specific action name was processed.
We maintain a global USERS dictionary to store user data, indexed by ID. The _next_id counter generates unique identifiers. When the pattern matches, we create a user object, store it, and return a success response. Without OR patterns, we'd need duplicate case clauses or additional conditionals inside the handler.
Patterns describe structure, but often we need additional validation beyond shape matching. Guards are if conditions that must be true for a case to match:
This pattern requires name and email to be strings, and email must contain an "@" symbol. The guard prevents matching when these conditions aren't met, even if the dictionary structure is correct. This is crucial for security and correctness: we don't want to create users with malformed data.
The **extra syntax captures any additional keys in the dictionary into the extra variable, allowing us to extract optional fields like role or tags. This pattern demonstrates combining structure matching, capture variables, OR patterns, and guards in a single, readable clause. Without guards, we'd need nested if statements inside the case block, reducing clarity.
After validating required fields, we often need to process optional parameters. Let's expand the user creation logic to handle role and tags:
The extra dictionary contains any keys beyond "action", "name", and "email". We extract "role" with a default of "user" and "tags" as an empty list. The tags normalization ensures they're strings, stripped of whitespace, lowercased, and non-empty. We also strip whitespace from name and email to handle common user input issues.
This normalization logic sits inside the case block, executing only when the pattern and guard match. The pattern matching handles routing and structure validation, while the block handles business logic and data cleaning. This separation of concerns makes the code easier to understand and modify.
Not all input will be valid, even if it has the right structure. We need to handle cases where the structure matches but validation fails:
This case comes after our guarded case and matches the same structure but without the guard. If someone sends {"action": "create_user", "name": "Alice", "email": "not-an-email"}, the first case won't match due to the guard, but this second case will, returning a clear error.
The order of cases matters: Python evaluates them top to bottom and executes the first match. By placing the guarded case first, we ensure valid data is processed before falling back to error handling. This pattern of "specific case with guard, then fallback without guard" is common when validating input.
Delete operations require validating the user ID and checking if the user exists. Pattern matching with guards handles both concerns elegantly:
The guard ensures uid is a positive integer before attempting deletion. Inside the block, we check if the user actually exists in our store. If found, we remove them and return success; otherwise, we return a "not_found" error.
This demonstrates how pattern matching handles the "what command is this" question, while internal logic handles the "can we execute it" question. The pattern could also use multiple cases: one for successful deletion and another for not found, but that would require more complex guards checking existence.
Often, we want to support different ways to look up the same resource. Users might be retrieved by ID or email, requiring different search strategies:
These two cases handle the same action ("get_user") but with different query strategies specified by the "by" field. The first retrieves by ID using dictionary lookup, while the second searches by email using a helper function. The guards ensure type safety before executing the search.
This pattern demonstrates how structural matching can route not just by action, but by the shape and content of the entire command. We could have used a single case with conditional logic inside, but separate cases make the two query paths explicit and easier to maintain.
Update operations are complex because they modify existing data and must validate each field independently. Let's see how pattern matching helps structure this logic:
The pattern captures the user ID and the fields dictionary. The guard ensures both have the correct types. Inside, we first verify the user exists, then validate each field individually. If any validation fails, we return an error immediately; otherwise, we update the user record.
This case also demonstrates that complex business logic can live inside case blocks. The pattern handles routing and initial validation, while the block implements the multi-step update procedure with its own error conditions.
Continuing the update logic, let's handle role and tags fields, which have different validation requirements:
Role updates are straightforward: if a role is provided and it's a string, we apply it. Tags require the same normalization we saw during creation: convert to strings, strip whitespace, lowercase, and filter empties. Finally, we return the updated user object.
The update logic shows how internal validation differs from pattern guards. The guard ensures fields is a dictionary; the internal checks validate individual field values. This layered validation approach is clearer than trying to express everything in the pattern and guard.
Recursive pattern matching enables powerful compositional patterns. We can define a "bulk" command that processes multiple commands and aggregates results:
This case matches bulk requests and recursively calls process_command on each item. The list comprehension collects all results, allowing clients to execute multiple operations in a single request. Each item is processed independently through the full pattern matching logic.
This demonstrates the power of structural pattern matching combined with recursion: we can build higher-order operations that compose simpler ones. The bulk operation doesn't need to know what commands it's processing; the recursive call handles routing and execution.
Every match statement should handle unexpected input gracefully. The final catch-all case provides a safety net:
The wildcard pattern _ matches anything that didn't match previous cases. We return an error response that includes the action name (if present) to help with debugging. This prevents the match statement from raising an exception for unrecognized commands, providing a predictable API response instead.
The catch-all must come last because patterns are evaluated in order. If it came first, it would match everything and subsequent cases would never execute. This is similar to how else must come last in if-elif chains.
Let's test the complete system with various operations. We'll create a user, retrieve them by ID and email, update their profile, and attempt deletion:
The first test creates a user with extra whitespace and mixed-case tags. The pattern matching extracts the fields, guards validate them, and normalization cleans the data. The second test retrieves by ID, while the third updates the user's name and email.
Notice the tags were normalized to lowercase and whitespace was removed. The user ID is 1 because it's the first user created. Each operation returns a structured response with "ok": true and the relevant data.
Let's continue testing with email-based retrieval and deletion operations:
The fourth test retrieves the user by their updated email. The fifth test deletes the user successfully, while the sixth attempts to delete them again, demonstrating error handling for missing users.
The email-based retrieval finds the user with the updated email. The first deletion succeeds, returning the deleted ID. The second deletion fails with a "not_found" error because the user no longer exists. This shows proper error handling in both success and failure scenarios.
Let's test the validation logic by providing invalid data:
This command has the correct structure but an invalid email (missing "@"). The guarded case won't match, so the fallback error case triggers:
The error message clearly indicates the problem. This demonstrates how guards and fallback cases work together: guards filter valid inputs to their handlers, while fallbacks catch structural matches that fail validation.
Now let's test the bulk dispatcher with multiple operations, including creating users and retrieving one:
The bulk command contains three sub-commands: two registrations (note the "register" alias for "create_user") and one retrieval. Each is processed independently through the pattern matching system:
The results array contains three entries: two successful registrations with IDs 2 and 3, and a retrieval that finds Carol. The "register" action name is recognized due to our OR pattern. The third sub-command successfully retrieves the user just created, demonstrating that bulk operations are fully transactional within the same request.
Finally, let's verify that unknown commands are handled gracefully:
The catch-all case matches and returns a descriptive error. The "got": "unknown" field helps identify what action was attempted. This prevents the system from crashing on unexpected input, maintaining API stability.
You've now mastered structural pattern matching in Python, a powerful tool for handling complex conditional logic with clarity and precision. We built a complete command router that processes user management operations through declarative patterns, combining OR patterns for action aliases, guards for validation, dictionary destructuring with **extra for optional fields, and recursive matching for bulk operations. This pattern-based approach reduces code complexity while clearly expressing what data shapes your functions expect.
Structural pattern matching complements the patterns we've learned previously: decorators add reusable behavior wrapping, single dispatch adapts implementations based on type, and now pattern matching routes logic based on data structure and content. Together, these functional patterns give you a comprehensive toolkit for writing expressive, maintainable Python code.
You've seen how to match dictionary structures, validate inputs with guards, capture extra keys, handle multiple action names with OR patterns, implement fallback error cases, and compose operations through recursion. The key insight is that pattern matching lets you declaratively describe what you expect rather than imperatively checking it step by step.
In the upcoming practice exercises, you'll extend the command router with new operations, debug subtle pattern matching issues, add validation guards, and implement your own nested routing logic. These challenges will solidify your understanding and prepare you to apply pattern matching in real-world systems. Let's build on this foundation!
