Positional-only parameters with / (3.8)
Before 3.8, Python had no way to say "this parameter cannot be passed as a keyword." Every parameter was callable either way, which meant callers could write f(x=1) instead of f(1) and you could not stop them. That made parameter names part of your public API whether you intended it or not.
1# Python 3.8+: parameters before / are positional-only2def circle_area(radius, /, precision=2):3 import math4 return round(math.pi * radius ** 2, precision)56# OK: positional7print(circle_area(5)) # 78.5489# OK: precision as keyword10print(circle_area(5, precision=4)) # 78.53981112# Error: radius as keyword13circle_area(radius=5) # TypeError: positional-only
The * separator forces everything after it to be keyword-only. You can use both in one signature:
1def transfer(from_account, to_account, /, *, amount, currency="USD"):2 # from_account and to_account: positional-only (avoid ambiguity with 'from' keyword)3 # amount and currency: keyword-only (must be explicit for clarity)4 print(f"Transfer {amount} {currency} from {from_account} to {to_account}")56transfer("ACC-001", "ACC-002", amount=500)7# transfer(from_account="ACC-001", to_account="ACC-002", amount=500) # TypeError8# transfer("ACC-001", "ACC-002", 500) # TypeError: amount is keyword-only
The walrus operator := (3.8)
:= assigns and returns a value in a single expression. It removes the pattern of assigning a value, checking it on the next line, then using it again.
1import re23# Before: assign, check, use -- three separate lines4line = "Error: disk full at /var/log"5match = re.search(r"Error: (.+)", line)6if match:7 print(match.group(1))89# After: assign and check in one expression10if match := re.search(r"Error: (.+)", line):11 print(match.group(1))
While loops that process chunks
1import io23data = b"hello world this is a stream of bytes for testing purposes"4stream = io.BytesIO(data)56# Before: sentinel pattern, duplicated read call7chunk = stream.read(8)8while chunk:9 print(chunk)10 chunk = stream.read(8)1112print("---")13stream.seek(0)1415# After: walrus keeps the read in the condition16while chunk := stream.read(8):17 print(chunk)
List comprehensions with expensive calls
1# Before: call process() twice -- once to filter, once to use2def process(n: int) -> int | None:3 return n * 2 if n % 3 == 0 else None45results = [process(n) for n in range(10) if process(n) is not None]6print(results)78# After: call once, keep the result9results = [y for n in range(10) if (y := process(n)) is not None]10print(results)
@functools.cache and @lru_cache (3.9)
Memoisation before 3.9 meant writing your own dict cache, or using @lru_cache(maxsize=None) which required the awkward maxsize=None argument to disable the size limit. Python 3.9 added @cache as a clean alias for the unbounded case.
1import functools2import time34# Manual cache: the old way5_fib_cache: dict[int, int] = {}67def fib_manual(n: int) -> int:8 if n in _fib_cache:9 return _fib_cache[n]10 if n < 2:11 return n12 result = fib_manual(n - 1) + fib_manual(n - 2)13 _fib_cache[n] = result14 return result1516# 3.9: @cache -- same semantics, no boilerplate17@functools.cache18def fib(n: int) -> int:19 if n < 2:20 return n21 return fib(n - 1) + fib(n - 2)2223print(fib(50))24print(fib.cache_info())
@lru_cache with a size limit
1from functools import lru_cache23# Keep only the 128 most recent results4@lru_cache(maxsize=128)5def fetch_user_from_db(user_id: int) -> dict:6 # Simulating a DB call7 print(f" [DB] fetching user {user_id}")8 return {"id": user_id, "name": f"User{user_id}"}910print(fetch_user_from_db(1))11print(fetch_user_from_db(2))12print(fetch_user_from_db(1)) # cache hit, no DB call13print(fetch_user_from_db.cache_info())
Rule: use @cache for pure functions with no side effects and unbounded inputs (maths, parsing). Use @lru_cache(maxsize=N) when you care about memory: a large key space would grow without bound otherwise. Arguments must be hashable for either decorator to work.
@singledispatch: type-based function routing
When a function needs to behave differently for different types, the naive solution is a chain of isinstance checks. It works, but adding new types means editing the original function.@singledispatch lets you register handlers separately and extend without modifying the base.
1# Before: isinstance ladder that grows with every new type2def serialize(obj):3 if isinstance(obj, int):4 return str(obj)5 elif isinstance(obj, float):6 return f"{obj:.6g}"7 elif isinstance(obj, list):8 return "[" + ", ".join(serialize(x) for x in obj) + "]"9 elif isinstance(obj, dict):10 pairs = ", ".join(f"{k}: {serialize(v)}" for k, v in obj.items())11 return "{" + pairs + "}"12 else:13 raise TypeError(f"Cannot serialize {type(obj)}")
1from functools import singledispatch23@singledispatch4def serialize(obj):5 raise TypeError(f"Cannot serialize {type(obj)}")67@serialize.register(int)8def _(obj: int) -> str:9 return str(obj)1011@serialize.register(float)12def _(obj: float) -> str:13 return f"{obj:.6g}"1415@serialize.register(list)16def _(obj: list) -> str:17 return "[" + ", ".join(serialize(x) for x in obj) + "]"1819@serialize.register(dict)20def _(obj: dict) -> str:21 pairs = ", ".join(f"{k}: {serialize(v)}" for k, v in obj.items())22 return "{" + pairs + "}"2324print(serialize(42))25print(serialize(3.14159))26print(serialize([1, 2.5, 3]))27print(serialize({"a": 1, "b": [2, 3]}))
The real benefit: if a library defines serialize, you can add support for your own types without touching its source. Just import the function and register your handler.