Every language has features you discover a year after you needed them, and your reaction is always the same: “wait, that’s been there this whole time?” Here’s my list for Python.
Dataclasses (3.7+)
Before I knew about dataclasses, I was writing __init__, __repr__, and __eq__ by hand for every data container. Absolute waste of time.
from dataclasses import dataclass
@dataclass
class Trade:
symbol: str
price: float
quantity: int
side: str = "buy" # default value
That’s it. You get __init__, __repr__, __eq__, and optional ordering for free. Add frozen=True if you want immutability. The class above replaces about 20 lines of boilerplate.
If you’re still writing classes with nothing but self.x = x in __init__, stop and use a dataclass. Your future self will thank you.
The walrus operator := (3.8+)
Assign a value and use it in the same expression. Sounds trivial, but it eliminates a specific kind of annoying duplication:
# Before: compute, then check
line = f.readline()
while line:
process(line)
line = f.readline()
# After: assign and check in one step
while (line := f.readline()):
process(line)
Also great in list comprehensions with expensive calls:
results = [
clean
for raw in data
if (clean := expensive_transform(raw)) is not None
]
Don’t overuse it. If the expression is already simple, := just adds noise. But when it prevents calling the same function twice, it’s perfect.
f-string debugging (3.8+)
Add = after an expression in an f-string and Python prints both the expression and its value:
x = 42
print(f"{x = }") # prints: x = 42
print(f"{x * 2 = }") # prints: x * 2 = 84
print(f"{len(data) = }") # prints: len(data) = 1500
I used to write print(f"x: {x}") everywhere. The = trick is faster, less error-prone, and self-documenting. You’ll never go back.
match statements (3.10+)
Python’s version of pattern matching. If you’ve used match in Rust or Scala, this will feel familiar (though less powerful). If you’ve been chaining if/elif blocks, this is the upgrade:
match command.split():
case ["quit"]:
exit_game()
case ["move", direction]:
player.move(direction)
case ["pick", "up", item]:
player.pick_up(item)
case _:
print("Unknown command")
The real power is structural matching — you can destructure dicts, objects, and nested structures:
match event:
case {"type": "click", "x": x, "y": y}:
handle_click(x, y)
case {"type": "scroll", "delta": d} if d > 0:
scroll_up(d)
case {"type": "scroll", "delta": d}:
scroll_down(abs(d))
Is it strictly necessary? No. if/elif still works. But match makes complex dispatch logic readable, and readability is the whole game.
Type hints that actually help (3.9+)
You don’t need from typing import List, Dict, Optional anymore. Built-in generics work directly:
# Old way
from typing import List, Dict, Optional
def process(items: List[Dict[str, Optional[int]]]) -> None: ...
# Modern way (3.9+)
def process(items: list[dict[str, int | None]]) -> None: ...
The X | Y syntax for union types (3.10+) kills Optional and Union in one stroke. Combine with mypy or pyright and you catch bugs before they hit production. Not at runtime — Python doesn’t enforce types at runtime — but in your editor and CI pipeline.
dict | dict merging (3.9+)
defaults = {"color": "blue", "size": "medium"}
overrides = {"size": "large", "shape": "circle"}
merged = defaults | overrides
# {'color': 'blue', 'size': 'large', 'shape': 'circle'}
No more {**a, **b}. The | operator is cleaner and you can use |= for in-place merge. Small thing, but it comes up constantly in configuration handling.
itertools.batched() (3.12+)
How many times have you written a loop to chunk a list into groups of N? Too many. Now it’s built in:
from itertools import batched
list(batched("ABCDEFG", 3))
# [('A', 'B', 'C'), ('D', 'E', 'F'), ('G',)]
If you’re stuck on an older Python, more-itertools has chunked() which does the same thing.
The pattern
Most of these features share a trait: they reduce boilerplate. Python has always been good at saying what you mean in fewer lines, and each release pushes that further. The cost of not keeping up isn’t that your code breaks — it’s that you write more code than you have to, and more code means more bugs.
Pick one feature from this list that you haven’t used yet, try it in your next PR, and watch how quickly it becomes a habit.