13. Structural Pattern Matching (Python 3.10+)#
This was a major new feature in Python 3.10. It looks a lot like a switch statement from C, but it’s a lot more powerful. Many other modern languages have this too, like Ruby. If you can write Python 3.10 only things, then this can simplify code that would have had lots of elif
’s and isinstance
checks.
13.1. Exact matches#
Let’s look look at a simple match statement that looks just like a classic switch statement in C languages:
item = 2
match item:
case 1:
print("One")
case 2:
print("Two")
Two
The thing you are matching against must not be a plain variable name (you’ll see why in a moment). So this is basically a switch statement from C, without fallthrough. The first match is chosen, and if nothing matches, you simply continue on. (If you want to fail, there is a catch-all form you’ll see later).
13.2. Tuple unpacking#
But this is just the surface. Let’s start to expand this to match structure by looking at a place where Python already has pattern matching: assignments. This works, and has for many years (in fact, Python 2 even supported it inside function definitions):
x, y = (1, 2)
a, (b, c) = (3, (4, 5))
This is the basis for pattern matching - it’s expanded to include dicts and classes, but it is much like this. Here’s what it looks like:
item = (1, 2)
match item:
case (x, y, z):
print(f"{x} {y} {z}")
case (x, y):
print(f"{x} {y}")
case (x,):
print(f"{x}")
1 2
Notice that simple variable names get assigned, just like on the left side of the equals in the previous examples.
We can combine the two things we have seen so far, too:
item = (3, (4, 5))
match item:
case (3, (x, 5)):
print(x)
4
Now this tries to “match” structurally with each case - when one is allowed, it gets unpacked and you can use those values (you still can use the original value, too). If no cases match, you simply continue on.
A tiny detail - since this is trying to match each one, top to bottom, it’s not using iteration, but rather getitem access. So an generic iterator will not work here, like in normal unpacking. You’d have to have a restartable iterator anyway, so it’s not a big deal, but might be useful to know. Also, strings are not included, even though they have a getitem, as that would be a large source of bugs -
(x, y)
does not match a length 2 string.
A single *
argument is supported, too, just like normal unpacking.
values = [1, 2, 3, 4]
match values:
case (a, *b):
print(a, b)
1 [2, 3, 4]
13.3. Other forms of unpacking#
You can unpack several other things: dicts, classes, and a single value - which must always come last, since it matches everything. All of these can be recursively nested, as well.
item = {"one": "two"}
match item:
case {"one": x}:
print(f"Got {x = }!")
Got x = 'two'!
There are a few rules on this being an actual mapping. It is a non-exhaustive match - if there are other key/value pairs, you can get them with **other
, but you don’t have to add this to make it ignore extra values; it always matches if the key/value pairs are there. You also need to specify the key.
Classes match via isinstance
; if you give an “empty” class, you can access it using the original match argument:
class AClass:
def __init__(self, value):
self.thing = value
item = AClass(32)
match item:
case AClass():
print(f"Got {item = }!")
Got item = <__main__.AClass object at 0x7f8e7c7875e0>!
Classes look up attributes if you give names:
match item:
case AClass(thing=x):
print(f"Got {x = }!")
Got x = 32!
And if you have positional arguments, there is a __match_args__
attribute that maps positional arguments to keyword arguments (and you get this for free if you use use dataclass
!):
class BClass:
__match_args__ = ("thing",)
def __init__(self, value):
self.thing = value
item = BClass(32)
match item:
case BClass(x):
print(f"Got {x = }!")
Got x = 32!
import dataclasses
@dataclasses.dataclass
class CClass:
thing: int
item = CClass(32)
match item:
case CClass(x):
print(f"Got {x = }!")
match item:
case CClass(thing=x):
print(f"Got {x = }!")
Got x = 32!
Got x = 32!
Built in classes have some custom handling here, so things like
int(x)
work as expected - they basically returnself
. But arbitrary classes are intended to match what they are constructed with, not usually themselves - you already have that. You can also useMyClass() as x
if you want.
Many cases where you might have used isinstance
before can be replaced with pattern matching!
Single value unpacking will match anything, so it should always come last.
item = 3.0
match item:
case float(x):
print(f"float {x = }!")
case int() as x:
print(f"int {x = }!")
case _:
print("Could be anything else")
float x = 3.0!
Note the _
is treated slightly specially - it is allowed multiple times, unlike normal unpacking.
13.4. Guards#
You can also set up guards - if statements are allowed after the match, just like in comprehensions. Remember, matches happen top to bottom, first one wins.
item = 3.0
match item:
case float(x) if x < 3:
print(f"Small.")
case float(x) if x >= 3:
print(f"Big!")
Big!
Sadly, guards are still not allowed on normal loops for consistency with comprehensions and now case
s, or on arbitrary statements (like in Ruby).
13.5. Multiple patterns#
You can “or” together multiple patterns.
item = 3.0
match item:
case float(x) | int(x):
print(f"{x = } is an int or a float")
x = 3.0 is an int or a float
This can be a bit tricky, since you don’t know which or pattern matched - use multiple case statements instead if that matters.
13.6. Some examples#
You’ve now seen the syntax. Let’s look at a couple of examples:
13.6.1. Example 1: strings#
Let’s say you want to select behavior based on the first character of an input string. “*” followed by nothing else needs to be skipped, “+” needs to be ignored, and “-” needs to be inverted.
values = ["12", "-7", "+6", "*"]
for val in values:
match val[0], val[1:]:
case "*", "":
pass
case "+", c:
print(c)
case "-", c:
print("invert:", c)
case _:
print(val)
12
invert: 7
6
13.6.2. Example 2: Streams vs. filenames#
Let’s say you were going to use isinstance
to dispatch differently based on a iostream or a string input:
import os
from pathlib import Path
file_or_name = Path("str.txt")
match file_or_name:
case os.PathLike() as p:
print(f"Use open({os.fspath(p)!r}) (path)")
case str(x):
print(f"Use open({x!r})")
case io.IOBase():
print("Is already a file object")
case _:
raise RuntimeError("Not a valid file or filename!")
Use open('str.txt') (path)
Note this is not quite as elegant as it could have been; Path does not have positional support, and os.PathLike
might not ever be able to have it.
13.6.3. Example 3: Picking something from a structure#
Let’s say you had some data structures, and you are looking for information that could be in several different places. You could do it like this:
d = {
"name": "Me Myself",
"first_name": "Me",
"last_name": "Myself",
}
match d:
case {"first_name": fn, "last_name": ln}:
print(f"{fn} {ln} (added)")
case {"name": nm}:
print(nm)
Me Myself (added)
13.6.4. Example 4: parsing commands#
Here’s an example running some different commands, like for a game:
def parse_command(*args: str | int) -> None:
match args:
case (
("go", ("north" | "east" | "west" | "south") as direction, int(x))
| ("go", int(x), ("north" | "east" | "west" | "south") as direction)
):
print(f"Moving {x} {direction}")
case ("press", str(btn)) if btn in {"button", "switch", "lever"}:
print(f"Pressing {btn}")
case _:
raise RuntimeError("Failed to understand command")
parse_command("go", "north", 4)
parse_command("go", 2, "west")
parse_command("press", "switch")
Moving 4 north
Moving 2 west
Pressing switch
Notice how readable this remains, even for complex commands! The fact you can nest this makes it very powerful for parsing.
13.7. Reminder about new features#
Can’t this be written with chained and nested ifs, ifinstances, and manual unpacking? Absolutely it can. But like other new features, it is more restrictive, making it easier to read, comprehend, and reason about. It specifies that you are trying to match a single variable, while chained ifs could be doing anything, so a reader must check every if to see if the same variable is being used, etc.