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 = 403
match 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")
#> Unauthorized

Here, 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 sys
sys.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 Doe

In 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 requests
import json
import pprint
res = 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 object

Literal 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])
#> 6
sum_list([1, "2", 3])
#> Can only sum lists of numbers

Here 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
@dataclass
class Point:
x: float
y: float
p = Point(x = 1, y = 1)
# data class for validation
match p:
case Point(x = x):
print(x)
#> 1

In 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 enum
class 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