Python, from the ground up Lesson 22 / 60

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. This lesson is the version I’ve ended up with after years of pull-request comments — the rules I actually keep, the ones I let slide, and a note at the end about which of these your AI assistant will help you with.

Automate the bits you don’t have to argue about

Before discussing taste, install the tools that remove half the discussion:

pip install ruff mypy

In 2026, ruff is what you reach for. It’s a linter, an import sorter, and a formatter, all in one fast Rust binary. It replaces about six older tools (flake8, isort, pyupgrade, black, pydocstyle) and is configured from a single [tool.ruff] block in pyproject.toml. Wire it into a pre-commit hook, set the rules your team agrees on, and stop arguing.

[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
ignore = ["E501"]  # line length is the formatter's job

mypy (or pyright) does the type-checking. Ninety percent of style debates evaporate the day you adopt these. Spend that energy somewhere it actually matters.

What PEP 8 gets right, and what to ignore

The parts worth following without thinking:

  • snake_case for variables and functions, PascalCase for classes, UPPER_SNAKE_CASE for constants, leading underscore for “internal”.
  • Two blank lines between top-level definitions, one between methods.
  • Imports at the top, grouped: standard library, third party, local — the formatter handles this.

The parts I disregard:

  • Line length 79. Set it to 88 (ruff’s default) or 100. Modern monitors are wide; readability suffers more from awkward line breaks than from lines that reach 95 characters.
  • Trailing commas. Use them on every multi-line collection. The formatter will keep them, the diffs will be smaller, and PEP 8 doesn’t object.
  • Compound statements on one line. PEP 8 says don’t, but if cond: return None is fine. Pick one and be consistent.

The two naming rules I’d add on top of PEP 8:

  • No single-letter variables outside i in a tight loop or x, y for coordinates. c for “customer” looks innocent until six months later you’re reading c.c wondering what the second c was.
  • Functions are verbs, variables are nouns. parse_invoice not invoice_parser. customer not get_customer (unless get_customer is the function that returns it).

The Python that feels like Python

A few rules that, once they’re muscle memory, make code review trivial:

# f-strings for everything. No %, no .format(), no concatenation.
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]

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

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

# zip with strict=True (3.10+) to catch length mismatches.
for old, new in zip(old_rows, new_rows, strict=True):
    diff(old, new)

The “no string concatenation” rule deserves emphasis. Every time you write "Hello " + name + "!" or "path/" + filename, you’re choosing a slower, harder-to-read version of an f-string or Path join. Make the f-string reflexive.

Type hint everything public

Add type hints to every public function signature: anything imported from another module, anything in a library API, anything that crosses a module boundary. Inside private helpers, type-hint when the type isn’t obvious.

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]

def find_customer(customer_id: int) -> Customer | None:
    ...

Use built-in generics (list[X], dict[K, V]) since 3.9 and the X | None union syntax since 3.10. You don’t need from typing import List, Optional anymore. Don’t annotate inside function bodies unless the type is genuinely ambiguous — count = 0 does not need count: int = 0. The annotation is noise; the inference is fine.

The pay-off isn’t mypy strictness. It’s that your editor knows what your code does, AI assistants give better suggestions, and refactoring becomes safe.

Functions, classes, and Python’s love of free functions

Java taught us to wrap everything in a class. Python doesn’t agree. If a function doesn’t carry state, it doesn’t need to be a method:

# Don't do this
class InvoiceParser:
    def __init__(self):
        pass

    def parse(self, raw: str) -> Invoice:
        ...

# Do this
def parse_invoice(raw: str) -> Invoice:
    ...

Reach for a class when you have state (multiple methods that share data), multiple implementations (a Protocol with two backends), or resource lifecycle (a context manager). Otherwise, a free function in a module is the most Pythonic answer. Modules are Python’s namespaces; you don’t need a class to organise functions.

When you do write classes, prefer @dataclass for the ones that mostly carry data:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Invoice:
    id: int
    amount: float
    customer: str

frozen=True gives you immutability, slots=True gives you smaller memory and faster attribute access. I default to both unless I need mutability.

Early returns, short functions, and mutable defaults

Three patterns that show up in every code review I’ve ever done:

Early return beats nested if. The “arrow” shape means you missed an opportunity to flatten:

# Arrow code
def process(order):
    if order is not None:
        if order.is_valid:
            if order.amount > 0:
                charge(order)

# Flat
def process(order: Order | None) -> None:
    if order is None:
        return
    if not order.is_valid:
        return
    if order.amount <= 0:
        return
    charge(order)

Short functions are easier to read, test, and name — but don’t fetishise the line count. A 60-line function with a clear linear flow beats six 10-line functions with a tangled call graph. The real rule is “one job per function”, not “one screen per function”.

Never use a mutable default argument. This bites every Python developer once:

# Wrong — the list is shared across calls.
def add(item, items=[]):
    items.append(item)
    return items

# Right — sentinel value.
def add(item: str, items: list[str] | None = None) -> list[str]:
    if items is None:
        items = []
    items.append(item)
    return items

ruff’s B006 rule catches this for you. Turn it on.

Imports, comments, and the rest

Imports: sorted, grouped, no wildcards. Ruff’s I rules handle the sort. Wildcard imports (from module import *) destroy your ability to grep for usage; ban them.

Comments explain why, not what. The code already says what. If a comment paraphrases the next line, delete it. The good comments are about non-obvious decisions: “Caching this because the upstream API rate-limits us at 10/s” or “Using a list rather than a set because order matters here”.

And tests are documentation. A clear test_parse_invoice_with_missing_total is more useful than a paragraph of prose. Aim for test names that read like English sentences.

Modern features worth using by default

A few features that have shipped recently that I now reach for without thinking:

  • @dataclass for any “this is mostly fields” class. Pair with frozen=True, slots=True when you can.
  • match/case when you’re destructuring shapes (a dict from JSON, an enum, a tagged union). For plain if x == 1: ... elif x == 2:, plain if/elif is still clearer.
  • The walrus (:=) when it actually saves a duplicate computation. Use it sparingly; it’s powerful and ugly.
  • pathlib.Path instead of os.path for everything file-related.

What AI assistants follow, and what they miss

Worth being explicit about, since AI assistants now write a chunk of every Python file. Things they get right almost without prompting:

  • PEP 8 surface details — naming, indentation, blank lines.
  • Type hints on function signatures, when you ask for them.
  • f-strings, comprehensions, enumerate over range(len()).
  • Modern syntax like list[int] and X | None, as long as your existing code already uses it. They mirror your style.

Things they consistently miss unless you prompt for them:

  • Early returns. They love nested ifs. Tell them: “flatten with early returns.”
  • Function size. They produce 80-line functions with helper logic that should be extracted. Ask for “smaller functions, one job each.”
  • The mutable default trap. They occasionally produce def f(items=[]). Catch it in review.
  • zip(..., strict=True). Almost never produced unprompted.
  • Project conventions. They don’t know your team’s rules unless you tell them. A short STYLE.md pinned in context fixes most of this.

The takeaway: AI assistants are a competent junior who knows PEP 8 cold and forgets your house rules every morning. Bring the rules to them.

The rules I keep, and the ones I let slide

I keep, without negotiation:

  1. Formatter and linter on a pre-commit hook.
  2. Type hints on every public signature.
  3. f-strings, pathlib, enumerate, zip(..., strict=True).
  4. Early returns, short functions, no mutable defaults.
  5. @dataclass(frozen=True, slots=True) as the default for value types.

I let slide:

  1. Line length (88 or 100, whichever the team picked).
  2. Trailing-comma religion — the formatter handles it.
  3. PEP 8’s exact blank-line counts — close enough is fine.
  4. “Don’t use match for simple cases” pedantry — if a colleague reaches for match, I don’t fight it.

The rest is taste, and taste develops by reading other people’s code, not by memorizing PEPs. Next lesson: how to get an AI assistant to actually produce code that looks like this.

Search