Introduction
Structural Pattern Matching
is a new feature introduced in Python
3.10, and perhaps the
most intriguing one. It takes an object to inspect (following
match) and multiple patterns to match against (following one or
more case). If there is a match, a pattern-specific code block
runs. The basic syntax is:
error_code = 403match error_code: case 400: print("Bad request") case 403: print("Unauthorized") case 500: print("Internal server error") case _:the default case when none of the above matches print("Something's wrong with the Internet")#> UnauthorizedHere, we check case by case whether error_code equals a value; if it’s
not one of 400, 403 or 500, _ acts as a catch-all wildcard.
Also, pattern matching stops when it hits a match, so each case should
be independent.
To run the examples in this article, make sure you are using Python 3.10 or higher.
import syssys.version#> '3.10.14 (main, Mar 19 2024, 21:46:16) [Clang 15.0.0 (clang-1500.3.9.4)]'Pattern Matching as a Replacement for switch Statements
The most obvious use of pattern matching is implementing the
switch ... case statements found in many other programming languages,
as a replacement for multiple if-else statements. Imagine we are
building a game with a move function for controlling figure movement:
def move(self, direction): match direction: case "up": self.move_up() case "down": self.move_down() case "left": self.move_left() case "right": self.move_right()Match Against Dictionaries and Lists
The real power of pattern matching resides in destructuring data structures like lists and dictionaries.
match {"first_name": "Jane", "last_name": "Doe", "middle_name": ""}: case {"first_name": first_name , "last_name": last_name}: print(first_name, last_name)#> Jane DoeIn the example above, the pattern says the object should contain the
keys “first_name” and “last_name”; if it matches, their values are
captured into the variables first_name and last_name. Note that for
a dictionary, structural pattern matching matches the structure instead
of the exact content: the pattern matches as long as “first_name” and
“last_name” are among the object’s keys, and any unmentioned keys, like
“middle_name”, are ignored.
For a more advanced example, suppose we are working with the SpaceX API to retrieve launch data. An individual launch record looks like this:
{ "flight_number": 1, "mission_name": "FalconSat", "upcoming": false, "launch_year": "2006", "launch_date_unix": 1143239400, "rocket": { "rocket_id": "falcon1", "rocket_name": "Falcon 1", }, "details": "Engine failure at 33 seconds and loss of vehicle", //... other fields}We only want the fields mission_name, rocket_name and details:
import requestsimport jsonimport pprintres = requests.get("https://api.spacexdata.com/v3/launches?limit=3")data = res.json()out = []for launch in data: match launch: case {"mission_name": mname, "rocket": {"rocket_name": rname}, "details": details}: out.append({"mission_name": mname, "rocket_name": rname, "details": details})
print(json.dumps(out, indent = 2))[ { "mission_name": "FalconSat", "rocket_name": "Falcon 1", "details": "Engine failure at 33 seconds and loss of vehicle" }, { "mission_name": "DemoSat", "rocket_name": "Falcon 1", "details": "Successful first stage burn and transition to second stage, maximum altitude 289 km, Premature engine shutdown at T+7 min 30 s, Failed to reach orbit, Failed to recover first stage" }, { "mission_name": "Trailblazer", "rocket_name": "Falcon 1", "details": "Residual stage 1 thrust led to collision between stage 1 and stage 2" }]Here, we not only match the top-level keys mission_name and details,
but also use a nested pattern to extract the rocket name.
You can also match on a list.
match [1, 2, 3]: case [1, 2]: print("match 1") case [2, *rest]:match the the rest of elements print("match 2") print(rest) case[*other, last]:match elements except the last one print("match 3") print(last) print(other)#> match 3#> 3#> [1, 2]I mentioned that pattern matching on a dict does not require us to
declare all fields explicitly. Lists are different: we have to declare
all elements, which is why match 1 fails. Match 2 attempts to use the
* syntax to capture all values after 2 into a list called rest. This
helps when the list is very large and it’s impossible to exhaust all
elements, or when we simply don’t care about some elements. However, the
second pattern does not specify the correct order, as 2 is not the first
element in the matching object. Match 3 captures all elements but the
last into other, and assigns the last element to last.
It’s also possible to express or logic with | in a case statement.
For example, we may add a case 401 | 403 | 404: "Not allowed"
statement to our first example. Here is another example from the PEP:
match command.split(): ... # Other cases case ["north"] | ["go", "north"]: current_room = current_room.neighbor("north") case ["get", obj] | ["pick", "up", obj] | ["pick", obj, "up"]: ... # Code for picking up the given objectLiteral patterns can also be captured using the as keyword:
match command.split(): case ["go", ("north" | "south" | "east" | "west") as direction]: current_room = current_room.neighbor(direction)Add Conditions
Pattern matching allows if conditions the same way as list
comprehensions: we can append an if ... else clause like so:
all_directions = ["up", "down"]
match commad.split(): case ["go", direction] if direction in all_directions: move(direction) case ["go", _]: print("direction not supported")For conditions that check whether an object is of a data type, we can directly use the class creator function:
def sum_list(numbers): match numbers: case []: return 0 case [int(first) | float(first), *rest]: return first + sum_list(rest) case _: raise ValueError(f"Can only sum lists of numbers")
sum_list([1, 2, 3])#> 6sum_list([1, "2", 3])#> Can only sum lists of numbersHere int(first) | float(first) checks whether the first element is
an integer or float. The next example uses a dataclass-based pattern
along with its attributes:
from dataclasses import dataclass
@dataclassclass Point: x: float y: float
p = Point(x = 1, y = 1)# data class for validationmatch p: case Point(x = x): print(x)#> 1In general, any class can be used for validation. This is also called a class pattern.
Matching Against Constants
Be careful when matching constants, since it’s common to store a
constant in a variable and then use it in case. The following code
will not work:
direction = "up"
match "down": case direction:capture "up" into the variable `direction` print("match") case _: print("no match")We are trying to match the value “up”, but Python interprets it as a
capture pattern that stores whatever the match object is into the
variable direction, so we are effectively overriding direction
(recall the list matching example) and the first case always matches.
Python rejects this with the error message
SyntaxError: name capture 'direction' makes remaining patterns unreachable.
Instead of matching literal values, we can use an enum type for constant matching:
import enumclass Direction(str, enum.Enum): up = "up" down = "down"
match "down": case Direction.up: print("match 1") case Direction.down: print("match 2") case _: print("match 3")#> match 2