Python case|match - structural pattern matching
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.
Python structural pattern matching
Structural pattern matching introduces the match/case statement and the pattern syntax to Python. It takes an object, tests the object against one or more match patterns, and takes an action if it finds a match. To apply structural pattern matching, you will need to use two new keywords: match and case.
day = 7
match day:
case 6:
print("Saturday")
case 7:
print("Sunday")
case _:
print("Almost Weekend")
# output
Sunday
match
keyword, followed by the variable you want to match on. Then, in the match block, you write cases with the case
keyword, followed by the value you want the variable to be equal to. Lastly, the case _:
block will match anything: it it the default handler.
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.
Let’s explore some more challenging examples. For example, you can specify multiple conditions for a single case block like this:
month = 'jan'
match month:
case 'jan' | 'mar' | 'may' | 'jul' | 'aug' | 'oct' | 'dec':
print(31)
case 'apr' | 'jun' | 'sep' | 'nov':
print(30)
case 'feb':
print(28)
case _:
print('Bad month!')
# output
31
This snippet will print out the number of days in a month, given by the month variable. To use multiple values, we separate them with the |
(or) operator. We could take it even further and check for leap year, using the guard expression:
month = 'jan'
match month:
case 'jan' | 'mar' | 'may' | 'jul' | 'aug' | 'oct' | 'dec':
print(31)
case 'apr' | 'jun' | 'sep' | 'nov':
print(30)
case 'feb' if is_leap_year():
print(29)
case 'feb':
print(28)
case _:
print('Bad month!')
Suppose that is_leap_year
is a function that returns true if the current year is a leap year. In this case, Python will firstly evaluate the condition, and, if met, print 29. If not, it will go to the next condition and print 28.
Packing and unpacking
Structural pattern matching provides much more features than basic switch-case statements. You could use it to evaluate complex data structures and extract data from them. For example, suppose you store date in a tuple of form (day, month, year)
, all integers. This code will print out what season is it, along with day and year:
date = (29, 6, 2021)
match date:
case (day, 12, year) | (day, 1, year) | (day, 2, year):
print(f'Winter of {year}, day {day}')
case (day, 3, year) | (day, 4, year) | (day, 5, year):
print(f'Spring of {year}, day {day}')
case (day, 6, year) | (day, 7, year) | (day, 8, year):
print(f'Summer of {year}, day {day}')
case (day, _, year):
print(f'Fall of {year}, day {day}')
case _:
print('Invalid data')
# output
Summer of 2021, day 29
You can see we are using the tuple syntax to capture the values. By placing day
and year
names in place of values, we are telling Python we want those values extracted and made available as variables. By placing actual number value in place of month, we are checking only for that value. When we check for fall, you can notice we are using the _
symbol again. It is a wildcard and will match all other month values that were missed by the previous checks. Lastly, we have one more wildcard case to account for badly formatted data.
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. 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()
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 variablefilename
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 variablecommand
in the body of thecase
block; nothing has been captured)
Patterns in Python structural pattern matching
Small example first:
for item in [[1, 2], [9, 10], [1, 2, 3], [1], [0, 0, 0, 0, 0]]:
match item:
case [x]:
print(f"single value: {x}")
case [x, y]:
print(f"two values: {x} and {y}")
case [x, y, z]:
print(f"three values: {x}, {y} and {z}")
case _:
print("too many values")
# output
two values: 1 and 2
two values: 9 and 10
three values: 1, 2 and 3
single value: 1
too many values
And example with check first or second item in collection:
for item in [[1,2], [9,10], [3,4], [1,2,3], [1], [0,0,0,0,0]]:
match item:
case [x]:
print(f"single value: {x}")
case [1, y]:
print(f"two values: 1 and {y}")
case [x, 10]:
print(f"two values: {x} and 10")
case [x, y]:
print(f"two values: {x} and {y}")
case [x, y, z]:
print(f"three values: {x}, {y} and {z}")
case _:
print("too many values")
# output
two values: 1 and 2
two values: 9 and 10
two values: 3 and 4
three values: 1, 2 and 3
single value: 1
too many values
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 variablevalue1
. -
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"):
Theor
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 variableletter
. -
case ["a", value] if <expression>:
Matches the capture only ifexpression
is true. Capture variables can be used in the expression. For instance, if we usedif value in valid_values
, the case would only be valid if the captured valuevalue
was in fact in the collectionvalid_values
. -
case ["z", _]:
Any collection of items that begins with"z"
will match. -
Match sequences using list or tuple syntax (like Python’s existing iterable unpacking feature)
-
Match mappings using dict syntax
-
Use
*
to match the rest of a list -
Use
**
to match other keys in a dict -
Match objects and their attributes using class syntax
-
Include or patterns with
|
-
Capture sub-patterns with
as
-
Include an
if
guard clause
Here is a program that will match a list with any number of elements.
for thing in [[1,2,3,4], ['a','b','c'], "this won't be matched"]:
match thing:
case [*y]:
for i in y:
print(i)
case _:
print("unknown")
# output
1
2
3
4
a
b
c
unknown
example:
items = [1, 2, 3, 4, 5, 6, 7, 8, 9]
match items:
case [1, *x]:
print(x)
# output
[2, 3, 4, 5, 6, 7, 8, 9]
match items:
case [*x, 9]:
print(x)
# output
[1, 2, 3, 4, 5, 6, 7, 8]
match items:
case [2|1, *x]:
print(x)
# output
[2, 3, 4, 5, 6, 7, 8, 9]
match items:
case [1|2, *x, 9]:
print(x)
# output
[2, 3, 4, 5, 6, 7, 8]
example:
dialog = [
["The Dead Parrot Sketch"],
["Cast", "Customer", "Shop Owner"],
["Customer", "He's gone to meet his maker."],
["Owner", "Nah, he's resting."],
["Customer", "He should be pushing up the daisies."],
["Owner", "He's pining for the fjords"],
["Customer", "He's shuffled off his mortal coil."],
["Customer", "This is a dead parrot!"]
]
for line in dialog:
match line:
case [title]:
print(title)
case ["Cast", *actors]:
print("Cast:")
for a in actors: print(a)
print("---")
case [("Customer"|"Owner") as person, line] :
print(f"{person}: {line}")
# output
The Dead Parrot Sketch
Cast:
Customer
Shop Owner
---
Customer: He's gone to meet his maker.
Owner: Nah, he's resting.
Customer: He should be pushing up the daisies.
Owner: He's pining for the fjords
Customer: He's shuffled off his mortal coil.
Customer: This is a dead parrot!
Dictionary structural pattern matching
Similarly, we can use **
to match the remainder of a dictionary. But first, let us see what is the behaviour when matching dictionaries:
d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
case {2: "two"}:
print("yes")
# output
yes
Please note, that case {"key": value}
doesn't mean that only single-element dictionary will be matched. It will match any dictionary containing "key", but the length of this dict doesn't matter.
When matching with dictionaries, we only care about matching the structure that was explicitly mentioned, and any other extra keys
that the original dictionary has are ignored. This is unlike matching with lists or tuples, where the match has to be perfect if no wildcard is mentioned.
While d
has a key 2
with a value "two"
, there is a match and we enter the statement.
Double asterisk **
However, if you want to know what the original dictionary had that was not specified in the match, you can use a **
wildcard:
d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
case {2: "two", **remainder}:
print(remainder)
# output
{0: 'zero', 1: 'one', 3: 'three'}
Finally, you can use this to your advantage if you want to match a dictionary that contains only what you specified:
d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
case {2: "two", **remainder} if not remainder:
print("Single key in the dictionary")
case {2: "two"}:
print("Has key 2 and extra stuff.")
# output
Has key 2 and extra stuff.
You can also use variables to match the values of given keys:
d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
case {0: zero_val, 1: one_val}:
print(f"0 mapped to {zero_val} and 1 to {one_val}")
# output
0 mapped to zero and 1 to one
Class matchers
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.
example 1
match media_object:
case Image(type="jpg"):
# Return as-is
return media_object
case Image(type="png") | Image(type="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 type {media_object} 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 type 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.
You can also perform captures with object matches:
match media_object:
case Image(type=media_type):
print (f"Image of type {media_type}")
example 2
Suppose you have a class like this:
class Vector:
speed: int
acceleration: int
You can use its attributes to match Vector objects:
vec = Vector()
vec.speed = 4
vec.acceleration = 1
match vec:
case Vector(speed=0, acceleration=0):
print('Object is standing still')
case Vector(speed=speed, acceleration=0):
print(f'Object is travelling at {speed} m/s')
case Vector(speed=0, acceleration=acceleration):
print(f'Object is accelerating from standing still at {acceleration} m/s2')
case Vector(speed=speed, acceleration=acceleration):
print(f'Object is at {speed} m/s, accelerating at {acceleration} m/s2')
case _:
print('Not a vector')
# output
Object is at 4 m/s, accelerating at 1 m/s2
define a default order for class arguments
Now, I don't know if you noticed, but didn't all the speed=
and acceleration=
in the code snippet above annoy you? Every time I wrote a new pattern for a Vector
instance, I had to specify what argument was speed
and what was acceleration
. For classes where this order is not arbitrary, we can use __match_args__
to tell Python how we would like match to match the attributes of our object.
Here is a shorter version of the example above, making use of __match_args__
to let Python know the order in which arguments to Vector
should match:
class Vector2:
__match_args__ = ("speed", "acceleration")
def __init__(self, speed:int, acceleration:int):
self.speed = speed
self.acceleration = acceleration
def describe_vector(vec):
match vec:
case Vector2(0, 0):
print('Object is standing still')
case Vector2(speed, 0):
print(f'Object is travelling at {speed} m/s')
case Vector2(0, acceleration):
print(f'Object is accelerating from standing still at {acceleration} m/s2')
case Vector2(speed, acceleration):
print(f'Object is at {speed} m/s, accelerating at {acceleration} m/s2')
case _:
print('Not a vector')
describe_vector(Vector2(0, 0))
# Object is standing still
describe_vector(Vector2(4, 0))
# Object is travelling at 4 m/s
describe_vector(Vector2(0, 6))
# Object is accelerating from standing still at 6 m/s2
describe_vector(Vector2(8, 12))
# Object is at 8 m/s, accelerating at 12 m/s2
__match_args__
allows to define a default order for arguments to be matched in when a custom class is used in a case
More Python structural pattern matching examples
example 1
Another place match
might be useful is when validating the structure of JSON from an HTTP request:
try:
obj = json.loads(request.body)
except ValueError:
raise HTTPBadRequest(f'invalid JSON: {request.body!r}')
match obj:
case {
'action': 'sign-in',
'username': str(username),
'password': str(password),
'details': {'email': email, **other_details},
} if username and password:
sign_in(username, password, email=email, **other_details)
case {'action': 'sign-out'}:
sign_out()
case _:
raise HTTPBadRequest(f'invalid JSON structure: {obj}')
example 2
command = input("What are you doing next? ")
match command.split():
case ["quit"]:
print("Goodbye!")
quit_game()
case ["look"]:
current_room.describe()
case ["get", obj]:
character.get(obj, current_room)
case ["drop", *objects]:
for obj in objects:
character.drop(obj, current_room)
case ["go", direction] if direction in current_room.exits:
current_room = current_room.neighbor(direction)
case ["go", _]:
print("Sorry, you can't go that way")
case _:
print(f"Sorry, I couldn't understand {command!r}")
example 3
command = input("What are you doing next? ")
fields = text.split()
n = len(fields)
if fields == ["quit"]:
print("Goodbye!")
quit_game()
elif fields == ["look"]:
current_room.describe()
elif n == 2 and fields[0] == "get":
obj = fields[1]
character.get(obj, current_room)
elif n >= 1 and fields[0] == "drop":
objects = fields[1:]
for obj in objects:
character.drop(obj, current_room)
elif n == 2 and fields[0] == "go":
direction = fields[1]
if direction in current_room.exits:
current_room = current_room.neighbor(direction)
else:
print("Sorry, you can't go that way")
else:
print(f"Sorry, I couldn't understand {command!r}")
!a
applies ascii()
, !s
applies str()
, and !r
applies repr()
.
example 4
def factorial(n):
match n:
case 0 | 1:
return 1
case _:
return n * factorial(n - 1)
factorial(5)
# output
120