Python evolves quietly. Every release ships features that make the language nicer to work with, but unless you’re reading the release notes line by line you can easily miss them. Here are ten modern Python habits that will quietly make your code cleaner, safer, and easier to maintain.
These are aimed at developers who already know the basics but haven’t necessarily kept up with everything that landed in Python 3.9 through 3.12.
1. Use type hints — even if you’re not enforcing them
Type hints aren’t just for static checkers like mypy or pyright. They double as documentation that doesn’t lie. The signature is right there next to the code, and IDEs use them for autocomplete and refactoring.
from collections.abc import Iterable
def average(values: Iterable[float]) -> float:
values = list(values)
return sum(values) / len(values) if values else 0.0
Modern Python (3.10+) lets you skip from typing import for most things — built-in generics like list[int] and dict[str, Any] work directly.
2. Prefer pathlib over os.path
os.path is a string-manipulation library wearing a path-shaped costume. pathlib gives you actual Path objects with methods that compose.
from pathlib import Path
config = Path.home() / ".config" / "myapp" / "settings.toml"
if config.exists():
text = config.read_text()
That’s three operations: build a path, check existence, read contents. Doing the same with os.path and open() is twice as much code.
3. Reach for dataclasses instead of plain classes
If you find yourself writing __init__ methods that just assign arguments to attributes, you want a dataclass:
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
is_active: bool = True
You get __init__, __repr__, and __eq__ for free. Add frozen=True to make instances immutable. Add slots=True (3.10+) to reduce memory usage.
For more complex needs (validation, JSON serialization), step up to Pydantic — but for plain data, dataclasses is the right amount of magic.
4. Use match for pattern matching
Python 3.10+ has structural pattern matching. It’s much more than a switch statement — it can destructure objects.
def describe(point):
match point:
case (0, 0):
return "origin"
case (x, 0):
return f"on the x-axis at {x}"
case (0, y):
return f"on the y-axis at {y}"
case (x, y):
return f"at ({x}, {y})"
case _:
return "not a point"
It shines when handling messy data — JSON payloads, AST nodes, command parsers. Don’t reach for it just to replace if/elif; reach for it when destructuring makes the code clearer.
5. Use comprehensions, but don’t abuse them
A comprehension is the right tool when:
- The expression is short and readable.
- You’re producing a list/dict/set from another iterable.
squares = [x * x for x in range(10) if x % 2 == 0]
emails_by_user = {user.id: user.email for user in users}
It’s the wrong tool when:
- You’re nesting three deep.
- The expression has side effects.
- You only care about the side effect, not the result (use a
forloop).
A for loop with a clear name beats a comprehension that needs three reads to understand.
6. The walrus operator (:=) for cleaner reads
Sometimes you need to compute a value, check it, and use it. The walrus operator (3.8+) lets you do that in one line:
while (line := file.readline()):
process(line)
if (match := pattern.search(text)) is not None:
print(match.group(0))
Don’t use it everywhere — readability still wins — but when the alternative is computing a value twice or splitting an obvious idiom across three lines, the walrus is your friend.
7. f-strings everywhere — including for debugging
f-strings have been around since 3.6, but Python 3.8 added the = debugging shortcut:
user = "alzy"
count = 42
print(f"{user=}, {count=}")
# user='alzy', count=42
Perfect for print-debugging. Saves typing and shows both the variable name and value.
f-strings also support format specs:
price = 1234.5678
print(f"{price:,.2f}") # '1,234.57'
8. Use enumerate and zip instead of indexing
Whenever you reach for range(len(...)), stop and ask: do I actually need the index?
# bad
for i in range(len(items)):
print(i, items[i])
# good
for i, item in enumerate(items):
print(i, item)
Iterating two lists in parallel? zip:
for name, score in zip(names, scores, strict=True):
print(f"{name}: {score}")
Note strict=True (3.10+) — it raises if the iterables are different lengths instead of silently truncating.
9. Use with for anything that needs cleanup
Files, locks, database connections, network sockets — if it has __enter__ and __exit__, wrap it in with. The cleanup is guaranteed even if an exception fires:
with open("data.csv") as f:
rows = list(csv.reader(f))
For your own resources, write a contextlib.contextmanager:
from contextlib import contextmanager
@contextmanager
def timer(label: str):
import time
start = time.perf_counter()
yield
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timer("expensive_call"):
expensive_call()
Combine multiple context managers on one line with parentheses (3.10+):
with (
open("input.txt") as src,
open("output.txt", "w") as dst,
):
dst.write(src.read())
10. Master collections and itertools
The standard library is a treasure chest. A few personal favorites:
collections.Counter— counting hashable items in one line.collections.defaultdict— auto-initializing values when a key is missing.collections.deque— O(1) appends and pops from both ends.itertools.chain— flatten one level of an iterable of iterables.itertools.groupby— group adjacent equal elements (sort first if needed).itertools.pairwise(3.10+) — iterate as(item[i], item[i+1])pairs.functools.lru_cache— memoize expensive pure functions.
from collections import Counter
words = "the quick brown fox jumps over the lazy dog the fox is quick".split()
print(Counter(words).most_common(3))
# [('the', 3), ('quick', 2), ('fox', 2)]
If you find yourself writing a small utility, search the stdlib first — odds are it already exists, well-tested and documented.
Bonus: tools worth installing
These aren’t language features, but they belong in any modern Python toolkit:
ruff— a Rust-powered linter and formatter that’s a drop-in replacement forflake8,isort, and friends. Massively faster.uv— a Rust-powered package and project manager.pip installwith rocket boosters.mypyorpyright— static type checking. Run on every PR.pytest— the testing framework you actually want.pydantic-settings— typed configuration loaded from environment variables.
Wrapping up
Modern Python rewards staying current. None of these features are “tricks” — they’re conventions that experienced Python developers reach for instinctively. Adopt them gradually, one habit at a time, and your code will quietly get cleaner without anyone calling out a dramatic refactor.
Want to go deeper? Check out:
- Python Virtual Environments: uv vs venv vs Poetry — what to use in 2026.
- A Practical Guide to Python Async/Await — the mental model that makes async click.
- Python Decorators Explained — Without the Magic — what they really are and how to write your own.
Got a favorite Python tip I missed? Drop it in the comments — I’m always looking for reasons to write less code. Happy hacking!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .