All 24 modules are open from the start — nothing here is
locked, and nothing costs anything. Sign in so your progress, titles and
credentials stay with you, on every device you use.
Free forever, with your Google account. No password, no payment.
Python of five years ago and Python today are the same language with a
noticeably different idiom. This module covers the additions worth adopting: an
assignment that can live inside a condition, a match statement that
takes data apart while it chooses, and a handful of small things that each remove
a specific bug you have probably already written.
Ready?
1
:= Assigns Inside an Expression
The walrus operator gives a value a name while using it. The
classic case is a loop that has to read something before it can test it:
# before
chunk = stream.read(1024)
while chunk:
process(chunk)
chunk = stream.read(1024) # written twice
# after
while chunk := stream.read(1024):
process(chunk)
That duplicated read is exactly the thing while True and a
break was working around in module 2-03. The walrus removes
it without either.
The other place it earns its keep is a comprehension that would otherwise
compute something twice — once to filter on, once to keep:
# calls parse() twice per item
[parse(row) for row in rows if parse(row)]
# once
[parsed for row in rows if (parsed := parse(row))]
Note the brackets round the walrus in that condition. They are required
in most positions, and where they are optional they usually help anyway.
= and :=
= is a statement and cannot appear in a condition — which
is why if x = 5: is a SyntaxError rather than
a bug. := is deliberately a different symbol so that
protection survives.
Quick check
What does the walrus remove from a read-then-test loop?
2
match Takes the Data Apart While It Chooses
match is not a switch statement. A case matches
a shape, and binds names out of it in the same step:
match command.split():
case ["deploy", environment]:
return f"deploying to {environment}"
case ["scale", environment, count] if count.isdigit():
return f"scaling {environment} to {count}"
case ["status"]:
return "all good"
case _:
return "unknown command"
Each pattern describes a list of a particular length, and the bare names
inside — environment, count — are
captures, not comparisons. They take whatever was in
that position.
The if on a case is a guard: the shape has
to match and the guard has to hold. And case _: is
the catch-all, which is worth writing even when you think the cases are
exhaustive.
Dictionaries work the same way, which is what makes this useful on JSON:
match payload:
case {"type": "order", "quantity": int(quantity)} if quantity > 0:
...
case {"type": "order"}:
... # an order, but not a usable one
int(quantity) there is a class pattern: it
matches only when the value is an int, and captures it. A
mapping pattern also ignores extra keys, so a payload with six fields
still matches a pattern naming two.
Quick check
In case ["deploy", environment]:, what is environment?
3
Small Additions That Each Kill a Bug
removeprefix / removesuffix
Removes a prefix if it is there. lstrip removes any of those characters, repeatedly, which is almost never what anyone wanted.
zip(a, b, strict=True)
Raises when the two are different lengths. Without it, zip silently stops at the shorter one and half your data disappears.
defaults | overrides
Merges two dictionaries into a new one, right-hand side winning. The copy-and-update from module 2-05, in one operator.
@functools.cache
Remembers what a function returned for a given set of arguments. One line, on any pure function.
The lstrip one is worth seeing rather than being told:
lstrip was given a set of characters and kept
eating from the left while it recognised them — so it took the
s off "server" as well. That bug is silent, plausible, and
very hard to spot in a log full of filenames.
And f-strings gained a debugging form: f"{total=}" prints
total=3750, name and value together. It is the fastest
print-debugging in the language and worth the muscle memory.
Quick check
Why does zip(names, scores) deserve strict=True?
4
Knowing When Not To
Every feature here can be overused, and two of them especially.
The walrus is at its best in a loop condition or a
comprehension filter. Buried in the middle of a long expression it hides
an assignment somewhere a reader is not looking for one — and the four-line
version was never a problem.
match is for shapes. Matching three literal
strings is an if/elif chain that has been made
longer and less familiar. The moment you are unpacking as you branch —
commands, JSON payloads, tokens — it starts paying for itself.
And there is a version floor. match needs Python 3.10 or
later, zip(strict=True) the same, removeprefix
3.9, the walrus 3.8. In a script you run yourself that is nothing; in a
library other people install, it is a decision.
The test for any new syntax
Does it say the same thing in fewer moving parts, or does it say the
same thing in fewer characters? The first is worth adopting everywhere.
The second is how a codebase becomes a dialect that only its author
reads fluently.
The small ones — removeprefix, strict, the merge operator — are pure
gain. The big ones are worth the judgement.
Quick check
When is match not an improvement on if/elif?
0 of 9 completed
Real Python runs right here in your browser — nothing to install, nothing
sent to a server. The interpreter downloads once the first time you press
Run, then stays cached.
01
To do
A loop that has to read something before it can test it traditionally
writes the read twice — once before the loop, once at the bottom. Two
copies of one line, which can drift apart.
while chunk := stream.read(3): print(chunk)
:= gives the value a name while the condition uses it. An
empty string is falsy, so the loop ends when the reads run out.
Your task: rewrite the loop below with a walrus, so the
read appears once. The output must not change:
abc def g
your_code.py
PythonCtrl↵ to run
Hint
while (chunk := data[position:position + 3]): with the print and the position increment inside. The two separate assignment lines both go away.
Output
02
To do
A comprehension that filters on a computed value normally computes it
twice — once for the condition, once to keep. On anything expensive that
doubles the work.
[parsed for row in rows if (parsed := parse(row))]
The brackets round the walrus are required here, and they help anyway.
Your task: the filter below calls parse
twice per row. Rewrite it with a walrus so each row is parsed once. The
calls list records every call, so the count proves it:
[12, 7] 4
Four rows, four calls — not eight.
your_code.py
PythonCtrl↵ to run
Hint
found = [value for row in rows if (value := parse(row))] — the name captured in the condition is what the expression on the left then keeps.
Output
03
To do
match is not a switch statement. A case matches
a shape and binds names out of it in the same step.
match command.split(): case ["deploy", environment]: return f"deploying to {environment}" case _: return "unknown"
A bare name inside a pattern is a capture, not a
comparison — it takes whatever was in that position. The pattern above
matches a two-item list whose first item is exactly
"deploy".
Your task: write route(command) handling
four shapes:
deploying to staging all good scaling eu to 3 unknown command: rm -rf /
deploy <env>, status on its own,
scale <env> <count>, and anything else echoing
the original command.
your_code.py
PythonCtrl↵ to run
Hint
case ["deploy", environment], case ["status"], case ["scale", environment, count], and case _ for the rest. The last one needs the original command, not the split version — use the parameter.
Output
04
To do
A case can carry an if, called a
guard: the shape has to match and the guard has
to hold. If the guard fails, matching carries on with the next case.
That ordering matters. A specific case with a guard belongs above the
general one it falls through to — the same first-match rule as an
elif chain from module 2-01.
Your task: write scale(command) accepting
scale <env> <count> only when the count is all
digits, and refusing it otherwise:
scaling eu to 3 count must be a number: lots unknown
your_code.py
PythonCtrl↵ to run
Hint
case ["scale", environment, count] if count.isdigit(): first. Then the same pattern without the guard, returning the complaint. Then case _ returning "unknown".
Output
05
To do
Mapping patterns are what make match genuinely useful on
JSON. A pattern names the keys it cares about and ignores the rest, so a
payload with six fields still matches a pattern naming two.
case {"type": "order", "quantity": int(quantity)} if quantity > 0:
int(quantity) is a class pattern: it
matches only when the value really is an int, and captures
it. So a quantity that arrived as text falls through to the next case
rather than being used.
Your task: write handle(payload) with four
cases:
order for 3 bad order: -1 bad order: 3 ignored: ping
A valid order, a negative quantity, a quantity that is text, and a
payload of some other type.
your_code.py
PythonCtrl↵ to run
Hint
First: {"type": "order", "quantity": int(quantity)} with a guard quantity > 0. Second: {"type": "order", "quantity": quantity} catching everything else that is an order. Third: {"type": kind} capturing the type for the ignored message. Note the first payload has an extra key and must still match.
Output
06
To do
zip stops at the shorter of its inputs. When the two are
supposed to be the same length, that turns a data problem into a smaller
report that looks perfectly fine.
zip(names, scores, strict=True) # raises when they disagree
Your task: run it and notice the report is missing two
rows with no complaint. Then add strict=True and handle the
ValueError:
kenji 88 ada 71 mismatched columns: zip() argument 2 is shorter than argument 1
Print the pairs first, then the complaint — so the failure is visible rather than silent.
your_code.py
PythonCtrl↵ to run
Hint
Wrap the loop in a try, add strict=True to the zip, and print f"mismatched columns: {error}" in an except ValueError as error. The pairs that did match are printed before the error arrives, because zip is lazy.
Output
07
To do
lstrip takes a set of characters and keeps
eating from the left while it recognises them. Everybody reads it as
"remove this prefix", and it is not:
The s of "server" was in the set, so it went too. The bug is
silent, plausible, and hard to spot in a list of filenames.
removeprefix and removesuffix do what people
meant, and leave the string alone when the affix is not there.
Your task: fix the cleaning so each name loses exactly
the prefix and the extension:
['server', 'stats', 'access'] staging_other.txt
The second line is a name that has neither affix, which must come back
untouched.
your_code.py
PythonCtrl↵ to run
Hint
Swap lstrip for removeprefix and rstrip for removesuffix, keeping the same arguments. Both leave the string alone when the affix is not present, which is what the second line demonstrates.
Output
08
To do
| merges two dictionaries into a new one,
with the right-hand side winning. It is the copy-and-update from module
2-05, without either step being possible to forget.
settings = DEFAULTS | user_config # a new dict; DEFAULTS untouched settings |= extra # in place, on settings
The two-line version had a real hazard: calling update
without copying first quietly edits the defaults for the rest of the
program. The operator cannot do that.
Your task: merge the three layers with |,
then apply one more with |=, and prove
DEFAULTS survived:
settings = DEFAULTS | file_config | cli_config — left to right, each layer beating the one before. Then settings |= {"timeout": 5}.
Output
09
To do
@functools.cache remembers what a function returned for a
given set of arguments, and hands the same answer back without running
the body again.
It works only on a pure function — same input, same
output, nothing else touched — for the reason that should now be
familiar: caching something that depends on the clock, a file or a global
gives you a stale answer forever.
Your task: cache the lookup, so repeated calls do the
work once. The calls list records every real execution:
1250 1250 990 ['tea', 'whisk'] 2
Four calls, two of them repeats, so only two reach the body.
your_code.py
PythonCtrl↵ to run
Hint
One line: @functools.cache immediately above def price_of. Nothing else changes.
Output
The command router
To do
A deployment tool reads commands from an operator and settings from three
layers of configuration. Write the router — using the syntax from this
module where it genuinely says the thing, and not where it does not.
Write resolve(file_config, cli_config): merges
DEFAULTS, then the file, then the command line, using the merge
operator. DEFAULTS must survive untouched.
Write route(command, settings): a
match over command.split(), handling:
deploy <env> — deploying to <env> on port <port>, using the resolved port
scale <env> <n> where n is all digits — scaling <env> to <n>
scale <env> <n> otherwise — count must be a number: <n>
status — ok, debug=<True/False>
anything else — unknown command: <the original command>
Write clean(names): strips the
staging_ prefix and the .log suffix from each name,
leaving names without them alone.
Then run it. Route every command in commands
with a walrus in the loop condition so each result is computed once. Pair the
cleaned names with their sizes using strict=True and report the
mismatch rather than losing rows.
Print exactly seven lines:
deploying to staging on port 9000 scaling eu to 3 count must be a number: lots ok, debug=True unknown command: rm -rf / ['server', 'stats', 'access'] mismatched columns: zip() argument 2 is shorter than argument 1
your_code.py
PythonCtrl↵ to run
Hint
resolve is one line: DEFAULTS | file_config | cli_config. route matches ["deploy", environment], then the guarded ["scale", environment, count] if count.isdigit(), then the unguarded scale case, then ["status"], then case _ echoing the original command. clean is a comprehension with removeprefix and removesuffix. For the loop, iterate the commands with a walrus in the condition — for example `while (index := ...)` is awkward here, so use a for loop whose body assigns with := inside the print, or simply capture the result once with a walrus in an if. The zip needs strict=True inside a try, printing f"mismatched columns: {error}".