06 - 1. Pythonic Thinking
06 - 1. Pythonic Thinking
Pythonic Thinking
The idioms of a programming language are defined by its users. Over the
years, the Python community has come to use the adjective Pythonic to
describe code that follows a particular style. The Pythonic style isn’t
regimented or enforced by the compiler. It has emerged over time
through experience using the language and working with others. Python
programmers prefer to be explicit, to choose simple over complex, and
to maximize readability. (Type import this into your interpreter to
read The Zen of Python.)
Throughout this book, the majority of example code is for Python 3.12
(released in October, 2023). This book also provides some examples
relying on features from Python 3.13 (released in October, 2024) to
highlight new capabilities that will be more widely available soon. This
book does not cover Python 2. Older versions of Python 3 are sometimes
mentioned, but only to provide background information.
Many computer operating systems ship with multiple versions of the
standard CPython interpreter preinstalled. However, the default meaning
of python on the command line may not be clear. python is usually an
alias for python2.7 , but it can sometimes be an alias for even older
versions, like python2.6 or python2.5 . To find out exactly which
version of Python you’re using, you can use the --version flag:
$ python --version
Python 2.7.10
$ python --version
-bash: python: command not found
$ python3 --version
Python 3.12.3
$ pypy3 --version
Python 3.10.14 (75b3de9d9035, May 28 2024, 18:06:40)
[PyPy 7 3 16 with GCC Apple LLVM 15 0 0 (clang-1500 3 9 4)]
[PyPy 7.3.16 with GCC Apple LLVM 15.0.0 (clang-1500.3.9.4)]
You can also figure out the version of Python you’re using at runtime by
inspecting values in the sys built-in module:
import sys
print([Link])
print([Link])
print(sys.version_info)
print([Link])
>>>
darwin
cpython
sys.version_info(major=3, minor=12, micro=3,
releaselevel='final', serial=0)
3.12.3 (main, Apr 9 2024, 08:09:14) [Clang 15.0.0 (clang-
1500.3.9.4)]
For a long time, the Python core developers and community were
actively maintaining support for both Python 2 and Python 3. The
versions are different in significant ways and have incompatibilities that
made porting difficult. The migration from 2 to 3 was an extremely long
and painful period that finally came to an end on April 20th, 2020, when
Python version 2.7.18 was published. This was the final official release of
Python 2. For anyone who still needs security patches and bug fixes for
Python 2, the only remaining options are to pay a commercial software
vendor for support or do it yourself.
Since then and continuing now, the Python core developers and
community are focused on Python version 3. The functionality of the
core language, the standard library, and the ecosystem of packages and
tools are constantly being improved. Keeping up with all of the changes
and innovations happening can be overwhelming. One good way to find
out about what’s new is to read the release notes
([Link] which highlight
additions and changes for each version. There are other websites out
there that will also notify you when the community packages you rely on
are updated (see Item 116: “Know Where to Find Community-Built
Modules”).
Things to Remember
PEP 8 provides a wealth of details about how to write clear Python code.
It continues to be updated as the Python language evolves. It’s worth
reading the whole guide online ([Link]
0008/). Here are a few rules you should be sure to follow.
Whitespace
• In a dictionary, put no whitespace between each key and colon, and put
a single space before the corresponding value if it fits on the same line.
Naming
• Class methods should use cls , which refers to the class, as the name of
the first parameter.
The Zen of Python states: "There should be one—and preferably only one
—obvious way to do it." PEP 8 attempts to codify this style in its guidance
for expressions and statements:
• The same thing goes for non-empty containers or sequences (like [1]
or "hi" ). The statement if somelist is implicitly True for non-empty
values.
Imports
PEP 8 suggests some guidelines for how to import modules and use them
in your code:
• Always use absolute names for modules when importing them, not
names relative to the current module’s own path. For example, to import
the foo module from within the bar package, you should use from bar
import foo , not just import foo .
• If you must do relative imports, use the explicit syntax from . import
foo .
Automation
If that all seems like a lot to remember, I have good news: The Python
community is coalescing around a common tool for automatic PEP 8
formatting: it's called black ([Link] and it's an
official Python Software Foundation project. black provides very few
configuration options, which makes it easy for developers working on
the same code base to agree on the style of code. Installing and using
black is straightforward:
All done!
1 file reformatted.
Besides black , there are many other community tools to help you
improve your source code automatically. Many IDEs and editors include
style-checking tools, auto-formatters, and similar plug-ins. One popular
code analyzer is pylint ([Link] it helps
enforce the PEP 8 style guide and detects many other types of common
errors in Python programs (see Item 3: “Never Expect Python to Detect
Errors at Compile Time” for more examples).
Things to Remember
When loading a Python program and preparing for execution, the source
code is parsed into abstract syntax trees and checked for obvious
structural errors. For example, a poorly constructed if statement will
raise a SyntaxError exception indicating what’s wrong with the code:
>>>
Traceback ...
SyntaxError: expected ':'
Errors in value literals will also be detected early and raise an exception:
>>>
Traceback ...
SyntaxError: invalid imaginary literal
Unfortunately, that’s about all the protection you can expect from Python
before execution. Anything beyond basic tokenization errors and parse
errors will not be flagged as a problem.
Even simple functions that seem to have obvious errors will not be
reported as having problems before program execution, due to the
highly dynamic nature of Python. For example, here, I define a function
where the my_var variable is clearly not assigned before it’s passed to
print :
def bad_reference():
print(my_var)
my_var = 123
bad_reference()
>>>
Traceback ...
UnboundLocalError: cannot access local variable 'my_var' wher
is not associated with a value
The reason this isn’t considered a static error is because it’s valid for
Python programs to dynamically assign local and global variables. For
example, here I define a function that is valid or not depending on the
input argument:
def sometimes_ok(x):
if x:
my_var = 123
print(my_var)
sometimes_ok(True)
>>>
123
sometimes_ok(False)
>>>
Traceback ...
UnboundLocalError: cannot access local variable 'my_var' wher
is not associated with a value
Python also won’t catch math errors upfront. It would seem that this is
clearly an error before the program executes:
def bad_math():
return 1 / 0
But it’s possible for the meaning of the division operator to vary based
the values involved, so checking for errors like this is similarly deferred
until runtime:
bad_math()
>>>
Traceback ...
ZeroDivisionError: division by zero
Things to Remember
• Python defers nearly all error checking until runtime, including
detection of problems that seem like they should be obvious during
program start-up.
• Community projects like linters and static analysis tools can help catch
some of the most common sources of errors before program execution.
my_values = parse_qs("red=5&blue=0&green=",
keep_blank_values=True)
print(repr(my_values))
>>>
{'red': ['5'], 'blue': ['0'], 'green': ['']}
Some query string parameters may have multiple values, some may
have single values, some may be present but have blank values, and
some may be missing entirely. Using the get method on the result
dictionary will return different values in each circumstance:
print("Red: ", my_values.get("red"))
print("Green: ", my_values.get("green"))
print("Opacity:", my_values.get("opacity"))
>>>
Red: ['5']
Green: ['']
Opacity: None
Python’s syntax makes this choice all too easy. The trick here is that the
empty string, the empty list , and zero all evaluate to False implicitly.
Thus, the expressions below will evaluate to the subexpression after the
or operator when the first subexpression is False :
>>>
Red: '5'
Green: 0
Opacity: 0
The red case works because the key "red" is present in the my_values
dictionary. The value retrieved by the get method is a list with one
member: the string "5" . This item is retrieved by accessing index zero in
the list. Then, the or expression determines that the string is not empty
and thus is the resulting value of that operation. Finally, the variable
red is assigned to the value "5" .
The green case works because the value in the my_values dictionary is
a list with one member: an empty string. The item at index zero in the
list is retrieved. The or expression determines the string is empty and
thus its return value should be the right side argument to the operation,
which is zero. Finally, the variable green is assigned to the value 0 .
The opacity case works because the value in the my_values dictionary
is missing altogether. The behavior of the get method is to return its
second argument if the key doesn’t exist in the dictionary (see Item 26:
“Prefer get Over in and KeyError to Handle Missing Dictionary
Keys”). The default value in this case is a list with one member: an
empty string. Thus, when opacity isn’t found in the dictionary, this code
does exactly the same thing as the green case.
This logic is now extremely hard to read. There’s so much visual noise.
The code isn’t approachable. A new reader of the code would have to
spend too much time picking apart the expression to figure out what it
actually does. Even though it’s nice to keep things short, it’s not worth
trying to fit this all on one line.
Now that this logic is spread across multiple lines, it’s a bit harder to copy
and paste for assigning other variables (e.g., red ). If I want to reuse this
functionality repeatedly—even just two or three times, as in this
example—then writing a helper function is the way to go:
The calling code is much clearer than the complex expression using or
and the two-line version using the conditional expression:
Things to Remember
• Python’s syntax makes it all too easy to write single-line expressions
that are overly complicated and difficult to read.
Python has a built-in tuple type that can be used to create immutable,
ordered sequences of values (see Item 56: “Prefer dataclasses for
Creating Immutable Objects” for similar data structures). Tuples can be
empty or contain a single item:
no_snack = ()
snack = ("chips",)
Tuples can also include multiple items, such as in these key-value pairs
from a dictionary:
snack_calories = {
"chips": 140,
"popcorn": 80,
"nuts": 190,
}
items = list(snack_calories.items())
print(items)
>>>
[('chips', 140), ('popcorn', 80), ('nuts', 190)]
>>>
Peanut butter
('Peanut butter',)
>>>
Traceback ...
TypeError: 'tuple' object does not support item assignment
Python also has syntax for unpacking, which allows for assigning
multiple values in a single statement. The patterns that you specify in
unpacking assignments look a lot like trying to mutate tuples—which
isn’t allowed—but they actually work quite differently. For example, if
you know that a tuple is a pair, instead of using indexes to access its
values, you can assign it to a tuple of two variable names:
>>>
Peanut butter and Jelly
Unpacking has less visual noise than accessing the tuple’s indexes, and it
often requires fewer lines of code. The same pattern matching syntax of
unpacking works when assigning to lists, sequences, and multiple levels
of arbitrary iterables within iterables. I don’t recommend doing the
following in your code, but it’s important to know that it’s possible and
how it works:
favorite_snacks = {
"salty": ("pretzels", 100),
"sweet": ("cookies", 180),
"veggie": ("carrots", 20),
}
((type1, (name1, cals1)),
(type2, (name2, cals2)),
(type3, (name3, cals3))) = favorite_snacks.items()
>>>
Favorite salty is pretzels with 100 calories
Favorite sweet is cookies with 180 calories
Favorite veggie is carrots with 20 calories
def bubble_sort(a):
for _ in range(len(a)):
for i in range(1, len(a)):
if a[i] < a[i - 1]:
temp = a[i]
a[i] = a[i - 1]
a[i - 1] = temp
>>>
['arugula', 'bacon', 'carrots', 'pretzels']
However, with unpacking syntax, it’s possible to swap indexes in a single
line:
def bubble_sort(a):
for _ in range(len(a)):
for i in range(1, len(a)):
if a[i] < a[i - 1]:
a[i - 1], a[i] = a[i], a[i - 1] # Swap
>>>
['arugula', 'bacon', 'carrots', 'pretzels']
The way this swap works is that the right side of the assignment ( a[i],
a[i-1] ) is evaluated first, and its values are put into a new temporary,
unnamed tuple (such as ("carrots", "pretzels") on the first
iteration of the loops). Then, the unpacking pattern from the left side of
the assignment ( a[i-1], a[i] ) is used to receive that tuple value and
assign it to the variable names a[i-1] and a[i] , respectively. This
replaces "pretzels" with "carrots" at index 0 and "carrots" with
"pretzels" at index 1 . Finally, the temporary unnamed tuple silently
goes away.
>>>
#1: bacon has 350 calories
#2: donut has 240 calories
#3: muffin has 190 calories
This works, but it’s noisy. There are a lot of extra characters required in
order to index into the various levels of the snacks structure. Now, I
achieve the same output by using unpacking along with the enumerate
built-in function (see Item 17: “Prefer enumerate Over range ”):
>>>
#1: bacon has 350 calories
#2: donut has 240 calories
#3: muffin has 190 calories
This is the Pythonic way to write this type of loop; it’s short and easy to
understand. There’s usually no need to access anything using indexes.
Using unpacking wisely will enable you to avoid indexing when possible,
resulting in clearer and more Pythonic code. However, these features are
not without pitfalls to consider (see Item 6: “Always Surround Single-
Element Tuples with Parentheses”). Unpacking also doesn’t work in
assignment expressions (see Item 8: “Prevent Repetition with
Assignment Expressions”).
Things to Remember
• You can reduce visual noise and increase code clarity by using
unpacking to avoid explicitly indexing into sequences.
In Python there are four kinds of tuple literal values. The first kind is a
comma-separated list of items inside open and close parentheses:
first = (1, 2, 3)
The second kind is the just like the first, but with an optional trailing
comma included, which allows for consistency when going across
multiple lines and eases editing:
And finally, the fourth kind is just like the third, but with an optional
trailing comma:
fourth = 1, 2, 3,
However, there are also three special cases in creating tuples that need to
be considered. The first case is the empty tuple, which is merely open
and close parentheses:
empty = ()
The second special case is the form of single-element tuples: you must
include a trailing comma. If you leave out the trailing comma, then what
you have is a parenthesized expression instead of a tuple:
single_with = (1,)
single_without = (1)
assert single_with != single_without
assert single_with[0] == single_without
And the third special case is similar to the second one except without the
parentheses:
single_parens = (1,)
single_no_parens = 1,
assert single_parens == single_no_parens
to_refund = calculate_refund(
get_order_value(user, [Link]),
get_tax([Link], [Link]),
adjust_discount(user) + 0.1),
You might expect that the return type is an integer, float, or decimal
number containing the amount of money to be refunded to a customer.
But in fact, it’s a tuple!
print(type(to_refund))
>>>
<class 'tuple'>
The problem is the extraneous comma at the end of the final line.
Removing the comma fixes the code:
to_refund2 = calculate_refund(
get_order_value(user, [Link]),
get_tax([Link], [Link]),
adjust_discount(user) + 0.1,
) # No trailing comma
print(type(to_refund2))
>>>
<class 'int'>
>>>
A: (1,)
B: [1]
C: [(1,)]
def get_coupon_codes(user):
...
return [['DEAL20']]
...
(a1,), = get_coupon_codes(user)
(a2,) = get_coupon_codes(user)
(a3), = get_coupon_codes(user)
(a4) = get_coupon_codes(user)
a5, = get_coupon_codes(user)
a6 = get_coupon_codes(user)
Things to Remember
• Single-element tuples require a trailing comma after the one value, and
may have optional surrounding parentheses.
• It’s all too easy to have an extraneous trailing comma at the end of an
expression, changing its meaning into a single-element tuple that breaks
a program.
i = 3
x = "even" if i % 2 == 0 else "odd"
print(x)
>>>
odd
def fail():
raise Exception("Oops")
>>>
20
>>>
[0.0, 0.5, 1.0, 1.5, 2.0]
This form of logic is quite confusing because you need to know that and
returns the first falsey value or the last truthy value, while or returns
the first truthy value or the last falsey value (see Item 23: “Pass Iterators
to any and all for Efficient Short-Circuiting Logic” for details).
Also, the approach of using Boolean operators doesn’t work if you want
to return a falsey value as a result of a truthy condition (e.g., x = (i % 2
== 0 and []) or [1] always evaluates to [1] ). It’s all non-obvious and
error prone, which is part of why conditional expressions were added to
the language in the first place.
if i % 2 == 0:
x = "even"
else:
x = "odd"
Although this is longer, it can be better for a few reasons. First, if I later
want to do more inside each of the condition branches, like printing
debugging information, I can without structurally changing the code:
if i % 2 == 0:
x = "even"
print("It was even!") # Added
else:
x = "odd"
I can also insert additional branches with elif blocks in the same
statement:
if i % 2 == 0:
x = "even"
elif i % 3 == 0: # Added
x = "divisible by three"
else:
x = "odd"
def number_group(i):
if i % 2 == 0:
return "even"
else:
return "odd"
You should avoid conditional expressions when they must be split over
multiple lines. For example, here the function calls I make are so long
that the conditional expression must be line-wrapped with surrounding
parentheses:
x = (my_long_function_call(1, 2, 3) if i % 2 == 0
else my_other_long_function_call(4, 5, 6))
That’s quite difficult to read. And if you apply an auto-formatter (see Item
2: “Follow the PEP 8 Style Guide”) to this code, the conditional expression
will likely be rewritten to use more lines of code than a standard
if / else statement anyway:
x = (
my_long_function_call(1, 2, 3)
if i % 2 == 0
else my_other_long_function_call(4, 5, 6)
)
x = 2
y = 1
if x and z := x > y:
...
>>>
Traceback ...
SyntaxError: cannot use assignment expressions with expressio
With conditional expressions, parentheses aren’t required. Thus, it’s
difficult to decipher what the original intent of the programmer was
since both of these forms are allowed:
z = dict(
your_value=(y := 1),
)
w = dict(
other_value=y := 1,
)
>>>
Traceback ...
SyntaxError: invalid syntax
Conditional expressions, in contrast, don’t require surrounding
parentheses in this context, which can make code noisier and hard to
read:
v = dict(
my_value=1 if x else 3,
)
Things to Remember
• The order of the test expression, true result expression, and false result
expression in a conditional expression is different than ternary
operators in other languages.
For example, say that I have a basket of fresh fruit that I’m trying to
manage for a juice bar. Here, I define the contents of the basket:
fresh_fruit = {
"apple": 10,
"banana": 8,
"lemon": 5,
}
def out_of_stock():
...
count = fresh_fruit.get("lemon", 0)
if count:
make_lemonade(count)
else:
out_of_stock()
>>>
Making 5 lemons into lemonade
The problem with this seemingly simple code is that it’s noisier than it
needs to be. The count variable is used only within the first block of the
if statement. Defining count above the if statement causes it to
appear to be more important than it really is, as if all code that follows,
including the else block, will need to access the count variable, when
in fact that is not the case.
This pattern of fetching a value, checking to see if it’s truthy, and then
using it is extremely common in Python. Many programmers try to work
around the multiple references to count with a variety of tricks that
hurt readability (see Item 4: “Write Helper Functions Instead of Complex
Expressions” and Item 7: “Consider Conditional Expressions for Simple
Inline if Statements”). Luckily, assignment expressions were added to
the language to streamline this type of code. Here, I rewrite the example
above using the walrus operator:
Although this is only one line shorter, it’s a lot more readable because it’s
now clear that count is only relevant to the first block of the if
statement. The assignment expression first assigns a value to the count
variable, and then evaluates that value in the context of the if
statement to determine how to proceed with flow control. This two-step
behavior—assign and then evaluate—is the fundamental nature of the
walrus operator.
Lemons are quite potent, so only one is needed for my lemonade recipe,
which means a non-zero, truthy check is good enough. If a customer
orders a cider, though, I need to make sure that I have at least four
apples. Here, I do this by fetching the count from the fresh_fruit
dictionary, and then using a comparison in the if statement test
expression:
def make_cider(count):
...
count = fresh_fruit.get("apple", 0)
if count >= 4:
make_cider(count)
else:
out_of_stock()
>>>
Making cider with 10 apples
This has the same problem as the lemonade example, where the
assignment of count puts distracting emphasis on that variable. Here, I
improve the clarity of this code by also using the walrus operator:
This works as expected and makes the code one line shorter. It’s
important to note how I needed to surround the assignment expression
with parentheses to compare it with 4 in the if statement. In the
lemonade example, no surrounding parentheses were required because
the assignment expression stood on its own as a non-zero, truthy check;
it wasn’t a subexpression of a larger expression. As with other
expressions, you should avoid surrounding assignment expressions with
parentheses when possible to reduce visual noise.
def slice_bananas(count):
...
class OutOfBananas(Exception):
pass
def make_smoothies(count):
...
pieces = 0
count = fresh_fruit.get("banana", 0)
if count >= 2:
pieces = slice_bananas(count)
try:
smoothies = make_smoothies(pieces)
except OutOfBananas:
out_of_stock()
>>>
Slicing 8 bananas
Making a smoothies with 32 banana slices
try:
smoothies = make_smoothies(pieces)
except OutOfBananas:
out_of_stock()
This second approach can feel odd because it means that the pieces
variable has two different locations—in each block of the if statement
—where it can be initially defined. This split definition technically works
because of Python’s scoping rules (see Item 33: “Know How Closures
Interact with Variable Scope and nonlocal ”), but it isn’t easy to read or
discover, which is why many people prefer the construct above, where
the pieces = 0 assignment is first.
The walrus operator can again be used to shorten this example by one
line of code. This small change removes any emphasis on the count
variable. Now, it’s clearer that pieces will be important beyond the if
statement:
pieces = 0
if (count := fresh_fruit.get("banana", 0)) >= 2: # Changed
pieces = slice_bananas(count)
try:
smoothies = make_smoothies(pieces)
except OutOfBananas:
out_of_stock()
Using the walrus operator also improves the readability of splitting the
definition of pieces across both parts of the if statement. It’s easier to
trace the pieces variable when the count definition no longer
precedes the if statement:
try:
smoothies = make_smoothies(pieces)
except OutOfBananas:
out_of_stock()
One frustration that programmers who are new to Python often have is
the lack of a flexible switch / case statement. The general style for
approximating this type of functionality is to have a deep nesting of
multiple if , elif , and else blocks.
def pick_fruit():
...
bottles = []
fresh_fruit = pick_fruit()
while fresh_fruit:
for fruit, count in fresh_fruit.items():
batch = make_juice(fruit, count)
[Link](batch)
fresh_fruit = pick_fruit()
A strategy for improving code reuse in this situation is to use the loop-
and-a-half idiom. This eliminates the redundant lines, but it also
undermines the while loop’s contribution by making it a dumb infinite
loop. Now, all of the flow control of the loop depends on the conditional
break statement:
bottles = []
while True: # Loop
fresh_fruit = pick_fruit()
if not fresh_fruit: # And a half
break
for fruit, count in fresh_fruit.items():
batch = make_juice(fruit, count)
[Link](batch)
The walrus operator obviates the need for the loop-and-a-half idiom by
allowing the fresh_fruit variable to be reassigned and then
conditionally evaluated each time through the while loop. This solution
is short and easy to read, and it should be the preferred approach in your
code:
bottles = []
while fresh_fruit := pick_fruit(): # Changed
for fruit, count in fresh_fruit.items():
batch = make_juice(fruit, count)
[Link](batch)
There are many other situations where assignment expressions can be
used to eliminate redundancy (see Item 42: “Reduce Repetition in
Comprehensions with Assignment Expressions” for an example). In
general, when you find yourself repeating the same expression or
assignment multiple times within a grouping of lines, it’s time to consider
using assignment expressions in order to improve readability.
Things to Remember
For example, say that I’m writing a vehicle assistant program that reacts
to a traffic light’s color. Here, I use a simple Python if statement for this
purpose:
def take_action(light):
if light == "red":
print("Stop")
elif light == "yellow":
print("Slow down")
elif light == "green":
print("Go!")
else:
raise RuntimeError
take_action("red")
take_action("yellow")
take_action("green")
>>>
Stop
Slow down
Go!
To use the match statement, I can create case clauses corresponding to
each of the if , elif , and else conditions:
def take_match_action(light):
match light:
case "red":
print("Stop")
case "yellow":
print("Slow down")
case "green":
print("Go!")
case _:
raise RuntimeError
def take_constant_action(light):
match light:
case RED: # Changed
print("Stop")
case YELLOW: # Changed
print("Slow down")
case GREEN: # Changed
print("Go!")
case _:
raise RuntimeError
>>>
Traceback ...
SyntaxError: name capture 'RED' makes remaining patterns
unreachable
Unfortunately, this code has an error, and a cryptic one at that. The issue
is that the match statement assumes that simple variable names that
come after the case keyword are capture patterns. To demonstrate what
this means, here I shorten the match statement to only have a single
branch that should match RED :
def take_truncated_action(light):
match light:
case RED:
print("Stop")
Now, I call the function by passing GREEN . I expect the match light
clause is evaluated first, and the light variable lookup in the current
scope resolves to "green" . Next, I expect the case RED clause is
evaluated, and the RED variable lookup resolves to "red" . These two
values don’t match (i.e., "green" vs. "red" ), thus I expect no output:
take_truncated_action(GREEN)
>>>
Stop
Surprisingly, the match statement executed the RED branch. Here, I use
print to figure out what’s happening:
def take_debug_action(light):
match light:
case RED:
print(f"{RED=}, {light=}")
take_debug_action(GREEN)
>>>
RED='green', light='green'
The case clause didn’t look up the value of RED —instead, it assigned
RED to the value of the light variable! What the match statement is
doing is similar to the behavior of unpacking (see Item 5: “Prefer
Multiple Assignment Unpacking Over Indexing”). Instead of case RED
translating to light == RED , Python determines if the multiple
assignment (RED,) = (light,) would execute without an error, similar
to this:
def take_unpacking_action(light):
try:
(RED,) = (light,)
except TypeError:
# Did not match
...
else:
# Matched
print(f"{RED=}, {light=}")
def take_enum_action(light):
match light:
case [Link]: # Changed
print("Stop")
case [Link]: # Changed
print("Slow down")
case [Link]: # Changed
print("Go!")
case _:
raise RuntimeError
Although this code now works as expected, it’s hard to see the benefits of
the match version over the simpler if version in the take_action
function above. The if version is 9 lines versus 10 lines with match .
The if version repeats the light == prefix for each branch, but the
match version repeats the ColorEnum. prefix for the constants.
Superficially, it seems like a wash. Why did Python add match
statements to the language if they’re not a compelling feature?
For example, say that I want to search a binary tree and determine if it
contains a given value. I can represent the binary tree as a three-item
tuple , where the first index is the value, the second index is the left
(lower value) child, and the third index is the right (higher value) child.
None in the second or third positions indicates the absence of a child
node. In the case of a leaf node, I can just put the value inline instead of
another nested tuple . Here, I define a nested tree this way containing
five values (7, 9, 10, 11, 13):
This function works as expected when the node values are comparable:
assert contains(my_tree, 9)
assert not contains(my_tree, 14)
In this function, the way that match works is each of the case clauses
tries to extract the contents of the tree argument using the given
destructuring pattern. After Python determines that the structure
matches, it evaluates any subsequent if clauses, which work similarly
to if clauses in comprehensions. When the if clause, sometimes
called a guard expression, evaluates to True , then the indented
statements for that case block will be executed and the rest will be
skipped. If no case clauses match the input value, then the match
statement will do nothing and fall through.
This code also uses the | pipe operator to add an or pattern to the final
case branch. This allows the case clause to match either of the given
patterns: (pivot, _, _) or pivot . As you might recall from the traffic
light example above that tried to reference the RED constant, the second
pattern ( pivot ) is a capture pattern that will match any value. Thus,
when tree is not a tuple with the right structure, the code assumes it’s
a leaf value that should be tested for equality.
Now imagine that my requirements change yet again, and I want to use a
class instead of a tuple to represent the nodes in my binary tree (see Item
29: “Compose Classes Instead of Deeply Nesting Dictionaries, Lists, and
Tuples” for how to make that choice). Here, I define a new class for
nodes:
class Node:
def __init__(self, value, left=None, right=None):
[Link] = value
[Link] = left
[Link] = right
I can create another instance of the tree using this class. Again, I specify
leaf nodes simply by providing their value instead of wrapping them in
an additional Node object:
obj_tree = Node(
value=10,
left=Node(value=7, right=9),
right=Node(value=13, left=11),
)
Modifying the if statement version of the contains function to handle
the Node class is straightforward:
The resulting code is similarly complex to the earlier version that used
three-tuples. In some ways the class makes the function better (e.g.,
accessing object attributes instead of unpacking), and in other ways
makes it worse (e.g., repetitive tree. prefixes).
I can also adapt the match version of the contains function to use the
Node class:
match also excels when the structure of data and its interpretation are
decoupled. For example, a deserialized JSON object is merely a nesting of
dictionaries, lists, strings, and numbers (see Item 54: “Consider
Composing Functionality with Mix-in Classes” for an example). It lacks
the clear encapsulation of responsibilities provided by an explicit class
hierarchy (see Item 53: “Initialize Parent Classes with super ”). But the
way in which these basic JSON types are nested—the keys, values, and
elements that are present at each level—gives the data semantic
meaning that programs can interpret.
For example, imagine that I’m building billing software, and I need to
deserialize customer records that are stored as JSON. Some of the
records are for customers who are individuals, and other records are for
customers that are businesses:
I’d like to take these records and turn them into well-defined Python
objects that I can use with my program’s data processing features, UI
widgets, etc (see Item 51: “Prefer dataclasses For Defining Light-
Weight Classes” for background):
@dataclass
class PersonCustomer:
first_name: str
last_name: str
@dataclass
class BusinessCustomer:
company_name: str
I can use the match statement to interpret the structure and values
within the JSON data and map it to the concrete PersonCustomer and
BusinessCustomer classes. This uses the match statements unique
syntax for destructuring dictionary literals with capture patterns:
import json
def deserialize(data):
record = [Link](data)
match record:
case {"customer": {"last": last_name, "first":
first_name}}:
return PersonCustomer(first_name, last_name)
case {"customer": {"entity": company_name}}:
return BusinessCustomer(company_name)
case _:
raise ValueError("Unknown record type")
print("Record1:", deserialize(record1))
print("Record2:", deserialize(record2))
>>>
Record1: PersonCustomer(first_name='Bob', last_name='Ross')
Record2: BusinessCustomer(company_name="Steve's Painting Co."
These examples are merely a small taste of what’s possible with match
statements. There’s also support for set patterns, as patterns, positional
constructor patterns (with __match_args__ customization),
exhaustiveness checking with type annotations (see Item 124: “Consider
Static Analysis via typing to Obviate Bugs”), and more. Given the
intricacies, it’s best to refer to the official tutorial
([Link] to determine how to leverage match
for your specific use case.
Things to Remember
• Although match statements can be used to replace simple if
statements, doing so is error prone. The structural nature of capture
patterns in case clauses is unintuitive for Python programmers who
aren’t already familiar with the gotchas of match .
• case patterns can be used effectively with built-in data structures (lists,
tuples, dictionaries) and user-defined classes, but each type has unique
semantics that aren’t immediately obvious.