Blog

Blog · Best Practices

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.

XCODX Team · 7 min read

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 pass encoding=), 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) not print; 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. It replaced the older Black + Flake8 + isort + pylint stack in most projects — it lints and formats, runs 10–100x faster, and autofixes many issues. Run ruff format and ruff check --fix, configured in a single pyproject.toml.
Should I use type hints in Python?
Yes, for anything beyond a small script. Type hints catch bugs before runtime, document intent and power your IDE. Check them with mypy or pyright (strict mode is the production baseline) and use modern syntax like list[int] and str | None.
Why is a mutable default argument dangerous?
Default values are evaluated once when the function is defined, so a 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?
For many teams, yes. uv manages the interpreter, virtual environment, dependency resolution and a lockfile far faster than pip + venv. Classic 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.