Python Hands-on

How to use structural pattern matching in Python

The pattern-matching syntax introduced in Python 3.10 allows for powerful new programming techniques for decision-making in apps.

How to use structural pattern matching in Python
Thinkstock

Python, for all its power and popularity, has long lacked a form of flow control found in other languages—a way to take a value and match it elegantly against one of a number of possible conditions. In C and C++, it’s the switch/case construction; in Rust, it’s called “pattern matching.”

The traditional ways to do this in Python aren’t elegant. One is to write an if/elif/else chain of expressions. The other is to store values to match as keys in a dictionary, then use the values to take an action—e.g., store a function as a value and use the key or some other variable as input. In many cases this works well, but can be cumbersome to construct and maintain.

After many proposals to add a switch/case-like syntax to Python failed, a recent proposal by Python language creator Guido van Rossum and a number of other contributors has been accepted for Python 3.10: structural pattern matching. Structural pattern matching not only makes it possible to perform simple switch/case style matches, but also supports a broader range of use cases.

Introducing Python structural pattern matching

Structural pattern matching introduces the match/case statement and the pattern syntax to Python. The match/case statement follows the same basic outline as switch/case. It takes an object, tests the object against one or more match patterns, and takes an action if it finds a match.

match command:
    case "quit":
        quit()
    case "reset":
        reset()
    case unknown_command:
        print (f"Unknown command '{unknown_command}'")

Each case statement is followed by a pattern to match against.

In the above example we’re using simple strings as our match targets, but more complex matches are possible. In fact, the chief use case for structural pattern matching is to match patterns of types, rather than patterns of values.

Python performs matches by going through the list of cases from top to bottom. On the first match, Python executes the statements in the corresponding case block, then skips to the end of the match block and continues with the rest of the program. There is no “fall-through” between cases, but it’s possible to design your logic to handle multiple possible cases in a single case block. (More on this later.)

It’s also possible to capture all or part of a match and re-use it. In the case unknown_command in our example above, where no match has been made, the value is “captured” in the variable unknown_command so we can re-use it.

Matching against variables with Python structural pattern matching

An important note is worth bringing up here. If you list variable names in a case statement, that doesn’t mean a match should be made against the contents of the named variable. Variables in a case are used to capture the value that is being matched.

If you want to match against the contents of a variable, that variable must be expressed as a dotted name, like an enum. Here’s an example:

from enum import Enum
class Command(Enum):
    QUIT = 0
    RESET = 1

match command:
    case Command.QUIT:
        quit()
    case Command.RESET:
        reset()

One doesn’t have to use an enum; any dotted-property name will do. That said, enums tend to be the most familiar and idiomatic way to do this in Python.

You cannot match against variable contents through indexing. For instance, case commands[0]: would be rejected as a syntax error.

Matching against multiple elements with Python structural pattern matching

The key to working most effectively with pattern matching is not just to use it as a substitute for a dictionary lookup or an if/else chain. It’s to describe the structure of what you want to match. This way, you can perform matches based on the number of elements you’re matching against, or their combination.

Here’s a slightly more complex example. Here, the user types in a command, optionally followed by a filename.

command = input("Command:")
match command.split():
    case ["quit"]:
        quit()
    case ["load", filename]:
        load_from(filename)
    case ["save", filename]:
        save_to(filename)
    case _:
        print (f"Command '{command}' not understood")

Let’s examine these cases in order:

  • case ["quit"]: tests if what we’re matching against is a list with just the item "quit", derived from splitting the input.
  • case ["load", filename]: tests if the first split element is the string "load", and if there’s a second string that follows. If so, we store the second string in the variable filename and use it for further work. Same for case ["save", filename]:.
  • case _: is a wildcard match. It matches if no other match has been made up to this point. Note that the underscore variable, _, doesn’t actually bind to anything; the name _ is used as a signal to the match command that the case in question is a wildcard. (That’s why we refer to the variable command in the body of the case block; nothing has been captured.)

Patterns in Python structural pattern matching

Patterns can be simple values, or they can contain more complex matching logic. Some examples:

  • case "a": Match against the single value "a".
  • case ["a","b"]: Match against the collection ["a","b"].
  • case ["a", value1]: Match against a collection with two values, and place the second value in the capture variable value1.
  • case ["a", *values]: Match against a collection with at least one value. The other values, if any, are stored in values. Note that you can include only one starred item per collection (as it would be with star arguments in a Python function).
  • case ("a"|"b"|"c"): The or operator (|) can be used to allow multiple cases to be handled in a single case block. Here, we match against either "a","b", or "c".
  • case ("a"|"b"|"c") as letter: Same as above, except we now place the matched item into the variable letter.
  • case ["a", value] if <expression>: Matches the capture only if expression is true. Capture variables can be used in the expression. For instance, if we used if value in valid_values, the case would only be valid if the captured value value was in fact in the collection valid_values.
  • case ["z", _]: Any collection of items that begins with "z" will match.

Matching against objects with Python structural pattern matching

The most advanced feature of Python’s structural pattern matching system is the ability to match against objects with specific properties. Consider an application where we’re working with an object named media_object, which we want to convert into a .jpg file and return from the function.

match media_object:
    case Image(codec="jpg"):
        # Return as-is
        return media_object
    case Image(codec="png") | Image(codec="gif"):
        return render_as(media_object, "jpg")
    case Video():
        raise ValueError("Can't extract frames from video yet")
    case other_type:
        raise Exception(f"Media object {media_object} 
            of type {codec} can't be handled yet")

In each case above, we’re looking for a specific kind of object, sometimes with specific attributes. The first case matches against an Image object with the codec attribute set to "jpg". The second case matches if type is "png" or "gif". The third case matches any object of type Video, no matter its attributes. And the final case is our catch-all if everything else fails, although we use an actual name for the match to capture it instead of _.

You can also perform captures with object matches:

match media_object:
    case Image(codec=media_type):
        print (f"Image of type {media_type}")

Using Python structural pattern matching effectively

The key with Python structural pattern matching is to write matches that cover the structural cases you’re trying to match against. Simple tests against constants are fine, but if that’s all you’re doing, then a simple dictionary lookup might be a better option. The real value of structural pattern matching is being able to make matches against a pattern of objects, not just one particular object or even a selection of them.

Another important thing to bear in mind is the order of the matches. Which matches you test for first will have an impact on the efficiency and accuracy of your matching overall. Most folks who have built lengthy if/elif/else chains will realize this, but pattern matching requires you to think about order even more carefully, due to the potential complexity. Place your most specific matches first, and the most general matches last.

Finally, if you have a problem that could be solved with a simple if/elif/else chain, or a dictionary lookup — use that instead! Pattern matching is powerful, but not universal. Use it when it makes the most sense for a problem.

Copyright © 2023 IDG Communications, Inc.