Python Best Practices in 2026: 15 Rules for Clean Python
Modern Python best practices — PEP 8 with Ruff, uv environments, type hints, pathlib, f-strings, dataclasses, logging and pytest — each with why it matters and a code example.
Python’s tooling matured fast in the last two years: Ruff replaced the old lint/format stack, uv made environments and dependency management instant, and type hints plus mypy/pyright are now standard on serious codebases. Here are 15 modern Python best practices for 2026, each with the reasoning and a code example. Target Python 3.11+ as a safe modern baseline.
1. Follow PEP 8 and enforce it with Ruff
Consistent style removes bikeshedding and makes diffs reviewable. Ruff (Rust-based, from Astral) has replaced Black + Flake8 + isort + pyupgrade + pylint in most 2026 projects, running 10–100x faster with autofix. Run ruff format and ruff check as separate commands — and don’t also run Black, which would fight it.
# pyproject.toml
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"] # pycodestyle, pyflakes, isort, pyupgrade, bugbear, simplify
# then:
# ruff format . formats (Black-compatible)
# ruff check --fix . lints + autofixes
2. Use virtual environments and pin dependencies
Isolated environments prevent cross-project version clashes, and a lockfile makes builds reproducible. In 2026 uv is the fastest path — it manages the interpreter, venv, resolution and a uv.lock. Commit the lockfile; never commit .venv/.
# modern (uv)
uv init && uv add requests
uv sync # installs from uv.lock, reproducible
# classic, still valid
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
3. Add type hints and check them with mypy or pyright
Static types catch whole classes of bugs before runtime and document intent — mypy --strict is the baseline used by FastAPI, Pydantic and SQLAlchemy themselves. Use modern syntax (list[int], str | None), accept broad abstract types for parameters, and return concrete types.
def total(prices: list[float], tax: float = 0.0) -> float:
return sum(prices) * (1 + tax)
# prefer str | None over Optional[str] (3.10+)
# prefer list[int] over List[int]
4. Use f-strings over % and .format()
F-strings are faster, more readable and less error-prone, and the = specifier is ideal for debugging. Never f-string untrusted input into SQL or shell commands, though — use parameterized queries, since f-strings do not escape.
name, n = "Ada", 42
# ❌ "Hello %s, you have %d" % (name, n)
# ✅
print(f"Hello {name}, you have {n} messages")
print(f"{n=}") # -> n=42 (debug form)
print(f"{n:>6,.2f}") # format spec still works
5. Use pathlib instead of os.path
Paths as objects give cleaner, cross-platform code and eliminate fragile string joins. Most of the standard library now accepts Path directly; pass str(p) for the few legacy APIs that still want strings.
from pathlib import Path
# ❌ os.path.join(os.path.dirname(__file__), "data", "in.csv")
# ✅
p = Path(__file__).parent / "data" / "in.csv"
if p.exists():
text = p.read_text(encoding="utf-8")
6. Manage resources with context managers (with)
with guarantees cleanup (files, locks, connections) even on exceptions, preventing leaks. Always pass encoding= to open() — the platform default is not guaranteed to be UTF-8 and causes portability bugs.
with open("out.txt", "w", encoding="utf-8") as f:
f.write(data)
# multiple resources (3.10+ parenthesized form)
with (open("a") as a, open("b") as b):
...
7. Use comprehensions — but keep them readable
Comprehensions are faster and clearer than manual append loops for building collections. But if one spans multiple conditions or loops, or needs a comment to understand, write an explicit loop instead.
# ✅
squares = [x * x for x in range(10)]
by_id = {u.id: u for u in users}
# ❌ unreadable nested/filtered comprehension -> use a loop
8. Prefer built-ins: enumerate, zip, and the right data structure
enumerate and zip are clearer and faster than manual index bookkeeping, and choosing a set/dict gives O(1) membership instead of a list’s O(n). Pass strict=True to zip (3.10+) to catch length mismatches — it silently stops at the shortest iterable otherwise.
# ❌ for i in range(len(items)): x = items[i]
# ✅
for i, x in enumerate(items, start=1):
...
for name, score in zip(names, scores, strict=True):
...
seen = set(ids) # O(1) membership
9. Prefer EAFP over LBYL
"Easier to Ask Forgiveness than Permission" is idiomatic Python, avoids race conditions (state can change between a check and its use), and is often faster. Keep the try body to the single operation that can fail so you don’t swallow unrelated errors.
# ❌ LBYL (racy)
if key in cache: return cache[key]
# ✅ EAFP
try:
return cache[key]
except KeyError:
return compute(key)
10. Never use mutable default arguments
Default values are evaluated once at definition time, so a mutable default is shared across all calls — a classic silent bug. Use None and create the mutable inside the function. Ruff’s bugbear rule B006 flags this automatically.
# ❌
def add(item, bucket=[]):
bucket.append(item); return bucket
# ✅
def add(item, bucket: list | None = None):
bucket = [] if bucket is None else bucket
bucket.append(item); return bucket
11. Model structured data with dataclasses (or Pydantic at boundaries)
Dataclasses remove __init__/__repr__/__eq__ boilerplate with zero dependencies; use Pydantic when data crosses a trust boundary (API bodies, config) for validation. Mutable field defaults must use field(default_factory=...) — a bare [] raises ValueError. Use frozen=True + slots=True for immutable, memory-efficient value objects.
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
tags: list[str] = field(default_factory=list) # not []
12. Use logging instead of print in application code
Logging gives levels, timestamps, structured output and routing to files/collectors without code changes; print gives none of that. Pass args to the logger (logger.info("x %s", v)) rather than pre-formatting an f-string, so the message is only built if that level is enabled. logger.exception includes the traceback and only works inside an except block.
import logging
logger = logging.getLogger(__name__)
def process(order_id: int) -> None:
logger.info("processing order %s", order_id) # lazy %-args
try:
...
except PaymentError:
logger.exception("payment failed for %s", order_id)
13. Catch specific exceptions — never a bare except
A bare except: hides bugs, swallows KeyboardInterrupt/SystemExit and makes failures undebuggable. Catch the specific exception you expect; if you must catch broadly, use except Exception and re-raise or log the traceback. Use raise ... from exc to preserve context.
# ❌
try: data = json.loads(raw)
except: data = {}
# ✅
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning("bad payload: %s", exc)
data = {}
14. Use the if __name__ == "__main__" guard
The guard lets a file run as a script yet be imported (or spawned by multiprocessing) without executing side effects. On Windows and macOS, multiprocessing uses spawn and re-imports the module — without this guard you get infinite process spawning.
def main() -> int:
...
return 0
if __name__ == "__main__":
raise SystemExit(main())
15. Write tests with pytest, and stream large data with generators
pytest’s plain assert, fixtures and parametrization make tests concise — they are your safety net for refactoring. And generators (yield / generator expressions) stream data lazily, keeping memory flat over huge sequences. A generator is single-use, so wrap in list() only if you truly need to iterate twice.
import pytest
@pytest.mark.parametrize("a,b,exp", [(1, 2, 3), (0, 0, 0)])
def test_add(a, b, exp):
assert add(a, b) == exp
# process a huge file without loading it
def read_rows(path):
with open(path, encoding="utf-8") as f:
for line in f: # lazy, one line at a time
yield line.rstrip()
Python best practices checklist
- Format + lint with Ruff; work inside a venv (uv) with a committed lockfile.
- Add type hints and check with mypy/pyright (strict); use
list[int]/str | None. - Prefer f-strings,
pathlib,with(always passencoding=), and readable comprehensions. - Use
enumerate/zip(strict=True)and the right data structure; prefer EAFP. - Never use mutable default arguments; model data with dataclasses / Pydantic.
- Use
logging(lazy %-args) notprint; catch specific exceptions. - Guard scripts with
if __name__ == "__main__"; test with pytest; stream big data with generators.
Frequently asked questions
What is the best Python formatter and linter in 2026?
ruff format and ruff check --fix, configured in a single pyproject.toml.Should I use type hints in Python?
list[int] and str | None.Why is a mutable default argument dangerous?
def f(x, items=[]) shares one list across every call — appends accumulate across calls. Use items=None and create the list inside the function. Ruff’s B006 rule flags this automatically.Is uv replacing pip and venv?
python -m venv + pip install -r requirements.txt still works fine; uv is the faster, reproducible modern default.Run Python instantly — no local install or venv needed — in the XCODX online compiler. Paste a script, try type hints, dataclasses or a generator, and see it execute in the browser. Deciding between languages? See Python vs other backends.