On this page
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:
- The release cycle: one release a year, five years of support, no LTS.
- Small additions you'll use daily:
f"{x=}", dict merging with|,removeprefix. - The walrus operator
:=and its two traps. - 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:
| Release | Highlights (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%}"givestotal=1234.5, rate=8.2%. Handy for logging and debugging; a format spec goes after the=.- Dict merging (3.9):
defaults | overridesreturns 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/striptake a set of characters:"metrics.csv".rstrip(".csv")is"metri", whileremovesuffix(".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, soif n := len(rows) > 1000:assigns the boolean ton- with 5 rows,nwasFalse. Parenthesize the assignment. - Comprehension scope. A walrus inside a comprehension assigns to the enclosing scope: after the
cleanedline above,yholds the last value computed. That's deliberate - it letsany()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 to | Use |
|---|---|
| Remove a known prefix or suffix | s.removeprefix(p) / s.removesuffix(p) |
| Trim whitespace or a set of characters | s.strip() / s.lstrip(chars) |
| Merge two dicts into a new one | a | b (right wins) |
| Update a dict in place | a |= b or a.update(b) |
| Log a variable with its name | f"{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
| Mistake | What happens | Do this instead |
|---|---|---|
url.lstrip("https://") | strips letters too: op.example.com | url.removeprefix("https://") |
"metrics.csv".rstrip(".csv") | "metri" | removesuffix(".csv") |
if n := f() > 3: | n is a boolean | if (n := f()) > 3: |
Expecting the left dict to win in a | b | values silently replaced | put the winner on the right |
| Calling a positional-only parameter by name | TypeError | pass it by position |
| Renaming a public parameter callers pass by name | their calls break | make 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/removesuffixremove a string;stripand friends remove a set of characters.a | bmerges dicts with the right side winning;|=updates in place.:=needs parentheses in comparisons and leaks out of comprehensions./makes parameters positional-only,*keyword-only.