◀ Course contents Part 4 · Module 4-04

Modern Python

The features that changed how the language reads

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:

"staging_server.log".lstrip("staging_")       # "erver.log"
"staging_server.log".removeprefix("staging_") # "server.log"

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
Python
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
    Python
    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
      Python
      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
        Python
        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
          Python
          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
            Python
            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:

              "staging_server.log".lstrip("staging_")       # "erver.log"
              "staging_server.log".removeprefix("staging_") # "server.log"

              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
              Python
              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:

                {'port': 9000, 'debug': True, 'timeout': 30, 'region': 'eu'}
                {'port': 9000, 'debug': True, 'timeout': 5, 'region': 'eu'}
                {'port': 8080, 'debug': False, 'timeout': 30}
                your_code.py
                Python
                Hint

                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
                  Python
                  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>
                    • statusok, 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
                    Python
                    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}".

                    Output
                    
                          

                      Notification