Python · Python 3.8 and 3.9

Python 3.8-3.9: Walrus, Positional-Only Parameters and Dict Merging

Python's yearly release cycle, then the syntax and string tools that 3.8 and 3.9 added - and the precedence, scope and character-set traps that come with them.

  • 6 min read
  • 11 practice questions
On this page
  1. Description
  2. Python's release cycle
  3. Small additions you'll use every day
  4. The walrus operator (3.8)
  5. Positional-only parameters (3.8)
  6. Which tool for which job?
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

Python gets a new release every year, and each one adds a few things that make everyday code shorter or safer. This skill covers the first two releases of "modern Python" that most codebases now assume - 3.8 and 3.9 - and the release cycle that decides which Python you can use at all.

Here's the kind of bug these releases fix. A URL cleaner strips the scheme:

host = url.lstrip("https://")

It works for https://example.com and every other test URL - and turns https://shop.example.com into op.example.com. lstrip doesn't remove a prefix; it removes any leading characters in the set {h, t, p, s, :, /}, and s and h are the first letters of shop. Python 3.9 added str.removeprefix for exactly this (it returns shop.example.com).

We'll go from simple to more technical:

  1. The release cycle: one release a year, five years of support, no LTS.
  2. Small additions you'll use daily: f"{x=}", dict merging with |, removeprefix.
  3. The walrus operator := and its two traps.
  4. Positional-only parameters /, and why library authors use them.

Python's release cycle

Python ships a new 3.x release every October (PEP 602), and each gets about five years of support - roughly two of bug fixes, then security fixes only. There are no long-term-support releases as in Java: 3.9 reached end of life in October 2025 and 3.10 does in October 2026, and most teams run one of the last three or four. So "what's new in modern Python?" means 3.8 onwards, and interviews expect you to know which release brought what:

ReleaseHighlights (this module and the next two)
3.8 (2019):=, positional-only /, f"{x=}", typing.Protocol/Literal/Final/TypedDict, functools.cached_property
3.9 (2020)dict | dict, str.removeprefix, list[int] in annotations, zoneinfo, functools.cache, graphlib
3.10 (2021)match, X | Y union types, zip(strict=True), better error messages
3.11 (2022)10-60% faster CPython, exception groups and except*, tomllib, TaskGroup, Self
3.12 (2023)def f[T], type X = ..., f-strings without quote limits, @override, itertools.batched
3.13 (2024)new REPL, experimental free-threading and JIT, "dead batteries" removed
3.14 (2025)t-strings, deferred annotations, free-threading supported, except A, B without parentheses

New syntax can be dated precisely: this module checks syntax claims by parsing them with ast.parse(..., feature_version=(3, N)), which rejects most grammar a release didn't have - := fails at 3.7 and parses at 3.8, match fails at 3.9 and parses at 3.10.

Small additions you'll use every day

  • f"{expr=}" (3.8) prints the expression text and its value - f"{total=}, {rate=:.1%}" gives total=1234.5, rate=8.2%. Handy for logging and debugging; a format spec goes after the =.
  • Dict merging (3.9): defaults | overrides returns a new dict in which the right side wins:
defaults = {"region": "eu-west-1", "retries": 3}
overrides = {"region": "us-east-1", "retries": 5}
config = defaults | overrides      # {'region': 'us-east-1', 'retries': 5}
defaults |= {"timeout": 30}        # updates defaults in place

d |= other updates in place and, like dict.update, also accepts a list of key-value pairs; | accepts only another dict. Before 3.9 you wrote {**defaults, **overrides}.

  • str.removeprefix / removesuffix (3.9) remove an exact string, once, if it's there. lstrip/rstrip/strip take a set of characters: "metrics.csv".rstrip(".csv") is "metri", while removesuffix(".csv") is "metrics".

The walrus operator (3.8)

name := expression assigns and returns the value, so it fits where a statement can't:

while (chunk := stream.read(8192)):          # read, test and keep in one place
    process(chunk)

if (n := len(rows)) > 1000:
    log.warning("large batch: %d rows", n)

cleaned = [y for x in raw if (y := normalize(x)) is not None]

Two traps:

  • Precedence. := binds more loosely than comparisons, so if n := len(rows) > 1000: assigns the boolean to n - with 5 rows, n was False. Parenthesize the assignment.
  • Comprehension scope. A walrus inside a comprehension assigns to the enclosing scope: after the cleaned line above, y holds the last value computed. That's deliberate - it lets any() tell you which item matched - but surprising.

Use it where it removes a repeated call or line; don't use it to cram logic onto one line.

Positional-only parameters (3.8)

def transfer(amount, /, to, *, memo=""):
    ...

transfer(10, "ana")               # ok
transfer(10, to="ben", memo="x")  # ok
transfer(amount=10, to="ana")     # TypeError

Parameters before / can only be passed by position; parameters after * only by keyword. The last call fails with TypeError: transfer() got some positional-only arguments passed as keyword arguments: 'amount'.

Library authors use / for two reasons: they can rename the parameter later without breaking anyone (no caller can depend on its name), and a function can accept arbitrary keyword arguments that might share the parameter's name. Many builtins have always worked this way - len(obj=[]) fails with TypeError: len() takes no keyword arguments.

Which tool for which job?

You want toUse
Remove a known prefix or suffixs.removeprefix(p) / s.removesuffix(p)
Trim whitespace or a set of characterss.strip() / s.lstrip(chars)
Merge two dicts into a new onea | b (right wins)
Update a dict in placea |= b or a.update(b)
Log a variable with its namef"{x=}"
Compute, test and keep a value in one expression(name := expr)
Stop callers relying on a parameter's name/ after it
Force callers to name an argument* before it

Common mistakes

MistakeWhat happensDo this instead
url.lstrip("https://")strips letters too: op.example.comurl.removeprefix("https://")
"metrics.csv".rstrip(".csv")"metri"removesuffix(".csv")
if n := f() > 3:n is a booleanif (n := f()) > 3:
Expecting the left dict to win in a | bvalues silently replacedput the winner on the right
Calling a positional-only parameter by nameTypeErrorpass it by position
Renaming a public parameter callers pass by nametheir calls breakmake it positional-only first

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. What is n after if n := len(rows) > 1000: with 5 rows?

Show answer

False - the comparison binds tighter, so n gets the boolean. Write if (n := len(rows)) > 1000:.

2. Why does "https://shop.example.com".lstrip("https://") return "op.example.com"?

Show answer

lstrip removes any leading characters from the set {h, t, p, s, :, /}, which includes the s and h of shop. Use removeprefix("https://").

3. What does / in def transfer(amount, /, to) do?

Show answer

It makes amount positional-only: transfer(amount=10, to="a") raises TypeError, and the library can rename amount without breaking callers.

Key takeaways

  • Yearly releases, about five years of support, no LTS - know what 3.8 onwards added.
  • removeprefix/removesuffix remove a string; strip and friends remove a set of characters.
  • a | b merges dicts with the right side winning; |= updates in place.
  • := needs parentheses in comparisons and leaks out of comprehensions.
  • / makes parameters positional-only, * keyword-only.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You want toUse
Remove a known prefix or suffixremoveprefix / removesuffix (3.9)
Trim a set of charactersstrip / lstrip / rstrip
Merge dicts into a new onea | b (right wins, 3.9)
Update a dict in placea |= b (also takes key-value pairs)
Log a value with its namef"{x=}" (3.8)
Compute, test and keep a value(name := expr) (3.8)
Free a parameter's name, or allow any keyword/ after it (3.8)
Force a keyword argument* before it

Snippets worth knowing by heart

while (chunk := stream.read(8192)):
    process(chunk)

if (n := len(rows)) > 1000:
    log.warning("large batch: %d rows", n)

def call(func, /, **kwargs):
    return func(**kwargs)

config = defaults | overrides
host = url.removeprefix("https://")
log.debug(f"{total=}, {rate=:.1%}")

The traps

  • url.lstrip("https://") - strips a set of characters: op.example.com.
  • "metrics.csv".rstrip(".csv") - "metri".
  • if n := f() > 3: - n is a boolean; parenthesize.
  • A walrus in a comprehension - the name leaks into the enclosing scope.
  • a | b with the winner on the left - the right side wins.
  • Positional-only parameters passed by name - TypeError.

Staff-level points, one line each

  • A walrus binds in the nearest function or module scope; inside a comprehension, the scope around it - which is how any(... (hit := x)) reports the match.
  • Forbidden by PEP 572: rebinding the loop variable, the iterable part, class-body comprehensions, a bare y := 1 statement.
  • / also lets **kwargs take a key named like the parameter - without it, "got multiple values for argument".
  • Settings(dict) | {} returns a plain dict; |= keeps the subclass; defaultdict and OrderedDict override __or__.
  • Counter | Counter takes the maximum of each count.
  • |= accepts key-value pairs; | needs a mapping on both sides.
  • f"{x=}" uses repr (name='ana'); !s for str; a format spec wins.
  • 3.9's PEG parser (PEP 617) made match and parenthesized with possible; decorators accept any expression (PEP 614).
  • New syntax is a floor: an old Python can't parse the file, so no version check inside it helps.
  • About five years per release, no LTS; 3.10 reaches end of life in October 2026; requires-python declares support.

Say it in an interview

  • "That's lstrip with a string - it strips characters, not a prefix; removeprefix is the fix."
  • "I'd make that parameter positional-only so we can rename it later."
  • "The merge puts overrides on the right, since the right side wins."
  • "We can't use match until we drop 3.9 - the old parser can't read the file."

Practice

This skill comes with 11 multiple-choice questions that tell you which misconception a wrong answer reveals. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python 3.8-3.9: Walrus, Positional-Only Parameters and Dict Merging free

Practice Python 3.8-3.9: Walrus, Positional-Only Parameters and Dict Merging free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime