Python code guidelines I actually use

PEP 8 in practice, the idioms that make Python code feel like Python, and the recent language features worth knowing about.

Every team I’ve joined has had its own unwritten Python style. The good news is that there’s a lot of overlap, and most of the disagreements are about things that don’t matter. Here’s the version I’ve ended up with after years of pull-request comments — the rules I actually keep, the ones I argue against, and the recent language features I think are worth using.

Start with the boring stuff: tooling

Before you argue about style, automate the parts you don’t have to argue about:

pip install ruff black mypy
  • ruff — fast linter and import sorter, replaces about six older tools (flake8, isort, pyupgrade, etc.). Run it as a pre-commit hook. Configure it to enforce PEP 8 plus the rules your team agrees on, then never debate those rules again.
  • black — formatter, opinionated and uncompromising on purpose. The point isn’t that black’s choices are perfect, it’s that nobody on your team has to make those choices. (Ruff also has a formatter now, and it’s drop-in compatible with black if you’d rather use one tool for everything.)
  • mypy — static type checker. More on type hints below.

Ninety percent of style debates evaporate the day you adopt formatters. Spend that energy somewhere it actually matters.

Naming and shape

PEP 8 is mostly common sense:

# Variables and functions: snake_case
order_count = 0

def parse_invoice(raw: str) -> Invoice:
    ...

# Classes: PascalCase
class InvoiceParser:
    ...

# Constants: UPPER_SNAKE_CASE
MAX_RETRY_ATTEMPTS = 5

# "Internal" names start with an underscore
def _build_url(host: str, path: str) -> str:
    ...

The two rules I’d add:

  • No single-letter variables outside of i in a tight loop or x, y in a coordinate. c for “customer” looks innocent until six months later you’re staring at c.c wondering what the second c was.
  • Function names should be verbs, variable names should be nouns. parse_invoice not invoice_parser. customer not get_customer.

Things that feel like Python

The clearest sign someone is writing Python the way Python wants to be written:

# f-strings for everything (since 3.6). Skip .format(), skip %.
print(f"Loaded {len(rows):,} rows in {duration:.2f}s")

# Comprehensions over map/filter chains, when they fit on one line.
active_ids = [u.id for u in users if u.is_active]

# A generator expression when you don't need the whole list at once.
total = sum(order.amount for order in orders)

# Dict/set comprehensions, same idea.
by_id = {u.id: u for u in users}

# Unpacking everywhere it makes the intent clearer.
first, *middle, last = path.parts
name, _, ext = filename.rpartition(".")

# Use enumerate, not range(len(...)).
for i, row in enumerate(rows, start=1):
    print(f"{i}: {row}")

# Use zip when iterating two sequences in lockstep.
for old, new in zip(old_rows, new_rows, strict=True):
    diff(old, new)

That strict=True on zip (added in 3.10) is one of the small things that pays for itself instantly: it raises ValueError if the sequences are different lengths, instead of silently dropping the tail. I now use strict=True by default and remove it only when I have a real reason.

Type hints, and how serious to be about them

Add type hints to every function signature in code you expect to keep. Inside function bodies, only annotate when the type isn’t obvious from context. This catches a surprising number of bugs at the IDE level — mypy is a nice extra, but the autocomplete win is what makes type hints worth the keystrokes.

from collections.abc import Iterable
from pathlib import Path

def load_invoices(paths: Iterable[Path]) -> list[Invoice]:
    return [parse_invoice(p.read_text()) for p in paths]

Two notes on modern syntax:

  • list[Invoice] instead of List[Invoice] — built-in generics have worked since Python 3.9. You don’t need to import from typing for the basic containers anymore.
  • X | None instead of Optional[X] — the union syntax (PEP 604) has worked since 3.10. Less import noise, easier to read.
def find_customer(customer_id: int) -> Customer | None:
    ...

I do not annotate variables inside function bodies unless the type is genuinely ambiguous. count = 0 doesn’t need count: int = 0. The annotation is noise; the inference is fine.

Use pathlib, not os.path

If you’re still doing os.path.join(base, "data", "file.csv"), switch.

from pathlib import Path

base = Path("/var/data")
target = base / "exports" / "2026-04" / "invoices.csv"

target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(rendered_csv, encoding="utf-8")

for csv_file in base.rglob("*.csv"):
    process(csv_file)

Path knows how to join, glob, read, write, check existence, and resolve relative paths, all on one object. The day I switched my muscle memory from os.path to pathlib was the day my file-handling code stopped looking like a museum.

The walrus, the match, and the rest of the recent stuff

A few features that have shipped in the last several releases that I find genuinely useful:

Assignment expressions (:=, the “walrus operator”), 3.8. Use it sparingly — it’s powerful and ugly. The one place it’s clearly worth it is when you’d otherwise compute the same expression twice:

# Without
data = fetch()
if data:
    process(data)

# With — fine, slightly more compact
if data := fetch():
    process(data)

# Where it really shines: avoid double work in a comprehension
results = [
    parsed
    for line in lines
    if (parsed := parse(line)) is not None
]

Structural pattern matching (match/case), 3.10. Don’t use it as a fancy if/elif. Use it when you’re actually destructuring a shape:

def describe(message: dict) -> str:
    match message:
        case {"type": "order", "amount": amount} if amount > 1000:
            return f"big order: {amount}"
        case {"type": "order", "amount": amount}:
            return f"order: {amount}"
        case {"type": "refund", "reason": reason}:
            return f"refund: {reason}"
        case _:
            return "unknown message"

That’s where match earns its keep. For a chain of if x == 1: ... elif x == 2:, plain if/elif is still clearer.

Exception groups and except*, 3.11. When you run several things concurrently, you sometimes want to catch several errors at once instead of just the first one. ExceptionGroup and except* give you that. I don’t reach for them often, but when I do they’re the right tool.

tomllib in the standard library, 3.11. You can finally read TOML config files without a third-party dependency:

import tomllib
from pathlib import Path

with Path("pyproject.toml").open("rb") as f:
    config = tomllib.load(f)

(There’s no tomllib.dump() — the standard library only reads TOML, doesn’t write it. For writing you still need tomli-w or tomlkit. This trips people up.)

The “latest updates” note

The release I’d actually pay attention to right now is Python 3.13 (October 2024), which finally landed two long-running experiments:

  • An experimental free-threaded build (no-GIL) — the global interpreter lock can be disabled at build time. It’s a separate python3.13t interpreter, not the default, and most C extensions need to opt in. I wouldn’t deploy production code on it yet, but it’s the most interesting Python release in a decade for anyone who’s ever lost a weekend to GIL workarounds.
  • A new interactive REPL with multi-line editing, syntax highlighting, and proper history. Borrowed from PyPy. It’s small but it’s the kind of small that makes you happier every single day.

There’s also a steady drumbeat of typing improvements (PEP 695 syntax for generic types in 3.12, ReadOnly for TypedDict in 3.13, TypeIs for cleaner narrowing) — worth skimming the “What’s New” page when each release ships, even if you don’t adopt the new toys immediately.

The shortest possible summary

If I had to write the rules on a postcard:

  1. Let the formatter make the formatting choices.
  2. Add type hints to function signatures.
  3. Use pathlib, f-strings, comprehensions, and enumerate.
  4. zip(..., strict=True) by default.
  5. Don’t reach for clever syntax until plain syntax actually fails you.

That’s it. The rest is taste, and taste develops by reading other people’s code, not by memorizing PEPs.