Python, End to End
0%

Foundations  ·  beginner to advanced

Python,
end to end.

Python is easy to start and full of details that only surface under load: names that are not variables, defaults evaluated once, a lock that serialises your threads, and an object model that costs bytes you did not budget for. This page walks the language from first principles to production — every mechanism drawn, every topic split into what a beginner, an intermediate and an advanced engineer each need to know about it.

28 bytesOne Python integerEvery value is a heap object with a header. A million of them is not eight megabytes.
1Threads running bytecodeThe GIL lets exactly one thread interpret at a time. It is released on I/O, which is why the distinction matters more than the lock.
0Runtime effect of a type hintAnnotations are stored and ignored. They are a promise to a checker, not a guarantee to the interpreter.
Part one

The object model

Python has no variables in the C sense. It has names bound to objects, and almost every beginner surprise — the function that mutated your list, the default argument that remembered, the is that worked until it did not — follows from that one fact. Learn the binding model first and the rest stops being folklore.

Python · 01

Names, objects and references

Assignment never copies. It points a name at an object that already exists. Two names can reach the same object, and only one of the two operations you can perform — rebinding a name, mutating an object — is visible to the other name.

A · TWO NAMES, ONE OBJECT a b [1, 2, 3] id 0x7fa1c40 b = a binds, never copies a is b → True B · REBIND: a = [9] a [9] new object b [1, 2, 3] b still sees [1, 2, 3] a is b → False C · MUTATE: a.append(9) a b [1, 2, 3, 9] id 0x7fa1c40 b sees the 9 as well same id, same object Identity is not equality, and interning makes the difference invisible == asks about value and calls __eq__. is compares id() and asks about identity. CPython interns the integers -5 to 256 and short identifier-like strings, so a is b is True for 256, False for 257. That is a memory optimisation, not a language rule. Use is only for None, True, False and your own sentinels.
Rebinding moves a name; mutation changes an object. Panel B and panel C differ by one character of syntax and completely in effect. Every “why did my caller’s list change” question is panel C, and every “why did my caller’s list not change” question is panel B.
a = [1, 2, 3]
b = a                           # one object, two names
b.append(4)
print(a)                        # [1, 2, 3, 4] -- a sees it
print(a is b, id(a) == id(b))   # True True

def rebind(lst):
    lst.append(99)              # mutates the caller's object
    lst = [0]                   # rebinds the LOCAL name only
    return lst

x = [1]
rebind(x)
print(x)                        # [1, 99] -- append survived, rebind did not
L1 · Beginner

Bindings, not boxes

  • Assignment binds a name — x = obj makes x refer to obj. Nothing is copied and no storage is reserved for x.
  • == vs is — equal value versus the same object. Use == unless you specifically mean identity.
  • Everything is an object: integers, functions, modules, classes and exceptions all have a type, attributes and an id().
  • Because functions are objects, you can put one in a list, pass it as an argument, or return it — no special syntax required.
  • type(x) tells you what it is; id(x) tells you which one it is.
L2 · Intermediate

What a call actually passes

  • Argument passing is name binding — the parameter becomes another name for the caller’s object. Not by value, not by reference in the Pascal sense.
  • So a function can mutate your list, dict or set, and cannot rebind your name. lst.append(x) escapes; lst = [] does not.
  • del x removes the name, not the object. Any other name still bound to it keeps it alive.
  • Rebinding in a loop, a comprehension or an except ... as e block follows the same rule — and e is deleted at the end of the except block.
  • Take a copy at the boundary if the caller must not see your edits; say so in the docstring either way.
L3 · Advanced

Lifetime, and why is lies

  • Reference counting frees an object the moment its last reference goes; a cycle detector handles the rest. What keeps an object alive is a reference, from anywhere — a list, a frame, a closure cell, a traceback.
  • id() is reused. It is the address in CPython, so a freed object’s id can be handed straight to the next one. Never store an id as a key.
  • is on ints and strings is a bug that passes its own test. Interning is an implementation detail that varies between literal, computed, and interactive contexts.
  • A traceback holds a reference to every frame, and every frame holds its locals — this is how exception handling keeps large objects alive longer than expected.
  • sys.getrefcount, gc.get_referrers and weakref are the tools when something will not die.
Python · 02

Mutability, copying, and the default-argument trap

A default value is evaluated once, when the def statement runs — not once per call. If that value is mutable, every call shares it, and the function quietly accumulates state across the life of the process.

A · ONE LIST, MADE AT def TIME def add(item, bucket=[]): one list object made once, here add("a") add("b") add("c") ["a"] ["a", "b"] ["a", "b", "c"] call 1 call 2 call 3 The same object grows across calls, for the life of the process. B · SHALLOW vs DEEP COPY orig copy.copy(orig) copy.deepcopy(orig) obj A obj B the original obj A obj B shares the same inners obj A2 obj B2 new inners as well A shallow copy duplicates the outer container only, so s[0].append(9) is visible through orig as well. deepcopy walks the whole graph — correct, and costly in time and memory. list(x), x.copy(), dict(x) and x[:] are all shallow. Mutable means unhashable, and that is the same fact from the other side A key’s hash must not change while it sits in a dict, so list, dict and set set __hash__ to None and raise TypeError. A tuple is hashable only if everything inside it is — (1, [2]) raises. Freeze with tuple and frozenset before you key on it.
The trap is not the list — it is the timing. def is an executable statement: it evaluates the defaults and builds a function object once. 0, None and "" are safe defaults because you cannot mutate them; anything you can call a method on to change is a shared cache you did not ask for.
def add(item, bucket=[]):        # bucket is created ONCE, at def time
    bucket.append(item)
    return bucket

add("a")            # ["a"]
add("b")            # ["a", "b"]  <- the same list, still there
add.__defaults__    # (["a", "b"],)  the evidence, on the function object

def add(item, bucket=None):      # the None-sentinel fix
    if bucket is None:
        bucket = []              # a fresh list per call
    bucket.append(item)
    return bucket
L1 · Beginner

Know which builtins bite

  • Mutable: list, dict, set, bytearray, and instances of most classes you write.
  • Immutable: int, float, str, bytes, tuple, frozenset, bool, None.
  • The default-argument trap — never write def f(x=[]) or def f(x={}). Use None and build inside the body.
  • list.copy() / dict.copy() hand out an independent top level; x[:] does the same for sequences.
  • A linter catches this one — ruff rule B006 exists precisely because it is so common.
L2 · Intermediate

Copy depth and hashability

  • Shallow vs deep — copy.copy duplicates one level; copy.deepcopy duplicates the whole reachable graph and keeps a memo so shared nodes stay shared and cycles terminate.
  • deepcopy costs — it is recursive, pickle-adjacent, and easily the slowest line in a hot loop. Prefer restructuring so you do not need it.
  • Mutable objects are unhashable because a dict places a key by its hash and would lose it if the hash moved.
  • A tuple containing a list is immutable at the top and still unhashable — hash((1, [2])) raises TypeError.
  • Dataclasses: field(default_factory=list) is the same fix, spelled for a class.
L3 · Advanced

Contracts and boundaries

  • The __hash__/__eq__ contract: equal objects must hash equal, and the hash must be stable for the object’s lifetime. Defining __eq__ alone sets __hash__ to None.
  • Freeze deliberately — frozenset, tuple, @dataclass(frozen=True), or a NamedTuple, when a value must be a key or crosses a thread boundary.
  • Defensive copying at API edges: copy on the way in if you will store it, on the way out if the caller might mutate it. Document the choice; do not make callers guess.
  • Copy-on-write is a myth here. Python has none — not for lists, not across fork() (refcount updates dirty the pages anyway).
  • __copy__ and __deepcopy__ let a type control its own duplication; use them for handles that must not be cloned.
Python · 03

The data model: protocols, not inheritance

Syntax is a call. len(x) is x.__len__(), x[k] is x.__getitem__(k), with x is x.__enter__(). Nothing checks a base class — the interpreter looks for the method and calls it.

SYNTAX IS A PROTOCOL — THE INTERPRETER CALLS A METHOD ON YOUR OBJECT len(b) → __len__ b[k] → __getitem__ for x in b → __iter__ class Basket: one ordinary object with b → __enter__ b + c → __add__ repr(b) → __repr__ no base class, no registration — the method just has to exist Duck typing is Go’s implicit interfaces, moved from compile time to call time In Go, a type satisfies io.Writer by having Write; it never names the interface. Python is the same idea without the check. The cost of moving the check to runtime is that a missing method is an AttributeError in production, not a compile error. Which is why typing.Protocol exists: state the shape, let a type checker do statically what Go’s compiler does.
This is the same design as Go’s implicit interfaces. Neither language asks a type to declare what it implements; both ask only whether the methods are there. Go checks at compile time and Python at the moment of the call — so in Python you write the protocol down with typing.Protocol or an ABC and let a type checker close the gap.
class Money:
    __slots__ = ("amount", "currency")

    def __init__(self, amount, currency):
        self.amount, self.currency = amount, currency

    def __repr__(self):          # what logs, reprs and tracebacks show
        return f"Money({self.amount!r}, {self.currency!r})"

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented    # let the other type try
        return (self.amount, self.currency) == (other.amount, other.currency)

    def __hash__(self):          # define WITH __eq__ or the type is unhashable
        return hash((self.amount, self.currency))
# the container and context-manager protocols -- no base class, just the methods
class Basket:
    def __init__(self):
        self._items: list[str] = []

    def __len__(self) -> int:                 # bool(basket), len(basket)
        return len(self._items)

    def __getitem__(self, i: int) -> str:      # basket[0], and makes it iterable for free
        return self._items[i]

    def __iadd__(self, item: str) -> "Basket": # basket += "eggs"
        self._items.append(item)
        return self

class transaction:                             # with transaction(conn): ...
    def __init__(self, conn):
        self.conn = conn

    def __enter__(self):
        self.conn.execute("BEGIN")
        return self.conn

    def __exit__(self, exc_type, exc, tb) -> bool:
        self.conn.execute("ROLLBACK" if exc_type else "COMMIT")
        return False                           # False: never swallow the exception
L1 · Beginner

The dunders you write first

  • __init__ initialises an object that already exists; __new__ is what actually creates it, and you almost never touch it.
  • __repr__ is for you — unambiguous, ideally something you could paste back in. __str__ is for the user.
  • Logs and tracebacks use __repr__, and lists of objects print their repr. A default <Order object at 0x7f...> is a wasted debugging session.
  • If you define only one, define __repr__ — str() falls back to it.
  • Dunder names are called by the interpreter. You define them; you rarely call them.
L2 · Intermediate

Implementing the protocols

  • __eq__ and __hash__ travel together; return NotImplemented (not False) for a type you do not understand, so the other operand gets its turn.
  • Iteration: __iter__ returns an iterator, whose __next__ raises StopIteration when done. A generator function gives you both for free.
  • __getitem__ alone makes an object indexable and, as a legacy fallback, iterable — which is why some old classes work in for without __iter__.
  • Operator overloading is for types where the operator already means something — vectors, money, paths. Not for cleverness.
  • functools.total_ordering fills in the other comparisons from __eq__ plus one of __lt__.
L3 · Advanced

Attribute machinery

  • Descriptors are the mechanism under @property, methods, classmethod and __slots__: an object with __get__/__set__ stored on the class.
  • __slots__ removes the per-instance __dict__ — less memory, faster attribute access, no ad-hoc attributes. Worth it for millions of small objects.
  • __getattr__ vs __getattribute__: the first runs only after normal lookup fails, the second intercepts every access and is easy to make infinitely recursive.
  • Metaclasses exist; __init_subclass__ and __set_name__ cover most of what people reach for them for. Learn them, deploy them rarely.
  • Say the protocol out loud. An ABC or a typing.Protocol turns “must have read and close” from a comment into something a checker enforces.
Part two

Functions and flow

Python’s function machinery is unusually rich: five kinds of parameter, laziness as a first-class control-flow tool, and closures that make decorators an ordinary consequence rather than a feature. All three shape how your API reads at the call site — and two of them have a trap that only shows up under load.

Python · 04

Arguments: positional, keyword, and the star operators

A signature has five zones, separated by / and by *. Where you put a parameter decides how callers may pass it — and therefore what you are free to change later without breaking them.

ONE SIGNATURE, FIVE ZONES def f(a, /, b, c=0, *args, d, e=1, **kw): a positional-only / b, c=0 pos or keyword *args extra positional d, e=1 keyword-only **kw extra keyword Everything after *args is keyword-only. Callers cannot pass it positionally, so you may reorder or add without breaking a single call site. 1 2, 3 4, 5 d=9 x="k" ← one call, mapped onto the zones Positionals fill left to right until they run out; everything named is matched by name; the leftovers go into *args and **kw. Parameter order is API surface, and positional order is the part you cannot change Once a parameter can be passed positionally, its position is a promise. Swapping two of them is a silent, type-correct break. A bare * in the signature costs nothing and buys you the freedom to reorder and extend the options forever. The / does the opposite for the first argument: it keeps the name yours, so renaming it breaks nobody.
Two boring characters, two real design decisions. Put * before every optional flag and the signature becomes extensible; put / after the obvious first argument and its name stops being part of your contract. Both changes are free at the call site and expensive to retrofit later.
def connect(host, /, port=5432, *, timeout=5.0, sslmode="require"):
    ...

connect("db1")                       # host is positional-only
connect("db1", 6432)                 # port may be positional
connect("db1", timeout=1.0)          # timeout MUST be by keyword
connect(host="db1")                  # TypeError: host is positional-only

opts = {"timeout": 1.0, "sslmode": "disable"}
connect("db1", **opts)               # unpack a dict into keyword arguments

args = ("db1", 6432)
connect(*args, timeout=1.0)          # unpack an iterable into positionals
L1 · Beginner

Passing and collecting

  • Defaults make a parameter optional; keyword arguments let the caller name it and skip ordering entirely.
  • *args collects the leftover positionals into a tuple; **kwargs collects the leftover named ones into a dict.
  • Unpacking at the call site: f(*seq) spreads an iterable into positions, f(**mapping) spreads a dict into names.
  • The names args and kwargs are convention only — the stars do the work.
  • Keyword arguments at a call site are documentation: retry(3, True) tells a reader nothing, retry(3, jitter=True) tells them everything.
L2 · Intermediate

Designing the signature

  • Keyword-only parameters (after a bare *) can be reordered, renamed in position, and added to without breaking callers. Use them for every flag and option.
  • Positional-only (before /) frees the name: useful for a first argument like self-ish subjects, and for wrappers that also take **kwargs and must avoid name clashes.
  • Defaults are evaluated once, at def time — the same fact as the mutable-default trap above, and it applies to datetime.now() defaults too.
  • Booleans are almost always keyword-only. send(msg, True, False) is unreadable by construction.
  • *-unpacking works in literals as well: [*a, *b], {**d1, **d2}.
L3 · Advanced

Binding, introspection and restraint

  • functools.partial pre-binds arguments and returns a callable — cheaper and clearer than a lambda, and it keeps a usable repr.
  • inspect.signature reads the real parameter list; sig.bind(*a, **kw) resolves a call the way the interpreter would, which is how frameworks do dependency injection.
  • Accept **kwargs rarely. It defeats type checkers, autocomplete and inspect, turns typos into silent no-ops, and pushes errors down into whatever you forward to.
  • Argument order is API surface and shows up in every caller’s source. Treat a reorder like a rename: it needs a deprecation, not a patch release.
  • typing.ParamSpec and Concatenate exist so a decorator can keep the wrapped signature visible to the checker.
Python · 05

Comprehensions, generators and laziness

A comprehension builds the whole result before anything downstream runs. A generator computes one element per request and forgets it. Same answer, same code shape, and a memory profile that differs by the size of your input.

A · [int(l) for l in f] — MATERIALISE, THEN USE events.log 1M lines [ ... ] 1M objects all resident peak ~ 40 MB sum(...) The list is complete before sum() sees element 0. In exchange it is reusable: len(), indexing, and as many passes as you like. B · (int(l) for l in f) — ONE AT A TIME events.log 1M lines ( ... ) 1 object in flight peak ~ 200 B sum(...) The consumer pulls: nothing runs until sum() asks. Consumed once. A second pass yields nothing at all — no error, just an empty result. Laziness moves the work; it does not remove it The same million calls happen either way. What changes is when they happen and how much is alive at once. So exceptions surface at consumption, not at construction, and a profile shows the cost inside the loop that drains it. If a generator is closed over a file handle, the handle must outlive the consumer — a classic “I/O on closed file” bug.
Choose by lifetime, not by taste. Need len(), indexing, or two passes? Build the list. Feeding one consumer, or streaming something bigger than memory? Use a generator — and remember it is an iterator that is now spent, which is why list(g) twice returns data and then nothing.
# Materialises every line: peak memory is the whole file.
total = sum([int(line) for line in open("events.log")])

# Streams: one line resident at a time, same answer.
with open("events.log") as f:
    total = sum(int(line) for line in f)

# A pipeline of generators -- still one element in flight.
def parse(lines):
    for line in lines:
        if line.strip():
            yield json.loads(line)

with open("events.ndjson") as f:
    errors = (e for e in parse(f) if e["level"] == "error")
    first_ten = list(itertools.islice(errors, 10))   # stops reading after 10
L1 · Beginner

Building sequences

  • Comprehensions come in three shapes: [x for ...] list, {k: v for ...} dict, {x for ...} set.
  • They replace the result = [] / append loop and are usually faster, because the append is done in C.
  • range is lazy — range(10**9) costs nothing until you iterate it. So are zip, map, filter and enumerate.
  • yield makes a generator: calling the function runs no code, it hands back an object that runs on demand.
  • One condition and one loop is a comprehension; two of either is a for loop.
L2 · Intermediate

Picking lazy or eager

  • Generator expression vs list comprehension — parentheses instead of brackets. Choose the generator when the result is consumed once, the list when you need len, indexing or a second pass.
  • Generators are single-use. Re-iterating a spent generator silently yields nothing, which looks like missing data, not like an error.
  • itertools is the standard toolkit: islice (take n), chain (concatenate), groupby (needs sorted input), batched (fixed-size chunks, 3.12+).
  • enumerate and zip replace index arithmetic; zip(strict=True) catches length mismatches instead of truncating.
  • A generator that wraps a resource should be consumed inside the with block that owns it.
L3 · Advanced

Pipelines and their cost

  • yield from delegates to a sub-generator, forwarding values, send and exceptions — and flattens recursion without a manual loop.
  • Generators were the original coroutines: send, throw and close predate async/await, which was built on exactly this machinery. Write new async code with async def.
  • A pipeline of generators keeps one item per stage alive, so memory is O(stages), not O(input) — the same shape as a Go channel pipeline, minus the concurrency.
  • __iter__ returning self is what makes an iterator work in for; a re-iterable iterable returns a fresh iterator each time. Confusing the two is why a class works once and then stops.
  • Laziness moves work rather than removing it: tracebacks point at the consumer, and per-element function-call overhead can make a generator slower than one bulk operation.
Python · 06

Scope, closures and decorators

A name is resolved local, enclosing, global, built-in — first match wins. A closure captures the enclosing variable, not its value at capture time, and a decorator is nothing more than a function that takes a function and returns one.

A · LEGB — LOOKUP ORDER Local — this function Enclosing — outer def Global — this module Built-in — len, print First match wins, then NameError. There is no block scope: a name set inside an if or for outlives it. global / nonlocal to write, not read. B · LATE BINDING fs = [lambda: i for i in range(3)] [f() for f in fs] → [2, 2, 2] not [0, 1, 2] Each lambda captured the variable i, not the value of i. It is read at call time — by then the loop left i at 2. fs = [lambda i=i: i for i in range(3)] default binds NOW → [0, 1, 2] The default-argument trap, used on purpose. C · @d means f = d(f) def f() d(f) wrapper original f before / after code lives here The name f now points at wrapper, and only the closure still holds the original — hence functools.wraps. A closure is a function plus the cells it reads — and it keeps them alive Inspect them with f.__closure__. A callback closed over a large object holds that object until the callback itself is dropped.
Late binding is not a bug, it is the definition. The closure holds a cell, and the cell holds whatever the variable holds now. That is exactly why the i=i default fixes it — the default is evaluated at definition time, so the value is frozen into the signature instead of being looked up later.
import functools, time

def timed(fn):
    @functools.wraps(fn)                 # keeps __name__, __doc__, __wrapped__
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            ms = (time.perf_counter() - start) * 1000
            log.info("%s took %.1f ms", fn.__name__, ms)
    return wrapper

@timed                                   # charge = timed(charge)
def charge(order_id):
    ...
# the late-binding trap: a closure reads the variable, not a snapshot of it
handlers = []
for route in ["orders", "users", "health"]:
    handlers.append(lambda: log.info("handling %s", route))

[h() for h in handlers]        # "handling health" three times -- route is one shared cell

# the fix: bind it as a default argument, evaluated once, right now
handlers = []
for route in ["orders", "users", "health"]:
    handlers.append(lambda route=route: log.info("handling %s", route))

[h() for h in handlers]        # "handling orders", "handling users", "handling health"
L1 · Beginner

Where names live

  • Local vs global — assigning to a name anywhere in a function makes it local for the whole function, even on lines above the assignment.
  • global and nonlocal are needed only to rebind an outer name. Reading and mutating need neither.
  • A closure keeps its enclosing variables alive after the outer function has returned — that is what makes inner functions useful.
  • @decorator is f = decorator(f), applied right after the def. Nothing more.
  • Module-level mutable state is global state; it will be shared by every import.
L2 · Intermediate

Writing decorators that behave

  • Late binding in loops — closures created in a loop all see the final value. Bind with a default (x=x) or functools.partial.
  • functools.wraps copies __name__, __doc__, __module__, annotations and sets __wrapped__. Without it, every decorated function is called “wrapper” in logs and docs.
  • A decorator with arguments is three nested functions: factory takes the arguments, returns a decorator, which returns the wrapper.
  • functools.lru_cache memoises on the arguments — which requires them to be hashable, and leaks if the key space is unbounded. cache is the unbounded form.
  • Always use try/finally in a timing or resource decorator, or exceptions skip your teardown.
L3 · Advanced

Where decorators get expensive

  • Class-based decorators (__call__) hold state cleanly, but an instance is not a descriptor — decorating a method loses the automatic self binding unless you implement __get__.
  • Order with classmethod/staticmethod matters: those must be outermost, because what they wrap has to be a plain function.
  • Stacking applies bottom-up — the decorator nearest the def wraps first and ends up innermost, which is the opposite of reading order.
  • Decorators hide signatures: a bare *args, **kwargs wrapper erases the parameter list for the type checker and the IDE. ParamSpec restores it.
  • Every decorator adds a frame to the traceback and a call to the hot path. Cheap individually, visible five deep.
Part three

Types and structure

Python will run almost anything you write. The tools in this part are how a team stops it running the wrong thing: annotations a checker reads, record types that say what a value is, and an import system whose rules decide what your process actually loaded.

Python · 07

Type hints: what they do and what they do not

An annotation is data attached to a function object. The interpreter stores it and moves on. Everything useful about hints happens in a second program — mypy or pyright — that you have to run yourself.

A · RUN TIME — THE ANNOTATION IS ONLY STORED def scale(xs: list[int], k: int) -> list[int]: return [x * k for x in xs] scale(["a", "b"], 3) # nothing checks this line → ['aaa', 'bbb'] a wrong answer, produced cheerfully The hints live in scale.__annotations__ and are read by nothing else. Delete them and the program behaves identically. B · CHECK TIME — A SECOND PROGRAM READS IT $ mypy app.py app.py:4: error: Argument 1 to "scale" has incompatible type "list[str]"; expected "list[int]" exit status 1 — the build stops same file, same line, before anyone ran it The checker is a build step, not a language feature. Hints you never check are comments that go stale in a fortnight. Two separate jobs, often confused Static checking proves things about the code you wrote — that every caller passes what the function claims to need. Runtime validation — pydantic, a schema, a hand-written check — is for data you did not write, arriving from outside.
Hints are a contract with your tools, not with the interpreter. That is not a weakness: it is what lets you add them to a million-line codebase one module at a time. But it does mean an unchecked repository full of annotations has all of the cost and none of the guarantee.
from typing import Literal, Protocol, TypedDict

class Row(TypedDict):          # the shape of JSON you do not own
    id: int
    status: Literal["open", "closed"]

class Store(Protocol):         # structural: no base class, no registration
    def get(self, key: str) -> Row | None: ...

def load(store: Store, key: str) -> Row | None:
    return store.get(key)      # any object with a matching get() type-checks
# PEP 695 (3.12+): generic syntax with no import, T scoped to the function
def first[T](xs: list[T]) -> T:
    return xs[0]

# the older spelling, still what most of the ecosystem reads and writes
from typing import TypeVar, cast
U = TypeVar("U")

def last(xs: list[U]) -> U:
    return xs[-1]

from __future__ import annotations   # hints stop being evaluated eagerly; the default on 3.14+ (PEP 649)

def handler(req: Request) -> Response: ...  # forward reference, unquoted -- lazy annotations resolve it

n = cast(int, config.get("timeout"))  # zero run-time effect -- a lie you are asserting is true
L1 · Beginner

Annotate the boundaries first

  • Parameters and returns — def get(uid: int) -> User: is where hints pay for themselves.
  • Containers — list[int], dict[str, int], tuple[str, ...]. Built-in generics since 3.9; no typing.List needed.
  • Maybe-missing — str | None is the honest type for anything that can be absent.
  • No enforcement — passing a str where int is annotated runs exactly as before. Nothing raises.
  • Your editor already uses them — completion and go-to-definition get better the moment you add them.
L2 · Intermediate

Say more than “object”

  • Optional[X] is X | None — and a default of None does not make a parameter optional in the type sense; annotate it.
  • Any is an escape hatch — it silently switches checking off for everything it touches. object is the safe “I do not know”.
  • TypedDict — types a dict you receive from JSON without turning it into a class.
  • Literal["asc", "desc"] — catches the typo in the string argument that would otherwise reach production.
  • Protocol — structural typing: anything with the right methods fits, no inheritance. Duck typing the checker can see.
  • Ramp the strictness — start with --ignore-missing-imports, add disallow_untyped_defs per package as each one is cleaned up.
L3 · Advanced

Generics, laziness and lies

  • PEP 695 — def first[T](xs: list[T]) -> T: in 3.12+; the older TypeVar spelling still fills every codebase you will read.
  • Variance in one sentence — a list[Dog] is not a list[Animal], because you could append a cat to it; immutable Sequence[Dog] is.
  • from __future__ import annotations — annotations become strings, so forward references work and import cost drops. On 3.14+ you rarely need it: PEP 649 defers annotation evaluation by default, and forward references simply work. Anything reading annotations at run time should go through annotationlib / get_type_hints.
  • typing.cast — zero run-time effect, pure assertion to the checker. A lie you own, so leave a comment saying why it is true.
  • Different job, different tool — a checker cannot know what came off a socket. Validate that with pydantic; check the rest statically.
Python · 08

Dataclasses, NamedTuple, TypedDict, Pydantic

Four ways to say “this is a user record”. They differ on exactly four things: whether it can be mutated, whether anything is checked at run time, what it costs per instance, and whether you had to install it.

ONE RECORD — id, name, tags — MODELLED FOUR WAYS @dataclass NamedTuple TypedDict BaseModel class User: class User(NamedTuple): class User(TypedDict): class User(BaseModel): Mutable Checked at run time Per-instance cost Behaves as a tuple Dependency no, never yes, and it coerces smallest: it is a tuple yes: index and unpack stdlib stdlib stdlib yes, or frozen=True yes, or frozen a dict, or slots=True a plain dict no no, it is a mapping no yes: any key, any time no no no, and not even a class largest, plus parse time pip install pydantic Where each one belongs JSON off the network untrusted, any shape User.model_validate(raw) parses, coerces, raises here @dataclass User trusted, nothing to check validate once, then trust it.
Read the grid as a cost sheet. Every column is fine somewhere: a NamedTuple for a hot inner loop that unpacks, a TypedDict for a payload you never own, a dataclass for the domain object you pass through twenty functions, pydantic exactly where the data arrives from outside.
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)
class User:
    id: int
    name: str
    tags: list[str] = field(default_factory=list)   # not tags: list = []

    def __post_init__(self) -> None:
        if self.id <= 0:                            # frozen: use object.__setattr__
            raise ValueError("id must be positive") # to normalise fields here

u = User(1, "ada")
u.name = "grace"        # FrozenInstanceError -- hashable, safe as a dict key
# NamedTuple — when tuple-ness IS the point: unpack it, hash it, index it
from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

p = Point(1.0, 2.0)
x, y = p                       # unpacks like any tuple
p[0]                           # 1.0 — still indexable
p._replace(x=9.0)              # returns a NEW Point; p itself never changes
{p: "origin-ish"}              # hashable out of the box, no frozen= needed
# TypedDict — for a JSON shape you do not own; a plain dict at run time
from typing import TypedDict

class UserPayload(TypedDict):
    id: int
    name: str
    tags: list[str]

def greet(u: UserPayload) -> str:
    return f"hello {u['name']}"    # dict access, not attribute access

raw: UserPayload = {"id": 1, "name": "ada", "tags": []}
raw["naem"] = "typo"               # mypy/pyright catch this; nothing does at run time
greet({"id": 1})                   # also only a type-checker error — TypedDict never runs
# BaseModel — the same record, but arriving from outside and needing to be believed
from pydantic import BaseModel, Field, ValidationError

class User(BaseModel):
    id: int = Field(gt=0)
    name: str
    tags: list[str] = []           # pydantic copies the default per instance — safe here

try:
    u = User.model_validate({"id": "1", "name": "ada"})  # "1" coerced to 1
except ValidationError as e:
    log.warning("bad user payload: %s", e.errors())

u.model_dump()                     # -> plain dict, JSON-safe with mode="json"

Records make tagged unions, and match (3.10+) is how you consume one — the checker narrows each arm, and assert_never turns “forgot a variant” into a type error instead of a production bug:

# dispatch on a tagged union: match narrows, assert_never proves exhaustiveness
from dataclasses import dataclass
from typing import assert_never

@dataclass
class Created:
    id: int

@dataclass
class Deleted:
    id: int
    reason: str

def apply(ev: Created | Deleted) -> str:
    match ev:
        case Created(id=i):            return f"created {i}"
        case Deleted(id=i, reason=r):  return f"deleted {i}: {r}"
        case _:                        assert_never(ev)  # unreachable -- or the union grew
L1 · Beginner

Stop passing dicts around

  • A dict has no shape — user["nmae"] is a KeyError at 2am; user.nmae is caught by the checker and your editor.
  • @dataclass — write the fields with their types and you get __init__, __repr__ and __eq__ for free.
  • Defaults work as expected — retries: int = 3, and required fields must come before defaulted ones.
  • The repr matters — a dataclass prints as User(id=1, name='ada'), which is most of a debugging session.
  • It is still a normal class — add methods, inherit, use it anywhere.
L2 · Intermediate

The four arguments that matter

  • field(default_factory=list) — the mutable-default trap again; a bare [] as a class-level default is a ValueError from dataclasses, and a shared list everywhere else.
  • frozen=True — assignment raises, and the instance becomes hashable. Good default for anything you pass between threads.
  • slots=True — no per-instance __dict__: less memory, faster attribute access, no typo-attributes.
  • __post_init__ — derived fields and cheap invariants, run after __init__ assigns.
  • NamedTuple — when tuple-ness is the point: unpacking, use as a dict key, returning from a function that used to return a plain tuple.
  • TypedDict — for a JSON shape you do not own. It is a dict at run time; the types exist only for the checker.
L3 · Advanced

Validate at the edge, trust inside

  • Pydantic belongs at the boundary — HTTP body, queue message, config file, third-party response. Parse once, into a typed object.
  • Do not validate in a loop — re-validating an already-parsed model on every internal hop buys nothing and shows up in profiles.
  • v2 does the work in Rust — much faster than v1, but still far more expensive than a dataclass constructor, which does almost nothing.
  • attrs — the library dataclasses came from; still ahead on validators, converters and __slots__ ergonomics.
  • The rule — untrusted data crosses exactly one line in your codebase. After that line it is a typed record, and no function downstream defends itself.
Python · 09

Modules, packages, imports and environments

import orders is a search, an execution and a cache, in that order. Knowing those three steps explains import side effects, why the second import is free, and why a circular import fails with an attribute error rather than a clear message.

A · WHAT import orders ACTUALLY DOES every later import: cache hit, the file does not run again import orders a name to bind sys.modules already loaded? search sys.path cwd, venv, stdlib execute the file top to bottom, once cache in sys.modules then bind the name Executing the file runs every top-level statement. That is what an import side effect is: a connection opened, a registry populated, a model loaded — all of it at start-up, all of it before your first line of work. B · A CIRCULAR IMPORT, HALFWAY THROUGH # a.py import b X = 1 (not reached yet) # b.py import a Y = a.X a is loaded but empty AttributeError: partially initialized module 'a' has no attribute 'X' Move the shared type out, or import inside the function. C · ONE PROJECT, ONE ENVIRONMENT myproj/ pyproject.toml deps, tools, build uv.lock exact resolved versions .venv/ this project only src/myproj/ __init__.py makes it a package __main__.py python -m myproj tests/ The venv is this project’s interpreter; the lockfile is the reason tomorrow resolves to the same versions.
Two practical consequences. Because a module runs exactly once per interpreter, module-level state is a singleton — convenient, and the reason import order can change behaviour. And because import executes, a heavy top-level import is start-up latency you pay on every cold start, every worker, every test run.
# what `import payments.charge` actually does, once per process
import sys
sys.path       # searched in order; "" means the current directory, first
sys.modules    # the cache — every later import is just a dict lookup

# payments/config.py — a module body runs exactly once, at first import
print("configuring")        # printed once, however many modules import it
SETTINGS = load_settings()  # module-level state is a singleton by accident

# the guard: this file can be imported as a library AND run as a script
if __name__ == "__main__":
    main()

# circular imports fail halfway through, not at the import line:
#   ImportError: cannot import name 'Order' from partially initialized
#   module 'payments.models' (most likely due to a circular import)
# the fix is a direction, not a trick — move the shared type into its own module
L1 · Beginner

One environment per project

  • Module vs package — a module is a .py file; a package is a directory of them, importable as a unit.
  • Import forms — import orders, from orders import Order, import orders as o. Avoid from x import *: the reader cannot tell where a name came from.
  • if __name__ == "__main__": — the guard that makes a file both importable and runnable, and stops multiprocessing re-running your script.
  • A virtual environment per project — python -m venv .venv, or let uv manage it. It is a directory with its own interpreter and its own packages.
  • Never install globally — two projects will eventually want two versions of the same library, and system Python belongs to the system.
L2 · Intermediate

Make the environment reproducible

  • Absolute imports — from myproj.orders import Order reads the same from anywhere. Keep relative imports (from .orders import Order) shallow if you use them at all.
  • sys.modules is the cache — re-importing gives you the same object. Monkeypatching in tests and reload tricks both hang off this.
  • Import side effects — anything at module level runs on import. Keep it to constants and definitions; put work in functions.
  • pyproject.toml — one file for dependencies, build backend, and tool config (ruff, mypy, pytest).
  • Pin and lock — loose ranges for a library you publish, an exact lockfile for an application you deploy.
  • pip vs uv — same package format; uv resolves and installs an order of magnitude faster and manages the venv and lockfile itself.
L3 · Advanced

Break the cycle, watch the clock

  • Three fixes for a circular import — move the shared type to its own module, import inside the function that needs it, or accept the dependency is backwards and invert it.
  • if TYPE_CHECKING: — imports needed only for annotations, skipped at run time. Pairs with from __future__ import annotations.
  • The src layout — putting the package under src/ means tests import the installed package, not the working copy that happens to be on sys.path.
  • Editable installs — pip install -e . puts a link on the path, so edits take effect without reinstalling.
  • Namespace packages — a directory without __init__.py can still be a package split across distributions. Usually a surprise rather than a plan.
  • Import time is start-up cost — python -X importtime -c "import myproj" prints the tree with microseconds per module. A lazily imported heavy dependency is often the whole fix.
Part four

Errors and resources

Python asks forgiveness rather than permission: you attempt the operation and handle the exception. That is idiomatic and it is race-free, but it only works if the handler catches what it can actually deal with — and if the file, socket or transaction is released whichever way control leaves the block.

Python · 10

Exceptions, EAFP, and chaining

Four clauses, four different jobs. else is the part of the body that must not be inside the try; finally is the part that runs whatever happens, including on the way out of a return.

A · WHICH CLAUSE RUNS, AND WHEN try: row = fetch(id) no exception a matching exception else: the success-only work except NotFound as e: handle, or re-raise finally: success, exception, return, break the value is returned, or the exception carries on upwards A return inside try does not skip finally: the value is computed, finally runs, then the function returns. A return inside finally replaces it — and eats a live exception. B · RE-RAISING: KEEP THE CAUSE raise ConfigError(msg) from err sets __cause__ — you meant this traceback: “the direct cause of the above” raise ConfigError(msg) sets __context__ — Python noticed for you traceback: “during handling of the above” raise ConfigError(msg) from None erases both — the original never prints the line number you needed is gone Translate errors at layer boundaries: a driver error becomes a domain error, with from err so the driver detail survives. Use from None only when the inner error is pure noise and you are certain of it.
The clause people skip is else. Code that belongs after a successful attempt should not sit inside the try, where it can raise the same exception type and be caught by a handler that was never meant for it. That is how a KeyError from your own logic gets reported as “record not found”.
import contextlib

class Transient(Exception): ...    # worth a retry: timeout, 503, connection reset
class Terminal(Exception): ...     # never retry: 400, 403, bad payload

def fetch(url: str) -> bytes:
    try:
        return http.get(url)
    except TimeoutError as err:
        raise Transient(f"timeout: {url}") from err
    except PermissionError as err:
        raise Terminal(f"forbidden: {url}") from err

with contextlib.suppress(FileNotFoundError):
    os.remove(tmp)                 # "already gone is fine" -- not a bare except
# all four clauses, each with a job try/except alone cannot do
def load_batch(path: str) -> list[Row]:
    try:
        fh = open(path)
    except FileNotFoundError:
        return []                  # handled: an empty batch is a valid outcome
    else:
        with fh:                   # only runs if open() did NOT raise; the with
            return parse(fh)       # closes fh even if parse() raises -- next topic
    finally:
        log.info("load_batch done: %s", path)    # always -- even on the return above
L1 · Beginner

Catch what you can handle

  • The hierarchy — everything derives from BaseException; the errors you mean are under Exception. ValueError, KeyError, OSError are the ones you meet daily.
  • Be specific — except ValueError:, not except Exception:. A handler that catches everything hides the bug you have not found yet.
  • Never a bare except: — it catches KeyboardInterrupt and SystemExit too, so Ctrl-C stops working.
  • else — runs only when the try raised nothing; keeps the success path out of the guarded block.
  • finally — runs on every exit path. Cleanup goes here, or better, in a with.
L2 · Intermediate

EAFP, and errors with meaning

  • EAFP beats LBYL — try: open(p) against if os.path.exists(p): the check-then-act version has a race, and does the work twice.
  • Custom exception types — one base class per package, so callers can catch your errors without catching everything.
  • raise NewError(...) from err — translation with the cause attached. The default implicit chaining is fine too; the anti-pattern is from None.
  • contextlib.suppress(FileNotFoundError) — says “I expect this and it is fine” in one line, and cannot accidentally swallow more.
  • Exceptions as control flow — StopIteration ends every for loop, KeyError drives defaultdict. Idiomatic here, unlike most languages.
  • Log with the traceback — logging.exception(...) inside the handler; a logged message without a traceback is half an incident report.
L3 · Advanced

Groups, cost, and taxonomy

  • ExceptionGroup and except* — 3.11+. A TaskGroup where three tasks fail raises all three; except* handles each type and re-raises the rest.
  • Cost on hot paths — setting up a try is nearly free; raising is not. Building a traceback in a per-row loop is measurable, so a sentinel return can win there.
  • Taxonomy drives retries — transient (timeout, 503, deadlock) retries with backoff and jitter; terminal (400, 403, schema error) must not, or you have built a retry storm. The same split as the Go and foundations pages.
  • Do not catch BaseException — that is where KeyboardInterrupt, SystemExit and asyncio.CancelledError live. Catching them breaks shutdown and cancellation.
  • Retries need idempotency — a timeout does not tell you whether the write landed. Retry only what carries an idempotency key.
Python · 11

Context managers and deterministic cleanup

with is syntax for a try/finally that someone else wrote and tested. The release runs on the exception path as well as the happy one, which is the entire reason the statement exists.

A · with, EXPANDED with open(p) as f: body(f) is exactly cm = open(p) f = cm.__enter__() try: body(f) except BaseException: if not cm.__exit__(*sys.exc_info()): raise else: cm.__exit__(None, None, None) Both paths call __exit__. Cleanup does not depend on the body finishing, on the caller remembering, or on the collector. Several managers in one with: opened left to right, closed right to left. B · A TRANSACTION, EITHER WAY __enter__ → BEGIN body → INSERT, UPDATE __exit__(None) COMMIT __exit__(exc) ROLLBACK, then re-raise Return True from __exit__ and the exception stops here: the caller sees a clean return from a failed transaction. Almost always a bug. Why open() without with seems to work CPython refcounts, so a file object with no references left is closed immediately. That is an implementation detail, not a promise. Hold one reference in a traceback, a closure or a cycle — or run on PyPy — and the descriptor stays open until the collector feels like it.
The failure mode is not a crash, it is a slow leak. Descriptors accumulate under load until an OSError: Too many open files arrives during an incident that has nothing to do with files, and a lock or transaction held the same way blocks everyone behind it in the meantime.
import contextlib, logging, time

@contextlib.contextmanager
def timed(label: str):
    start = time.perf_counter()
    try:
        yield                       # the body of the with block runs here
    finally:                        # runs on the exception path too
        ms = (time.perf_counter() - start) * 1000
        logging.info("%s took %.1f ms", label, ms)

with timed("query"), conn.transaction():    # left to right in, right to left out
    rows = conn.fetch_all(sql)
L1 · Beginner

Use with for anything you open

  • with open(path) as f: — the file closes when the block ends, however it ends.
  • Why it matters — if json.load(f) raises halfway, the file still closes; a plain f.close() at the end of the function never runs.
  • Not just files — sockets, locks, database connections and transactions, tempfile, subprocess.Popen.
  • Several at once — with open(a) as src, open(b, "w") as dst: in one statement, entered left to right.
  • The as target — is whatever __enter__ returns, which is not always the manager itself.
L2 · Intermediate

Write your own

  • The protocol — __enter__(self) returns the resource; __exit__(self, exc_type, exc, tb) releases it and returns falsy to let any exception through.
  • @contextlib.contextmanager — one function, one yield. Everything before is setup, everything after is teardown.
  • Wrap the yield in try/finally — without it, an exception in the body skips your teardown entirely. This is the bug in most hand-written generators.
  • ExitStack — for a number of resources known only at run time: stack.enter_context(open(p)) in a loop, all unwound together.
  • closing, suppress, redirect_stdout — contextlib already has the adapters you were about to write.
  • Also a decorator — a ContextDecorator can be applied to a whole function with @timed("x").
L3 · Advanced

Reentrancy, async, and suppression

  • Generator-based managers are single-use — re-entering one raises. If you need reuse, write the class, or use contextlib.nullcontext for the optional case.
  • async with — __aenter__ and __aexit__, built with @asynccontextmanager. A connection pool acquire that awaits needs this; a sync manager holding an await is how you block the loop.
  • AsyncExitStack — the same dynamic unwinding for coroutine resources.
  • Returning True suppresses — legitimate for suppress, wrong almost everywhere else: it silently converts a failure into a success.
  • Cleanup can raise — an exception in __exit__ replaces the one in flight, with the original as its context. Keep teardown boring.
  • Refcounting is not a resource policy — deterministic close is a CPython accident that PyPy does not share, and one stray reference removes it on CPython too.
Part five

Concurrency, and where Python is different

Every other page in this series treats threads as the default unit of parallelism. Python does not, because one lock inside the interpreter decides who executes bytecode. Understand that lock and the rest — threads, processes, asyncio — stops being folklore and becomes a straightforward choice.

Python · 12

The GIL: what actually runs in parallel

One lock per interpreter, held by whichever thread is executing bytecode. Threads still interleave, and they still overlap perfectly while waiting on I/O — but four CPU-bound threads on four cores finish no sooner than one.

A · FOUR THREADS, ONE INTERPRETER LOCK shaded = holding the GIL and running bytecode T1 T2 T3 T4 wall clock → Four cores, and the shaded blocks never overlap. Adding threads to CPU-bound work makes it slightly slower, not faster. A thread that blocks on a socket drops the lock immediately, so I/O-bound threads do overlap, and overlap well. B · FOUR PROCESSES, FOUR INTERPRETERS each with its own GIL, its own heap, no sharing worker 1 worker 2 worker 3 worker 4 all four at once — real parallelism The price: every argument and every result is pickled across the boundary, start-up costs tens of milliseconds per worker, and nothing is shared unless you deliberately share it. The lock is dropped more often than people think Every socket read, every disk write and time.sleep release the lock, and so do the heavy loops inside NumPy, zlib and most database drivers — so threads spent inside those libraries really do run in parallel.
“Python is bad at concurrency” is imprecise. The GIL serialises bytecode, not waiting. A service that spends 95% of its time waiting on a database scales fine on threads; a service that spends its time in pure-Python loops needs processes, a C extension that releases the lock, or a different language for that one function.
# the same work, three pools — the numbers are the whole argument
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# CPU-bound: threads take turns holding the GIL, so nothing is gained
with ThreadPoolExecutor(8) as ex:
    list(ex.map(tokenise_row, rows))      # 4.1 s on 8 cores -- pure Python

# CPU-bound: separate interpreters, separate GILs, real parallelism
with ProcessPoolExecutor(8) as ex:
    list(ex.map(tokenise_row, rows))      # 0.6 s — and pickling costs

# I/O-bound: the GIL is released around the syscall, so threads are fine
with ThreadPoolExecutor(32) as ex:
    list(ex.map(fetch, urls))             # 32 sockets, one core, no problem

# and from async code, the escape hatch for one blocking call:
row = await asyncio.to_thread(legacy_driver.query, sql)
L1 · Beginner

One lock, one runner

  • What it is — a mutex inside CPython that a thread must hold to execute bytecode.
  • Threads do not speed up CPU work — four threads summing numbers take as long as one, plus switching overhead.
  • Threads do help I/O — waiting on a socket or a disk releases the lock, so twenty requests can be in flight at once.
  • They are still real OS threads — pre-emptive, so shared state still needs a lock of your own.
  • Measure before choosing — if the profile is dominated by waiting, you have an I/O problem and the GIL is irrelevant.
L2 · Intermediate

Pick the executor by the profile

  • ThreadPoolExecutor — the answer for I/O: HTTP fan-out, S3 uploads, a few dozen database calls.
  • ProcessPoolExecutor — the answer for CPU: image work, parsing, compression, anything numeric without NumPy.
  • Pickling is the process tax — arguments and results are serialised. Sending a 200MB dataframe to each worker can cost more than the work.
  • concurrent.futures — one API for both, so switching is a one-word change. as_completed for results in finishing order.
  • Chunk the work — per-item tasks in a process pool are dominated by round-trip overhead; hand each worker a batch.
  • Never an unbounded pool — the same rule as everywhere else in this series: fix the worker count, and let the queue absorb the burst.
L3 · Advanced

Escaping the lock

  • C extensions release it — NumPy, Pillow, zlib, hashlib, and most database drivers drop the GIL around their heavy call, so threads over those really are parallel.
  • Free-threaded CPython (PEP 703) — builds with no GIL: experimental in 3.13, officially supported since 3.14 (PEP 779). Real thread parallelism, a single-thread cost that has shrunk to a few percent, and an ecosystem of C extensions still being rebuilt and re-audited for thread safety. Watch it seriously; default production stays on the GIL build for now.
  • Subinterpreters (PEP 734) — a per-interpreter GIL in one process: isolation like processes, without the fork cost. In the stdlib as concurrent.interpreters since 3.14; still young in library support.
  • Shared memory — multiprocessing.shared_memory or memory-mapped arrays move large buffers between processes without pickling them.
  • The honest summary — the GIL serialises bytecode, not I/O. Say which of those your workload is made of before you argue about it.
Python · 13

asyncio: one thread, many waits

The event loop runs one task until it awaits something, then runs another. Thousands of sockets on one thread, with no locks — and one synchronous call in the wrong place freezes every task at once.

A · HEALTHY LOOP event loop one thread T1 running — no await yet T2 parked: await sock.recv() T3 parked: await sleep(0.5) T4 parked: await db.fetch() Three tasks are waiting on the kernel; one holds the thread. Every await is an opportunity to hand it over. That is all concurrency means here — nothing runs in parallel. B · ONE BLOCKING CALL event loop BLOCKED T1: requests.get(url) T2 frozen — ready, not run T3 frozen — timer overdue T4 frozen — health check Nothing is awaiting: the thread is inside C code, so the loop cannot switch. Latency for every other task rises by the full duration of that one call. The escape hatch await asyncio.to_thread(get, url) the sync call moves off the loop a worker thread blocks instead of the loop thread every other task keeps running bound that pool, as always
This is the asyncio bug you will actually hit. Not a subtle race — a library that looked async, or a quick time.sleep, or a CPU-heavy JSON parse sitting in a coroutine. The symptom is uniform: every latency percentile rises together, and the health check times out along with the traffic.
import asyncio, httpx

async def fetch_all(urls: list[str]) -> list[bytes]:
    sem = asyncio.Semaphore(20)                  # bound the fan-out

    async with httpx.AsyncClient() as client:
        async def one(u: str) -> bytes:
            async with sem:                      # at most 20 in flight
                r = await client.get(u)
                return r.content

        async with asyncio.timeout(30):          # the whole batch, not each call
            return await asyncio.gather(*(one(u) for u in urls))

asyncio.run(fetch_all(urls))
L1 · Beginner

A coroutine is a plan, not a thread

  • async def returns a coroutine — calling it runs nothing. Forgetting the await is a warning and a function that never happened.
  • await yields control — it says “I am waiting; run someone else”, and resumes when the result is ready.
  • asyncio.run(main()) — one entry point per program: it creates the loop, runs the coroutine, and shuts the loop down.
  • Concurrency, not parallelism — one thread the whole time. It wins by never waiting idly, not by using more cores.
  • Async is contagious — only a coroutine can await, so an async call at the bottom pulls async all the way up the stack.
L2 · Intermediate

Run many, bound them, cancel them

  • gather vs TaskGroup — gather collects results; TaskGroup (3.11+) also guarantees that when one task fails the rest are cancelled and awaited. Prefer it for new code.
  • asyncio.timeout(s) — a context manager that cancels whatever is inside. Cleaner than threading a deadline through every call.
  • Cancellation is an exception — CancelledError is raised at the await point, so cleanup belongs in finally, and you must not swallow it.
  • Never block the loop — no time.sleep, no requests, no big json.loads, no synchronous database driver.
  • asyncio.to_thread(fn, *args) — the one-line adapter for a sync library you cannot replace.
  • Semaphore(n) — launching 10,000 coroutines at a service that handles 50 is not concurrency, it is a denial-of-service test. Bound the fan-out, every time.
L3 · Advanced

The sharp edges

  • Coloured functions — sync and async are two ecosystems: requests and httpx, psycopg sync and async. Choosing async is choosing a dependency stack.
  • Keep a reference to your tasks — the loop holds only a weak one, so a bare asyncio.create_task(...) can be garbage collected mid-flight. Store it in a set and discard on completion, or use a TaskGroup.
  • loop.run_in_executor — what to_thread wraps; use it directly to target a process pool for CPU-bound work.
  • Structured concurrency — a TaskGroup block cannot exit with children still running. The same discipline as an errgroup in Go, enforced by the language.
  • Back-pressure — a bounded asyncio.Queue makes the producer wait when consumers fall behind. An unbounded one turns a slow consumer into a memory leak.
  • Debugging — asyncio.run(main(), debug=True) logs coroutines that hold the loop too long. It finds the blocking call in minutes.
Python · 14

Threads, processes, or async: choosing

The decision has one input that matters — what the work is waiting on — and one honest caveat: most services never reach the scale at which the exciting answer beats a pool of ordinary synchronous workers.

What is the work waiting on? CPU: nothing waits parsing, maths, compression I/O at ordinary scale tens to hundreds in flight I/O at high fan-out thousands of live sockets Mixed async service, sync library ProcessPoolExecutor or NumPy, which drops the GIL ThreadPoolExecutor or sync workers behind an LB asyncio if the whole stack is async to_thread, executor keep the loop itself free chunk the work: pickling is the tax the boring answer, usually the right one no locks, one thread: one blocking call ruins it bound every pool you create Most web services are I/O-bound and never come close to saturating a core: a fixed pool of synchronous workers behind a load balancer is a complete answer, and it is the one you can profile, debug and reason about at three in the morning.
Answer the input question honestly and the rest follows. The expensive mistake is rewriting a service into asyncio for throughput it never needed, and discovering the concurrency limit was the database connection pool all along.
 ThreadsProcessesasyncio
Parallel on several coresNo, for Python bytecodeYesNo — one thread
Overlaps I/O waitsYesYesYes, at the lowest cost
Cost per unitAn OS thread, roughly 8MB of reserved stackA whole interpreter, tens of milliseconds to startA coroutine object, a few hundred bytes
Practical fan-outHundredsAbout one per coreTens of thousands
Sharing dataSame memory — so you need locksPickled, or explicit shared memorySame memory, no pre-emption inside a step
How it goes wrongRaces on shared state; no CPU speed-upSerialisation cost; worker start-up; no shared cacheOne blocking call stalls everything
Existing sync librariesWork unchangedWork unchangedNeed an async equivalent, or to_thread
Reach for it whenI/O-bound and the code is already synchronousCPU-bound and the payload is smallI/O-bound, very high fan-out, async stack

Two limits that decide this before you do

Your database connection pool and your downstream rate limits cap real concurrency long before the interpreter does. Twenty thousand coroutines contending for thirty connections is thirty connections' worth of throughput plus a queue you cannot see. Size the pool first, bound the fan-out to match, and only then argue about threads against async — the same lesson as the queue-depth section on the foundations page, in a different costume.

# the same fan-out three ways — choose by what the work is waiting on

# 1. waiting on the network, and the library is async-native
results = await asyncio.gather(*(fetch(u) for u in urls))

# 2. waiting on the network, but the library is blocking and you cannot change it
with ThreadPoolExecutor(32) as ex:
    results = list(ex.map(requests.get, urls))

# 3. not waiting at all — burning CPU
with ProcessPoolExecutor() as ex:
    results = list(ex.map(compress, chunks))

# the start method is a real decision, not a detail:
#   fork   — fast, copy-on-write, UNSAFE in a process that has threads
#   spawn  — a fresh interpreter; everything must be picklable
multiprocessing.set_start_method("spawn")   # default on macOS/Windows only; Linux went
# fork -> forkserver in 3.14 -- set it explicitly so every platform agrees
Part six

Memory and speed

Python costs roughly 10–100× a compiled language per operation, and every value is a heap object with a header. Neither fact is fixable by rewriting a loop more cleverly. What is fixable: doing fewer operations, holding fewer objects, and knowing which measurement answers which question.

Python · 15

How CPython holds an object, and what that costs

Every value is a separately allocated heap object carrying a reference count and a type pointer, and every container holds pointers to those objects rather than the values themselves. That is why an int is 28 bytes and a list of a million of them is not eight megabytes.

EVERY VALUE IS A HEAP OBJECT A LIST HOLDS REFERENCES A MILLION OF THEM ob_refcnt 8 B ob_type 8 B ob_size 8 B the digit 4 B int(12345) — 28 bytes 24 bytes of that is header xs = [12345, 7, 99, …] ptr ptr ptr ptr ptr each slot is one 8-byte pointer to an object that lives elsewhere slicing copies pointers, never values the pointer array 8 MB the int objects 28 MB about 36 MB in total for numbers a C array would hold in 8 MB, contiguously sys.getsizeof(xs) reports the pointer array only — it is shallow, and the million objects it points at are not counted. DEFAULT INSTANCE THE SAME CLASS WITH __slots__ ob_refcnt ob_type __dict__ {"x": 1.0, "y": 2.0} a hash table, per instance one dict for every instance, and any attribute name is accepted __slots__ = ("x", "y") ob_refcnt ob_type x = 1.0 y = 2.0 values sit at fixed offsets inside the object — no dict smaller, faster to read, and a typo now raises AttributeError The saving is per instance, so it only matters in the hundreds of thousands. Measure yours before adding __slots__ to everything.
Containers hold references, so nothing you can see tells you what a structure really costs. A list of a million ints is 8 MB of pointers plus 28 MB of objects scattered across the heap — and it is the scattering, not the total, that makes iterating it slow: every element is a separate cache miss.
import sys

sys.getsizeof(1)             # 28   -- refcount, type, size, one digit
sys.getsizeof(2**64)         # 36   -- three 30-bit digits now
sys.getsizeof("hello")       # 54   -- 49 of header, 1 byte per ASCII char
sys.getsizeof([0] * 1000)    # 8056 -- 56 header + 8 per slot, and nothing else

xs = [1234 + i for i in range(1_000_000)]
sys.getsizeof(xs)                    # about 8.4 MB: the pointer array only
sum(sys.getsizeof(x) for x in xs)    # about 28 MB more: what it points at

class Point:                         # a dict per instance, any attribute allowed
    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y

class Slim:
    __slots__ = ("x", "y")           # fixed offsets, no __dict__, typos raise
    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y
# a cycle: refcounting alone never frees this pair -- only the collector can
class Node:
    def __init__(self, name: str) -> None:
        self.name, self.parent, self.children = name, None, []

    def add(self, child: "Node") -> None:
        child.parent = self          # parent -> child -> parent: a cycle
        self.children.append(child)

# fix: the back-reference doesn't need to keep the parent alive
import weakref
child.parent = weakref.ref(root)     # dies with root, not with child

import gc, os
gc.freeze()                          # at start-up, after imports, before fork():
os.fork()                            # stops the collector from touching every object's refcount
                                      # and un-sharing every copy-on-write page across workers
L1 · Beginner

Objects, references, and a shallow ruler

  • Everything is a heap object — an int, a string, a function, a class — each carrying a reference count and a type pointer before any data.
  • Containers hold references. Copying a list copies pointers, which is why b = a[:] gives you a new list of the same objects.
  • sys.getsizeof is shallow: it measures the container itself, not the objects it refers to.
  • Small ints (−5 to 256) and some short strings are cached, so is occasionally looks true by accident. Compare with ==.
  • Two numbers worth memorising: an int is 28 bytes, a list slot is 8.
L2 · Intermediate

Hold fewer objects, and smaller ones

  • __slots__ on classes you create by the hundred thousand: fixed offsets instead of a per-instance dict, and a misspelt attribute becomes an error instead of a new field.
  • Lists over-allocate on growth, which is what makes append amortised O(1) — so append in a loop is rarely the thing to fix.
  • Generators and yield stream a pipeline instead of materialising each stage; peak memory becomes one item rather than n.
  • array.array, bytes, memoryview for homogeneous data: one contiguous buffer of values instead of n pointers to n objects.
  • sys.intern for strings you compare or key on constantly — identical objects short-circuit the character-by-character compare.
L3 · Advanced

Refcounts, cycles, and RSS that never falls

  • Reference counting frees immediately; the generational collector exists only for cycles. Deterministic release is why with works so well and why a stray reference in a traceback keeps a whole object graph alive.
  • gc.freeze() after imports and before fork(): the collector otherwise touches the refcount of every shared object, un-sharing copy-on-write pages until each worker owns a private copy of your imports.
  • weakref and WeakValueDictionary for caches and observer registries, so the cache is not the leak.
  • RSS does not fall after a spike. Freed memory returns to CPython’s arenas, not the OS, and one live object pins a whole arena. Fragmentation looks exactly like a leak on a graph.
  • Where Python stops being the answer: a million small records wants NumPy or a columnar buffer; a leak you cannot find wants a worker process recycled every N requests.
Python · 16

Finding the slow part, then fixing it

Each tool answers exactly one question, and the common failure is reading a CPU profile for a problem that was never CPU. Pick the tool from the symptom, not from habit.

THE QUESTION THE TOOL WHAT YOU ARE LOOKING FOR Where does the wall time go? cProfile + pstats the call with the largest cumulative time Is this really faster than that? timeit time per call, best of many repeats Which line of this function? line_profiler the one line holding most of the function Memory grows over hours tracemalloc the allocation site that grew between snapshots It is slow in production, now py-spy top --pid sampled stacks: no restart, no code change Slow, but the CPU is idle perf_counter spans time spent waiting on I/O, not computing Every row starts with a symptom. A profile taken without one produces a ranked list of functions and no decision — which is how an afternoon disappears into speeding up a function nobody calls twice.
The two rows at the bottom are the ones people skip. If the CPU is idle while the request is slow, no CPU profile will ever explain it — the time is in a socket, a lock, or a query plan. And in production, the profiler that requires a redeploy is the profiler you will not use.
# 1. where does the time go
python -m cProfile -o run.prof -m myapp.batch
python -c "import pstats; pstats.Stats('run.prof').sort_stats('cumulative').print_stats(15)"

# 2. is this really faster than that
python -m timeit -s "xs = list(range(1000))" "sum(xs)"

# 3. what grew between here and there
import tracemalloc
tracemalloc.start()
before = tracemalloc.take_snapshot()
handle_batch()
for stat in tracemalloc.take_snapshot().compare_to(before, "lineno")[:10]:
    print(stat)             # file:line, size delta, count delta

# 4. production, right now, with no restart and no redeploy
py-spy top --pid 4131
py-spy dump --pid 4131      # every thread's Python stack, as it stands
L1 · Beginner

Measure before you change anything

  • Never optimise what you have not timed. Intuition about which Python line is slow is wrong roughly half the time.
  • time.perf_counter() for durations: monotonic, and the highest resolution the platform offers.
  • time.time() is the wrong clock — it is a wall clock that NTP can step backwards, so a duration computed from it can come out negative.
  • timeit for anything under a millisecond: it runs the snippet many times and reports the best, because the mean is mostly noise from other processes.
  • Write the number down before and after. “It feels faster” is not a result.
L2 · Intermediate

Find the hot spot, then confirm it

  • cProfile to a file, then pstats sorted by cumulative time. Cumulative finds the expensive subtree; tottime finds the expensive function itself.
  • Read the call tree downwards. The leaf with the huge tottime is often a stdlib function called two million times — the fix is in the caller, not the leaf.
  • tracemalloc snapshots and compare_to for growth: it names the allocating line, which a total heap size never does.
  • line_profiler (kernprof -l) when the hot function is forty lines and the cost is one of them.
  • functools.lru_cache on a pure function with a bounded key space — the cheapest win there is, until the cache pins objects you wanted freed.
L3 · Advanced

Sampling, the observer effect, and the ladder

  • Deterministic profilers instrument every call. cProfile costs 2–3× and distorts exactly the many-small-calls code you were investigating — you profile the profiler.
  • Sampling profilers read stacks from outside the process at a fixed rate: small constant overhead, nothing imported into your app, safe to attach to a live pod. py-spy record for a flame graph, py-spy dump for a hung process.
  • The ladder, in order: a better algorithm → fewer Python-level operations → vectorise with NumPy → a C extension or Rust via PyO3 → another process or another language.
  • Skipping rungs wastes weeks. A Rust extension wrapped around a quadratic algorithm is still quadratic, and now nobody on the team can change it.
  • The honest baseline: the interpreter costs roughly 10–100× a compiled language per operation. That gap is why the winning move is almost always to perform fewer operations, not faster ones.
Python · 17

The performance patterns worth knowing by heart

A short list of rewrites that are always worth making, because each one moves work out of the interpreter or removes it entirely. None of them need a profiler to justify — but a profiler will tell you which one matters here.

s = "" for w in words: s += w s = "".join(words) 1) alloc 3 B, copy 0 B 2) alloc 7 B, copy 3 B 3) alloc 12 B, copy 7 B 4) alloc 18 B, copy 12 B the shaded part is the old string, copied again on every iteration 10,000 appends of a 10-char word copy about 500 MB in total w1 w2 w3 w4 pass 1 — add up the lengths: 18 one allocation pass 2 — copy each word once, into it n pieces, n bytes copied. Linear. join sees every piece before it allocates, and the whole loop runs in C the same argument applies to lists: extend once, do not add in a loop The shape to recognise: work that grows with what you have already done. It hides in a list built with + inside a loop, a dataframe appended row by row, and a dict rebuilt on every iteration. Each looks like one cheap line at the call site.
CPython does have a resize trick that sometimes makes s += w linear — but only when the string has exactly one reference, and it stops applying silently the moment anything else holds one (a list, a closure, a debugger). Behaviour that depends on a refcount is not behaviour you can design around.
Instead ofDo thisWhy
s += chunk in a loop"".join(parts)Each += allocates a new string and copies everything so far. join measures once, allocates once, copies once.
if x in big_listif x in big_setA list scans every element; a set or dict key hashes straight to the slot. Building the set pays for itself after about ten lookups.
math.sqrt(...) or self.total looked up inside a hot loopBind it to a local first: sqrt = math.sqrtEvery attribute and global reference is a dictionary lookup at run time; a local is an index into the frame. Worth 10–20% in a tight loop, and nothing at all elsewhere.
Building a list you only iterate onceA generator expression, or a function that yieldsNothing is materialised: peak memory is one item instead of n, and the first result arrives immediately instead of after the last.
try: d[k] … except KeyError per rowd.get(k, default), setdefault, defaultdict, CounterRaising is cheap once and expensive a million times. The dict methods do the same work in C with a single lookup.
One query per row inside a loopOne batched query — IN (…) or executemanyThe N+1 problem again: 500 round trips at 1 ms is 500 ms of latency that no amount of Python tuning touches.
for i in range(len(a)): c[i] = a[i] * b[i]c = a * b on NumPy arraysMoves the loop into C over a contiguous buffer: no per-element object, no per-element bytecode dispatch. Routinely 10–100×.
Recomputing a pure function with the same arguments@functools.lru_cache(maxsize=1024)Memory for time, in one line. Only for pure functions with hashable arguments — and never maxsize=None on a method, which pins every instance forever.
copy.deepcopy(obj) in a hot pathPass immutable values, or copy the one field that changesdeepcopy walks and rebuilds the entire object graph, memo dict included. It is usually a design problem wearing a safety costume.
lst.pop(0) or lst.insert(0, x)collections.dequeBoth shift every remaining element. A deque appends and pops at either end in constant time.

The order to apply these in

Top to bottom, these are worth between nothing and a hundredfold, and the table cannot tell you which. Apply the ones that cost nothing in readability everywhere — join, sets for membership, generators, batched queries — and apply the rest only where a measurement pointed. Binding locals and unrolling loops in code that runs twice a day is how a codebase becomes unreadable for no gain.

# the rewrites that move the needle, and the one measurement that justifies them
out = "".join(parts)                  # not s += x in a loop — that is quadratic
seen = set(ids)                       # not `x in some_list` — O(n) every check
by_id = {r.id: r for r in rows}       # index once, then look up n times
total = sum(r.amount for r in rows)   # a generator: no intermediate list

# hoisting an attribute lookup out of a hot loop — a real 10-20% on tight loops
append = out.append
for x in data:
    append(transform(x))

# @cache for a pure function called with a small set of arguments
@functools.cache
def tax_rate(country: str, year: int) -> Decimal: ...

# and none of it goes in without this:
#   python -m timeit -s "from mod import f, data" "f(data)"
Part seven

The toolchain everyone assumes you have

None of what follows is in the language. It is the set of defaults the Python community converged on after twenty years of doing it badly — and it is what a reviewer will silently assume is already in place before they read a line of your code.

Python · 18

Environments, resolution, and lockfiles

A dependency range says what is allowed. A lockfile says what you actually got. Projects that ship only the first one discover the difference when CI and a laptop resolve the same range to different versions on different days.

DECLARE → RESOLVE → LOCK → INSTALL pyproject.toml httpx>=0.27,<0.29 what is allowed resolver solves the whole graph at once uv.lock httpx==0.28.1 sha256:9c2f… committed .venv/lib/python3.13/site-packages 84 packages, exact versions byte-identical on every machine THE SAME PROJECT, TWO MONDAYS APART installed from the lockfile $ uv sync --frozen httpx 0.28.1 anyio 4.4.0 certifi 2024.8.30 httpx 0.28.1 anyio 4.4.0 certifi 2024.8.30 Laptop, CI and image agree, so a green CI run means something. installed from the range $ pip install -r requirements.txt httpx 0.28.1 anyio 4.4.0 certifi 2024.8.30 httpx 0.28.1 anyio 4.6.2 certifi 2025.1.31 Nobody edited anything. anyio published a release. Pinning your direct dependencies is not enough — the versions that move under you are almost always transitive, three levels down, in a package you have never heard of. Only a full lockfile pins those.
A range is a permission; a lock is a fact. The reproducibility bug is never in the package you chose. It is in the fourth-level transitive dependency that shipped a patch release between your last green build and this red one.
# pyproject.toml — one file for metadata, dependencies and tool config
[project]
name = "payments"
requires-python = ">=3.13"
dependencies = ["httpx>=0.27,<0.29", "pydantic>=2.7", "sqlalchemy>=2.0"]

[dependency-groups]           # PEP 735: dev deps that are not shipped
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# uv lock          → writes uv.lock, commit it
# uv sync --frozen → installs exactly the lock, fails if it is stale
# uv run pytest    → runs inside the project env, no activation needed
ToolWhat it isWhere you meet itVerdict in 2026
uvResolver, installer, env and Python-version manager in one Rust binaryNew projects; teams that got tired of waitingThe default worth starting from. 10–100× faster installs; understands pyproject.toml natively
pip + venvThe bundled baseline — always present, no lockfile of its ownEverywhere; every Dockerfile you inheritKnow it cold. It is the fallback, and the thing the others emulate
pip-toolspip-compile turns a .in of ranges into a fully pinned .txtMature repos that added locking without changing workflowStill perfectly good. The minimal way to get a lockfile onto a pip project
poetryDependency management, locking and publishing with its own resolverWidely adopted 2019–2024; lots of existing reposFine if you are in it. Slower resolution; historically diverged from standards
pdm / hatchStandards-first project managers; hatchling is a very common build backendLibraries, especially in the scientific and packaging communitieshatchling as a build backend is a safe, boring choice
conda / mambaManages non-Python binaries too — CUDA, MKL, compilersData science, ML, anything with a Fortran ancestorUse when the hard dependency is not Python. Otherwise it is extra weight
L1 · Beginner

Never install into the system Python

  • Virtual environment — a directory with its own site-packages; activating it just puts its bin/ first on PATH.
  • One env per project, never shared. Deleting .venv and recreating it should always be safe — if it is not, something is undeclared.
  • pyproject.toml is the single manifest: metadata, dependencies and tool configuration in one file.
  • .venv/ goes in .gitignore; the lockfile does not. PEP 751’s pylock.toml is the standard lockfile format — uv and pip can export and consume it, so the lock is no longer tool-proprietary.
  • Learn python -m pip, not bare pip — the -m form guarantees you are installing into the interpreter you think you are.
L2 · Intermediate

Commit the lockfile, install from it

  • Ranges in the manifest, exact versions in the lock. Loosen the range when you want an upgrade; regenerate the lock deliberately, in its own commit.
  • --frozen in CI and Docker — installs must fail rather than silently re-resolve when the lock is stale.
  • Extras and groups — test and lint dependencies live in a dev group so they never reach the production image.
  • Applications lock; libraries do not. A library publishes ranges, because pinning inside a library makes it uninstallable alongside anything else.
  • Pin the Python version too — requires-python plus a .python-version file.
L3 · Advanced

Understand what the resolver is doing

  • Resolution is a SAT problem over the whole graph. Backtracking resolvers can take minutes on a conflict, and the error names the pair that is unsatisfiable — read it rather than deleting pins at random.
  • Wheels vs sdists — a wheel is a prebuilt zip; falling back to an sdist means compiling on the target machine and needing a toolchain in your image.
  • Platform tags (manylinux, musllinux, macosx_arm64) decide which wheel you get. Alpine images miss manylinux wheels and rebuild from source — a common reason a Docker build is fifteen minutes.
  • Hash-pinned installs (--require-hashes) make a compromised index unable to substitute an artefact.
  • Multi-stage images — resolve and build in one stage, copy only site-packages into a slim runtime stage.
Python · 19

One formatter, one linter, three gates

Formatting stopped being a discussion when black shipped with no options. Linting stopped being five tools when ruff reimplemented them in one binary. What is left to decide is where the checks run — and the answer is all three places, with identical configuration.

WHAT COLLAPSED INTO ONE BINARY WHERE IT RUNS black formats isort orders imports flake8 + plugins lints pyupgrade modernises autoflake deletes dead code ruff format + lint + fix One config block. Rules ported by code, not reinvented. 1. editor, on save format + safe fixes milliseconds 2. pre-commit hook staged files only about a second 3. CI: ruff check --no-fix whole tree, version pinned the blocking one The three gates must run the same version with the same config. If CI can fail on something the editor accepted, people learn to distrust the editor — and the fast feedback loop, which is the entire point, quietly stops being used.
Same tool, same config, three latencies. The editor catches it before you look away, the hook catches it before it leaves your machine, and CI catches the one case where somebody skipped both.
# pyproject.toml — the whole quality config, in the file you already have
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "ASYNC", "S"]
#  E/F pycodestyle+pyflakes · I imports · UP pyupgrade
#  B bugbear · SIM simplify · ASYNC async traps · S security
ignore = ["E501"]             # the formatter owns line length

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]          # assert is the point of a test

The one rule set worth turning on deliberately

flake8-bugbear (B) is the highest-value group by a distance: it is the one that catches the mutable default argument, the loop variable captured by a closure, and except clauses that swallow everything. Those are the three bugs earlier in this page, found automatically, for free.

L1 · Beginner

Stop formatting by hand

  • Formatter — rewrites layout deterministically. ruff format is black-compatible; the output is not a matter of taste, and that is the feature.
  • Linter — finds likely mistakes: unused imports, undefined names, shadowed builtins, comparisons that are always true.
  • PEP 8 is the style baseline — but you should never be applying it manually.
  • Turn on format-on-save the day you start. Every formatting comment in a review is a wasted one.
  • ruff check --fix applies the safe fixes; read the diff before committing it.
L2 · Intermediate

Make the gate automatic

  • pre-commit — a hook manager with pinned tool versions in .pre-commit-config.yaml. pre-commit autoupdate bumps them in a reviewable commit.
  • Pin the linter version. An unpinned linter turns a new release into a red build on an unrelated PR.
  • Adopt on a large codebase by reformatting everything in one commit, then adding that SHA to .git-blame-ignore-revs so blame still works.
  • Select rules explicitly. select a named set rather than everything — a wall of warnings nobody triages is the same as no linter.
  • noqa needs a code and a reason: # noqa: S105 - test fixture, not a real key.
L3 · Advanced

Know what a linter cannot see

  • Lint is syntactic, types are semantic. Ruff will not tell you a function returns None on one branch; a type checker will. They are different gates, both needed.
  • Custom rules — when a bug class recurs, encode it: an AST check in CI, or a flake8 plugin. A rule that fires automatically beats a convention nobody remembers.
  • Security lint (bandit, ruff’s S rules) flags subprocess(shell=True), weak hashes and hard-coded secrets. High false-positive rate; still worth the triage.
  • Complexity budgets (C901, PLR0912) are blunt but real — they surface the function that needs splitting before a reviewer has to say it.
  • Formatter migrations change diffs, not behaviour. Do them alone, never inside a feature branch.
Python · 20

Type checking as a gate, not a hope

Annotations do nothing at run time. They are worth writing anyway — but only once something actually checks them, in CI, on every commit. The hard part is not the syntax; it is adopting a checker on a codebase that has none without stopping the world.

ADOPTING TYPES ON A CODEBASE THAT HAS NONE Each step is a configuration change plus a handful of commits — never a rewrite. You can sit on any step indefinitely. 0 · baseline runs green, checks little 1 · new code only disallow_untyped_defs 2 · module by module per-module overrides 3 · strict no untyped, no implicit Any WHICH CHECKER mypy The reference checker. Config other tools read. Slower on large trees. pyright / basedpyright Powers Pylance. Faster, sharper inference, stricter defaults. ty / pyrefly The Rust generation — from the uv/ruff team and Meta. Very fast, maturing quickly; worth watching for large trees.
Strictness is a ratchet, not a switch. Turning on strict across a legacy repo produces four thousand errors and one abandoned branch. Turning it on for new files produces a codebase that is fully typed in a year without anyone scheduling it.
# pyproject.toml — strict everywhere, with named, temporary exceptions
[tool.mypy]
python_version = "3.13"
strict = true
warn_unreachable = true
warn_unused_ignores = true    # deletes the ignores you no longer need

[[tool.mypy.overrides]]       # the legacy corner, listed explicitly
module = ["payments.legacy.*"]
disallow_untyped_defs = false

[[tool.mypy.overrides]]       # a dependency that ships no stubs
module = ["some_untyped_lib.*"]
ignore_missing_imports = true
L1 · Beginner

Annotate the boundaries first

  • Function signatures pay for themselves. Parameters and return type on every public function; local variables almost never need one.
  • Install the checker as a dev dependency and run it locally — mypy . or pyright. Your editor is already running one, quietly.
  • None is a type. str | None is the single most useful annotation in the language, because it forces the caller to handle the missing case.
  • Do not annotate for the sake of it: x: int = 0 tells nobody anything.
  • Third-party stubs live in types-requests-style packages; install them when the checker asks.
L2 · Intermediate

Ratchet, and never go backwards

  • Per-module overrides keep the legacy corner listed by name, so the exceptions are visible and shrinking rather than a blanket ignore.
  • warn_unused_ignores turns stale suppressions into errors — the ratchet that stops the exception list growing quietly.
  • Protocol over inheritance for typing duck-typed arguments — the direct analogue of a Go interface, satisfied structurally.
  • Type the data at the edge — Pydantic at the boundary, plain dataclasses inside. The checker is only as good as the point where untyped data becomes typed.
  • Run the checker in CI on the whole tree, not just changed files — a change in one module breaks types in another.
L3 · Advanced

Where the type system gets interesting

  • TypeVar, ParamSpec, Concatenate — how a decorator keeps its wrapped function’s signature instead of collapsing it to (*args, **kwargs).
  • overload for functions whose return type depends on argument types, and Literal for the flags that select between them.
  • Narrowing — isinstance, assert, and TypeGuard/TypeIs teach the checker facts it cannot infer; match statements narrow too.
  • Variance bites in real code: list[Dog] is not a list[Animal], because you could append a Cat. Accept Sequence when you only read.
  • Any is contagious — it silences errors downstream of itself. --disallow-any-explicit and object plus narrowing are the honest alternatives.
Python · 21

Layout, naming, and version numbers

Two directory layouts are common and one of them lets your tests import a copy of the code that will never be installed. The naming conventions are trivial to learn and instantly visible in review, which is exactly why they are worth getting right.

WHICH COPY DO THE TESTS ACTUALLY IMPORT? flat layout payments/   payments/__init__.py   tests/test_charge.py   pyproject.toml The current directory is on sys.path, so the source tree wins and the wheel is never tested. src layout payments/   src/payments/__init__.py   tests/test_charge.py   pyproject.toml Nothing importable sits in the root, so the tests exercise exactly what packaging produced. The classic symptom: tests pass locally and the published package fails on import, because a data file or a submodule was never added to the wheel. PEP 8 NAMES — VISIBLE IN EVERY REVIEW module_name.py files, packages ClassName classes, exceptions function_name functions, methods MAX_RETRIES module constants _internal private by convention __dunder__ reserved — never invent
The layout is a correctness question, not taste. A flat layout puts an importable copy of your package in the working directory, so every test run silently prefers the source tree over the artefact you are about to publish.

Version numbers

SemVer (2.4.1) is the ecosystem default; CalVer (2026.8.1) is common for applications and tools where dates mean more than compatibility promises. Python’s own rules live in PEP 440, which also defines pre-releases (1.0.0rc1), post-releases and the ~= compatible-release operator. Keep one source of truth — usually version in pyproject.toml, read at run time via importlib.metadata.version().

The PEPs actually worth knowing

8 style · 20 the Zen · 257 docstrings · 440 versions · 484 type hints · 517/518/621 builds and pyproject.toml · 561 shipping types · 3107/526 annotation syntax. You do not read PEPs for fun; you read the one that explains why a tool is behaving the way it is.

# pyproject.toml — src layout, one package, one source of truth for the version
[project]
name = "payments"
version = "0.4.1"           # read at run time via importlib.metadata

[tool.hatch.build.targets.wheel]
packages = ["src/payments"]

# and the check that tells you which layout you are really in:
#   $ pip install -e . && python -c "import payments; print(payments.__file__)"
#
#   src layout  → .../site-packages/payments/__init__.py   the installed copy
#   flat layout → ./payments/__init__.py                   the source tree wins
#
# Only the first one is testing what your users will actually install.
L1 · Beginner

Follow the conventions without thinking

  • snake_case for anything callable or storable, PascalCase for classes, SCREAMING_SNAKE for module-level constants.
  • Four spaces. Never tabs; the formatter enforces it anyway.
  • One leading underscore means “not part of the API”. Nothing enforces it — Python has no private — and that is fine.
  • Never name a file after a standard library module. A local random.py or types.py shadows the real one and produces a genuinely baffling error.
  • Docstrings on public functions; PEP 257 for the shape, Google or NumPy style for the body.
L2 · Intermediate

Adopt the src layout

  • src/ makes the installed package the only importable one, so pip install -e . plus pytest tests the real artefact.
  • Editable installs (-e) put a path hook in site-packages; your edits are live, but the import path is still the installed one.
  • Keep tests/ outside the package unless you intend to ship them.
  • Layer by responsibility, not by type. payments/charge/ beats models/, views/, utils/ — a utils package is where cohesion goes to die.
  • Build the wheel and inspect it (unzip -l dist/*.whl) before your first release. What is missing is always a data file.
L3 · Advanced

Publishing and the import graph

  • __all__ defines the star-import surface and signals intent to tooling; py.typed (PEP 561) is what makes your annotations visible to a consumer’s checker.
  • Namespace packages let several distributions contribute to one import path — powerful for plugin ecosystems, confusing everywhere else.
  • Entry points in pyproject.toml give you console scripts and plugin discovery without an import-everything scan.
  • Trusted publishing to PyPI via OIDC removes the long-lived API token from CI entirely — the same identity-federation idea as IRSA on EKS.
  • Import cycles are an architecture smell. Enforce the layering mechanically with import-linter rather than by review.
Part eight

The ecosystem, and the taste that goes with it

Python’s standard library is famously large and famously not enough. Almost every job has a conventional answer — knowing which one, and what arrives in your image alongside it, is most of what “experienced in Python” means in practice.

Python · 22

The library map

There is a default choice for nearly every job. Deviating from it is completely fine — it just needs a reason you can say out loud, because the default is what the next person on the team will already know how to debug.

ONE REQUEST, AND THE LIBRARY AT EACH HOP client uvicorn ASGI server FastAPI routing, DI, OpenAPI pydantic v2 parse, coerce, reject your handler the only novel part SQLAlchemy 2.0 + asyncpg, Alembic for migrations httpx async client, timeouts always set arq / Celery work that must outlive the request structlog → one JSON object per line on stdout OpenTelemetry SDK → spans carrying one correlation id across every hop above cross-cutting, wired once in main Everything above the handler parses and routes; everything below it does input and output. The novel code is one box wide.
A conventional Python service is mostly assembly. Five well-known libraries and one box of your own logic — which is the point: the boring parts are boring because thousands of people already debugged them.
JobThe defaultAlso commonWhat to know
HTTP APIFastAPIDjango, Flask, LitestarFastAPI derives validation and OpenAPI from your type hints. Django when you want the admin, auth and ORM as one decision
Serving ituvicorn (ASGI)gunicorn (WSGI), granianIn production, uvicorn --workers N — it grew a real multiprocess manager, so gunicorn is no longer the assumed supervisor (if you do keep it, the worker class now lives in the separate uvicorn-worker package); one worker per core as a starting point
Validationpydantic v2attrs+cattrs, msgspecv2’s core is Rust and roughly 5–20× faster than v1. Validate at the edge only — not on every internal call
DatabaseSQLAlchemy 2.0Django ORM, SQLModel, raw psycopg2.0 style is typed and async-capable. Know how to see the emitted SQL, and what N+1 looks like in a log
MigrationsalembicDjango migrationsAutogenerate drafts, humans edit. Every migration needs to be safe to run while the old code is still serving
HTTP clienthttpxrequests, aiohttprequests is sync-only and has no default timeout — the single most common source of a hung worker
Background workCeleryarq, dramatiq, RQ, TemporalAt-least-once delivery, so tasks must be idempotent. arq is the small asyncio-native option
Testingpytestunittest (stdlib)Plus pytest-asyncio, hypothesis for property tests, testcontainers for a real database, respx/responses for HTTP
CLItyper / clickargparse (stdlib)typer builds the parser from type hints; argparse is fine and adds no dependency
Configpydantic-settingsenvirons, dynaconfOne settings object, validated at start-up, failing loudly. Never os.environ[...] scattered through the code
Loggingstructloglogging + a JSON formatterStructured fields, not formatted strings. Configure once in main; libraries only ever call getLogger(__name__)
ObservabilityOpenTelemetryprometheus-client, sentry-sdkAuto-instrumentation covers the popular frameworks. Traces answer “where did the time go”; metrics answer “how often”
Data workpolars or pandasduckdb, pyarrowpandas has the ecosystem; polars has a query optimiser and far lower memory. duckdb when SQL is simply the better language
Numericsnumpyscipy, scikit-learn, torchVectorised operations sidestep both the interpreter and the GIL — the loop runs in C, with the lock released
Odds and ends—tenacity, orjson, zoneinfoRetries with backoff and jitter; fast JSON; stdlib time zones — and datetime.now(UTC), never utcnow()
# main.py — the entire stack from the diagram, wired once, closed once
from contextlib import asynccontextmanager

engine  = create_async_engine(settings.database_url, pool_size=10, max_overflow=5)
Session = async_sessionmaker(engine, expire_on_commit=False)
client  = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
log     = structlog.get_logger()

@asynccontextmanager
async def lifespan(app: FastAPI):
    log.info("starting", version=__version__)
    yield                                   # the app serves here
    await client.aclose()                   # reverse order, like Go's defer
    await engine.dispose()

app = FastAPI(title="payments", version=__version__, lifespan=lifespan)
app.include_router(orders.router)

# uvicorn main:app                     — development
# uvicorn main:app --workers 4         — production: uvicorn manages its own workers
L1 · Beginner

Reach for the standard library first

  • It is genuinely large — pathlib, dataclasses, itertools, functools, collections, json, datetime, subprocess, sqlite3, secrets.
  • collections earns its keep: Counter, defaultdict, deque for an O(1) queue.
  • pathlib over os.path — path / "sub" / "file.txt" reads better and handles platforms for you.
  • A dependency is a permanent cost. Ten lines you own beat a package you must patch, audit and upgrade forever.
  • Check when a package was last released before adopting it. Python has a long tail of dead but well-ranked libraries.
L2 · Intermediate

Know the default and why it won

  • FastAPI + pydantic + SQLAlchemy + pytest is the modern service stack; Django is the other coherent answer, and mixing the halves of the two is the usual mistake.
  • Async is all-or-nothing per call path. One synchronous database driver inside an async handler blocks the whole event loop — see the earlier asyncio topic.
  • Set timeouts on every client — HTTP, database, cache. A default of “wait forever” is how one slow dependency takes down a whole fleet.
  • Pin the framework major version and read its migration guide before bumping. Pydantic v1→v2 was a genuine rewrite.
  • Prefer libraries that ship py.typed; untyped dependencies punch holes straight through your type checking.
L3 · Advanced

Evaluate before you adopt

  • Read the release notes and the issue tracker, not the landing page. Open-issue age and maintainer count predict your next two years better than benchmarks do.
  • Rust-backed cores (pydantic-core, ruff, polars, orjson) are the defining shift of the last few years: Python at the edges, native code where the loop is hot.
  • C extensions constrain you — wheels for your platform, ABI compatibility, and how the package behaves under free-threaded builds.
  • Wrap third-party clients behind your own thin interface at the boundaries you expect to churn. Not everywhere — only where you have a real reason to think the library will change.
  • Measure before adopting a “faster” library. orjson is genuinely quicker, and irrelevant if you spend 40 ms in the database and 0.4 ms in JSON.
Python · 23

The dependencies you did not choose

You pick six packages and install eighty-four. Every one of the other seventy-eight runs arbitrary code at install time and at import time, with your credentials in the environment. That is not paranoia, it is just what pip install does.

WHAT YOU CHOSE, AND WHAT YOU INSTALLED pyproject.toml 6 direct dependencies you read about all six 84 packages on disk 78 arrived uninvited each runs code at import the blast radius Any one of them can read the same environment your credentials live in. FOUR CONTROLS, EACH CATCHING A DIFFERENT THING lockfile + hashes CATCHES an artefact changing underneath a version you already reviewed pip-audit in CI CATCHES published CVEs, deep in the tree, in packages you never named Dependabot CATCHES drift — the two-year upgrade that has to happen all at once reviewing new deps CATCHES typosquats, abandoned projects, and the ones nothing else can see Only the fourth is a human control, and it is the only one that catches a package that is malicious rather than merely out of date.
The tree is the attack surface, not the manifest. Three of these four controls are configuration you set once. The fourth is a habit: someone reads the name, the release history and the maintainer before a new import lands on main.
# the supply-chain job, in about six lines of CI
- run: uv sync --frozen                # fail if the lock is stale
- run: uv run pip-audit                # known CVEs in the whole tree
- run: uv run ruff check --select S    # shell=True, weak hashes, secrets
- run: uv run mypy .
- run: uv run pytest --cov --cov-fail-under=80

# and in the Dockerfile: resolve in one stage, run in a slim one
FROM python:3.13-slim AS runtime
COPY --from=build /app/.venv /app/.venv
USER 1000                              # never root
L1 · Beginner

Know what installing actually does

  • Installing runs code. A source distribution executes its build script; every package executes its __init__ on first import.
  • Check the name character by character. Typosquats live on PyPI on purpose — python-dateutil is real, plausible neighbours are not.
  • Never commit a secret, even in a test fixture; use .env files that are git-ignored, and a real secret manager in production.
  • Do not pip install as root in an image, and do not run the container as root either.
  • pip list --outdated once a month beats a two-year upgrade in one sitting.
L2 · Intermediate

Automate the boring three

  • pip-audit checks the resolved tree against the Python advisory database; run it in CI and on a schedule, since new CVEs land against code you have not touched.
  • Dependabot or Renovate for grouped, automatic upgrade PRs. Small and frequent, so the diff is readable.
  • Slim base images — python:3.13-slim over full, and be wary of alpine: musl means no manylinux wheels and long source builds.
  • Separate build and runtime stages so compilers never ship to production.
  • SBOM generation (CycloneDX) is increasingly a procurement requirement, and is one CI step.
L3 · Advanced

Reduce what you have to trust

  • Hash-pinned installs (--require-hashes, or a lockfile that stores digests) mean a compromised index cannot substitute an artefact for a version you already approved.
  • A private index with an allowlist removes dependency confusion — the attack where an internal package name is registered publicly at a higher version.
  • Trusted publishing via OIDC replaces long-lived PyPI tokens in CI; Sigstore attestations are the emerging provenance layer on top.
  • Count what you actually import. Dropping one convenience wrapper often removes a dozen transitive packages.
  • Vendoring is a legitimate answer for a small, stable, rarely-updated dependency — you trade upgrades for a tree you can read.
Python · 24

What “idiomatic” means in review

Idiomatic Python is not a style preference. Each of these rewrites removes a way for the code to be wrong — an index that can drift, a resource that can leak, an exception that can vanish. That is why reviewers keep asking for them.

WHAT GETS FLAGGED WHAT IS WANTED for i in range(len(xs)): use(xs[i]) if len(items) > 0: if k not in d: d[k] = [] f = open(p); data = f.read(); f.close() except Exception: pass iterate the thing truthiness the stdlib has it context manager catch what you handle for x in xs: use(x) if items: d = defaultdict(list) with open(p) as f: data = f.read() except TimeoutError: log.warning(…) None of these are shorter for the sake of it. Each one deletes a state the wrong version could reach — a stale index, a leaked handle, a swallowed error.
Idiom as defect removal. Ruff catches four of these five automatically; the fifth — which exception to catch — is a judgement about what your code can actually recover from, and no tool can make it for you.

The Zen, minus the poetry

import this prints nineteen lines. Three of them do real work in review. Explicit is better than implicit — pass the dependency, do not reach for a global. Flat is better than nested — guard clauses and early returns instead of an arrowhead of ifs. Errors should never pass silently — the one that except Exception: pass violates every time.

Idioms that have quietly expired

% and .format() → f-strings. os.path → pathlib. typing.List[int] → list[int], and Optional[X] → X | None. datetime.utcnow() → datetime.now(UTC), because the old one returned a naïve datetime that merely claimed to be UTC. setup.py → pyproject.toml. Ruff’s UP rules fix most of this for you.

# before — every flag from the diagram above, in one eight-line function
def process(data):
    result = []
    for i in range(len(data)):
        if data[i] != None:
            if data[i]["status"] == "ok":
                result.append(data[i]["value"] * 2)
    return result
# after — same behaviour; the name says what it returns and the types hold
def doubled_ok_values(rows: Iterable[Row | None]) -> list[int]:
    return [r.value * 2 for r in rows if r is not None and r.status == "ok"]

# what actually changed:
#   range(len(x))  → iterate the thing        != None → is not None
#   nested ifs     → one filter               dicts   → a typed record
#   "process"      → a name that says the answer, not the verb
L1 · Beginner

Let the language do the work

  • Iterate objects, not indices; enumerate when you genuinely need the position, zip when you have two sequences.
  • Comprehensions for transform-and-filter, a loop for anything with side effects. If it needs a comment to read, make it a loop.
  • Unpack: a, b = b, a, first, *rest = items.
  • f-strings for everything, including f"{value=}" for debugging, which prints the expression and its result.
  • t-strings (3.14+) — t"where id = {user_id}" builds a Template object instead of a string, so a library can escape or parameterise each interpolation before anything is joined. The answer to injection wherever an f-string would leave your program (SQL, HTML, shell).
  • Name things for what they are. data, result and temp tell the next reader nothing.
L2 · Intermediate

Structure the function itself

  • Guard clauses first. Handle the impossible cases and return, so the happy path is at one indent level and reads top to bottom.
  • Return one type. A function that returns a dict, or None, or raises, has three contracts and no callers who handle all of them.
  • Keyword-only arguments (* in the signature) for booleans and options — charge(amount, retry=True) reads; charge(amount, True) does not.
  • Small, pure core; thin, effectful shell. Push I/O to the edges and the middle becomes trivial to test.
  • Docstring the why. The what is in the signature and the types; the why is the only thing a reader cannot reconstruct.
L3 · Advanced

Know when to stop being clever

  • Metaclasses, __getattr__ magic and dynamic imports all work, and all defeat the type checker, the IDE and the next reader. The bar is high and it is rarely met.
  • Prefer composition and Protocol to deep inheritance; Python’s MRO is well defined and still nobody wants to reason about it at 3am.
  • Make illegal states unrepresentable — frozen dataclasses, enums instead of string flags, and a parsed type at the boundary rather than a validated dict passed onward.
  • Consistency beats personal preference. In an existing codebase, the local convention wins; change it deliberately and everywhere, or not at all.
  • Optimise for the reader who is on call. The next person to open this file will be tired, in a hurry and looking for one specific thing. Clever code charges them at exactly the worst moment.
Part nine

Five things you will actually be asked to write

Everything up to here was mechanism. This part is the code — five ordinary backend jobs written the way a reviewer would want them, with the reasoning attached. Read the L2 column of each for why it is written that way rather than the shorter way.

Python · 25

Parsing an untrusted payload with Pydantic

The job: turn a JSON body you did not write into an object your code can trust. Pydantic does it in one call, and the important part is what it means afterwards — nothing downstream ever has to ask whether qty is a number.

THE BOUNDARY — UNTRUSTED JSON IN, TYPED OBJECT OUT raw request body {"email": "a@b.co", "currency": "GBP", "items": [{"sku": "ABC-0001", "qty": "3", "price": "12.50"}]} every value is a str, a list or a dict model_validate(body) coerce "3" → 3 coerce "12.50" → Decimal check qty ≥ 1, sku pattern reject any unknown key CreateOrder order.items[0].qty → int order.items[0].price → Decimal order.currency → "GBP"|"EUR"|"USD" frozen, and mypy knows all of it OR, IF ONE FIELD IS WRONG ValidationError [{"type": "int_parsing", "loc": ("items", 0, "qty"), ← the JSON path, ready to return "msg": "Input should be a valid integer", "input": "three"}] One bad field, one 422, no partial write, and an error the caller can act on. Parse, do not validate. After that one line, the wrong shape is unrepresentable — which is the same trick as a Go struct, arriving one layer later.
One call is the whole boundary. Everything before it is untyped text off a socket; everything after it is an object whose invariants the checker and the reader can both rely on.
# models.py — the contract, written once
from decimal import Decimal
from typing import Annotated, Literal, Self
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator

Money = Annotated[Decimal, Field(gt=0, max_digits=12, decimal_places=2)]
Sku   = Annotated[str, Field(pattern=r"^[A-Z]{3}-\d{4}$")]

class LineItem(BaseModel):
    sku: Sku
    qty: Annotated[int, Field(ge=1, le=100)]
    price: Money

class CreateOrder(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)   # unknown key = error

    email: EmailStr
    currency: Literal["GBP", "EUR", "USD"]
    items: Annotated[list[LineItem], Field(min_length=1, max_length=50)]
    note: str | None = None
# two kinds of rule: one field, or the whole object
    @field_validator("note")
    @classmethod
    def strip_and_bound(cls, v: str | None) -> str | None:
        return v.strip()[:500] if v else None

    @model_validator(mode="after")                # runs once, after coercion
    def within_unattended_limit(self) -> Self:
        if sum(i.qty * i.price for i in self.items) > Decimal("10000"):
            raise ValueError("order exceeds the unattended limit")
        return self
# using it — the only three calls you need day to day
try:
    order = CreateOrder.model_validate(request_json)   # dict  -> object
except ValidationError as e:
    return 422, {"errors": e.errors(include_url=False)}

order.items[0].price * order.items[0].qty     # Decimal arithmetic, exact
payload = order.model_dump(mode="json")       # object -> JSON-safe dict
CreateOrder.model_json_schema()               # the OpenAPI schema, free

The same class does your configuration

pydantic-settings is the same machinery pointed at the environment, which is why the config topic earlier could promise “fails at start-up, not at 3am”: class Settings(BaseSettings): database_url: PostgresDsn; timeout_s: float = 2.0; api_key: SecretStr — then settings = Settings() at import raises immediately if API_KEY is missing or TIMEOUT_S is not a number. SecretStr also keeps the value out of every log line and traceback that repr the object.

L1 · Beginner

What the code does

  • BaseModel turns annotations into run-time checks — the one place in Python where a type hint has teeth.
  • Field carries the constraints: gt, ge, le, min_length, pattern. They land in the JSON schema too.
  • model_validate takes a dict and returns an instance or raises; model_dump goes back the other way.
  • Annotated[Decimal, Field(...)] names a reusable rule — Money is now one word you can put on any field.
  • ValidationError.errors() is a list of dicts, not a string. Return it as the response body.
L2 · Intermediate

Why it is written this way

  • extra="forbid" makes a typo in the client an error instead of a silently ignored field — the default is to drop unknown keys, which hides bugs for months.
  • frozen=True makes the parsed request immutable, so no later code can “fix up” the input and leave the log disagreeing with what happened.
  • Decimal for money, never float — and pydantic will coerce the string "12.50" straight to Decimal without going through binary floating point.
  • field_validator for one field, model_validator(mode="after") for a rule that needs several; mode="after" means every value is already the right type.
  • Separate the request model from the database model. They change for different reasons, and merging them is how internal columns end up in your public API.
L3 · Advanced

What bites in production

  • Validation is not free. v2’s core is Rust and fast, but validating the same object at four internal layers is four times the cost for no extra safety. Parse once, at the edge.
  • model_construct skips validation for data you already trust — useful on a hot path out of the database, dangerous anywhere near a socket.
  • Union order matters. Prefer a discriminated union (Field(discriminator="kind")) over a bare A | B: it picks by tag rather than by trying each in turn, and the errors name one branch instead of all of them.
  • ValidationError messages can leak input. Return type and loc to the caller; keep input for your logs, and never let a SecretStr reach either.
  • Generated schemas are a contract. If clients consume your OpenAPI, adding extra="forbid" or tightening a pattern is a breaking change — version it like one.
Python · 26

One endpoint, end to end

The job: accept an order, write it once, and return an answer the caller can retry safely. Four layers, each owning exactly one thing — and the transaction boundary drawn so a raised exception cannot leave half an order behind.

POST /orders CreateOrder runs before your code get_session() BEGIN create_order() HTTP shapes only orders.place() knows no HTTP 201 COMMIT AND THE THREE WAYS OUT THAT ARE NOT 201 422 Unprocessable Pydantic rejected the body. Your handler never ran, so no transaction was opened. 409 Conflict A domain rule said no — out of stock. Expected, named, rolled back, not an alert. 500 — anything else Unhandled. The context manager still rolls back, because that is its job. THE SAME REQUEST, SENT TWICE Idempotency-Key → unique index → second attempt hits DuplicateKey → return the first answer, still 201
The transaction boundary is the dependency, not the handler. Because get_session yields inside session.begin(), every exit path — return, raise, cancellation — commits or rolls back exactly once, and no handler has to remember to.
# deps.py — one session and one transaction per request
async def get_session() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as session:      # closes, returns to the pool
        async with session.begin():            # COMMIT on clean exit,
            yield session                      # ROLLBACK on any exception
# routes.py — HTTP shapes in, HTTP shapes out, nothing else
router = APIRouter(prefix="/orders", tags=["orders"])

class OrderCreated(BaseModel):        # response contract ≠ request contract
    id: UUID
    status: Literal["accepted"]
    total: Decimal

@router.post("", status_code=201, response_model=OrderCreated)
async def create_order(
    body: CreateOrder,                                  # validated already
    idem_key: Annotated[str, Header(alias="Idempotency-Key")],
    session: Annotated[AsyncSession, Depends(get_session)],
) -> OrderCreated:
    try:
        order = await orders.place(session, body, idem_key)   # INSERT inside session.begin_nested()
    except orders.DuplicateKey as e:
        order = await orders.get(session, e.existing_id)  # same answer
    except orders.OutOfStock as e:
        raise HTTPException(409, str(e)) from e           # keeps the cause
    return OrderCreated(id=order.id, status="accepted", total=order.total)
L1 · Beginner

What the code does

  • The type hints are the wiring. body: CreateOrder means “parse the JSON body into this”; Header(...) means “read this header”.
  • Depends(get_session) runs the dependency, hands you what it yields, and resumes it after the response.
  • The savepoint is load-bearing. A unique-key violation aborts a plain Postgres transaction — every later statement fails with InFailedSQLTransaction. Because place() wraps its INSERT in session.begin_nested(), only the savepoint rolls back, and the orders.get on the next line still runs inside a healthy transaction.
  • response_model filters the response through a second model, so an internal field cannot leak by accident.
  • raise HTTPException(409, ...) is how you choose a status code deliberately.
  • from e keeps the original traceback attached — the chained cause you saw in the exceptions topic.
L2 · Intermediate

Why it is written this way

  • The route knows HTTP; the domain does not. orders.place raises OutOfStock, never HTTPException — so the same function works from a worker, a CLI and a test.
  • Yield-style dependencies own the resource. The async with pair means the transaction cannot outlive the request, no matter how the handler exits.
  • Request and response models are separate classes even when they look alike today; they diverge the first time you add an internal field.
  • Idempotency is a database constraint, not an if. A unique index on the key is the only version that survives two concurrent retries.
  • A duplicate is not an error. Returning the original answer is what makes a client retry safe, which is what makes at-least-once delivery workable.
L3 · Advanced

What bites in production

  • One blocking call poisons the loop. A synchronous driver, time.sleep or a CPU-heavy hash inside an async def stalls every other request on that worker — asyncio.to_thread or a sync def endpoint instead.
  • The client can vanish mid-request. A disconnect cancels the task and raises CancelledError inside your handler; the rollback is correct, but any external side effect already sent is not undone.
  • Long transactions hold locks. Do not call another service inside session.begin() — a two-second HTTP timeout becomes a two-second row lock.
  • The dependency’s COMMIT runs after the response is sent — FastAPI resumes yield dependencies once the body is out the door. If the caller must be able to trust the 201, commit explicitly in the handler before returning, and keep the dependency as the rollback-on-error backstop.
  • Pool sizing is a total, not a per-worker setting. Eight workers × a pool of 20 is 160 connections; Postgres’s default max_connections is 100.
  • Validation errors are a 422 by default, which many clients treat as unretryable — deliberate, and worth documenting rather than discovering.
Python · 27

Calling another service without hanging

The job: call an internal API, survive its bad minutes, and never become the reason your own workers are all stuck. Two numbers decide everything — the per-attempt timeout and the total budget the caller is willing to wait.

RETRIES ONLY HELP INSIDE THE BUDGET caller deadline, 2.0s attempt 1 — 503 attempt 2 — 503 attempt 3 — answers after the caller gave up 0s 0.5 1.0 1.5 2.0 2.5 Three attempts of 600 ms plus two backoff waits do not fit in two seconds. Either cut the per-attempt timeout, or accept two tries — a third that lands after the caller has gone is pure added load on a service that is already failing. AND WHAT EACH OF THE FOUR httpx TIMEOUTS COVERS pool waiting for a free socket connect TCP handshake and TLS write sending the request body read gap between two chunks of response
read is a gap, not a total. A response that trickles a byte every second never trips a read timeout — which is why a retry budget and an overall deadline are separate settings from the four above.
# client.py — one client for the process, not one per call
TIMEOUT = httpx.Timeout(connect=1.0, read=2.0, write=2.0, pool=1.0)
LIMITS  = httpx.Limits(max_connections=100, max_keepalive_connections=20)

client = httpx.AsyncClient(base_url="https://ledger.internal",
                           timeout=TIMEOUT, limits=LIMITS)
# a fresh AsyncClient per call throws away the connection pool and the
# TLS session, and turns a 2 ms call into a 40 ms one

class Retryable(Exception): ...      # 5xx, timeouts: worth another try
class Permanent(Exception): ...      # 4xx: retrying cannot help

@retry(stop=stop_after_attempt(3),
       wait=wait_exponential_jitter(initial=0.1, max=1.0),   # jitter matters
       retry=retry_if_exception_type((httpx.TransportError, Retryable)),
       reraise=True)
async def post_entry(entry: LedgerEntry, idem_key: str) -> LedgerRef:
    r = await client.post("/entries", json=entry.model_dump(mode="json"),
                          headers={"Idempotency-Key": idem_key})
    if r.status_code >= 500:
        raise Retryable(f"ledger {r.status_code}")
    if r.status_code >= 400:
        raise Permanent(r.text[:200])
    return LedgerRef.model_validate(r.json())   # their JSON is untrusted too
L1 · Beginner

What the code does

  • Every call has a timeout. httpx defaults to five seconds; requests defaults to waiting forever, which is the difference that matters.
  • One client, reused — it holds the connection pool. Create it at start-up, close it at shutdown.
  • Retry only what a retry can fix: connection errors and 5xx. A 400 will be a 400 again.
  • Back off between attempts, and add jitter so a thousand clients do not all retry on the same tick.
  • Validate their response too — another service’s JSON is exactly as untrusted as a browser’s.
L2 · Intermediate

Why it is written this way

  • Two exception types, not status-code checks at the call site. Retryable and Permanent put the policy in one place and make the retry decorator declarative.
  • The idempotency key is generated by the caller and reused across all attempts — that is what makes a retry safe rather than a second charge.
  • Timeouts are named separately so you can allow a slow response while still failing fast on a dead host.
  • Cap the total attempts against the caller’s deadline, not against a number that felt right. Three × 600 ms does not fit in a 2s budget.
  • max_connections is your bound on the downstream, and it is the only thing standing between a slow dependency and every worker you own.
L3 · Advanced

What bites in production

  • Retries are a load multiplier. A struggling service that you hit three times instead of once gets 3× the traffic exactly when it can least take it — the metastable failure from the foundations page.
  • A circuit breaker is the missing piece. After N consecutive failures, fail immediately for a cooldown instead of queueing more work against a service that is down.
  • Retrying a non-idempotent POST duplicates work unless the server enforces the key. “We send a key” and “they honour it” are different claims — check which one is true.
  • Propagate the deadline, not just a timeout: pass the remaining budget downstream so the whole chain gives up together, the way a Go context does.
  • Connection pools and forked workers — a client created before fork shares sockets across processes. Build it in the worker’s own start-up hook.
Python · 28

Fan-out with a bound on it

The job: enrich two hundred records by calling a service for each. The naive version issues two hundred simultaneous requests, and the thing that breaks is not your process — it is theirs.

WITH A SEMAPHORE … 200 coroutines Semaphore(20) 20 permits, 180 waiting 20 in flight steady, all the way downstream: pool of 25 Latency rises a little. Nothing fails. WITHOUT ONE — gather(*(fetch(i) for i in ids)) 200 at once the loop is happy 200 sockets or a pool-timeout storm their pool: 25 175 queue, then 503 retries → 400 now it cannot recover Concurrency is cheap in your process and expensive in theirs. The bound belongs on your side, because you are the one who knows how many of you there are.
The semaphore is the backpressure. Without it, gather converts a list length into a concurrency level — and the list length is chosen by whoever sent the request, not by you.
# bounded fan-out, partial failure allowed
async def enrich_all(ids: list[str], *, limit: int = 20) -> list[Result | BaseException]:
    sem = asyncio.Semaphore(limit)

    async def one(oid: str) -> Result:
        async with sem:                        # the bound, not the loop
            async with asyncio.timeout(2.0):   # per item, not per batch
                return await enrich(oid)

    return await asyncio.gather(*(one(i) for i in ids), return_exceptions=True)
    # return_exceptions=True: one bad id does not lose the other 199 results
# all-or-nothing instead: 3.11+ TaskGroup cancels siblings on first failure
async def must_all_succeed(ids: list[str]) -> dict[str, Result]:
    async with asyncio.TaskGroup() as tg:
        tasks = {i: tg.create_task(enrich(i)) for i in ids}
    return {i: t.result() for i, t in tasks.items()}

# the exit raises ExceptionGroup — handle by type, not by position:
try:
    results = await must_all_succeed(ids)
except* TimeoutError as eg:
    log.warning("slow upstream", count=len(eg.exceptions))
except* ValidationError as eg:
    log.error("bad payload", count=len(eg.exceptions))
L1 · Beginner

What the code does

  • gather starts everything at once and waits for all of it. The list you pass decides how much runs concurrently.
  • Semaphore(20) hands out twenty permits; the twenty-first async with waits until one is returned.
  • return_exceptions=True puts failures in the result list instead of losing the successes.
  • asyncio.timeout is a context manager, so it bounds a block rather than a single call.
  • TaskGroup is the newer, stricter option: if one child fails the rest are cancelled.
L2 · Intermediate

Why it is written this way

  • The timeout is inside the semaphore, so the two seconds measure the actual call — not two seconds of queueing behind other tasks.
  • Choose the failure mode deliberately. gather(return_exceptions=True) for “do as much as you can”; TaskGroup for “all of it or none”.
  • except* handles an ExceptionGroup, which is what a TaskGroup raises when several children fail at once.
  • The limit should be derived, not guessed: their capacity divided by the number of instances of you.
  • Order out matches order in. gather preserves position, so you can zip results back to inputs; as_completed does not.
L3 · Advanced

What bites in production

  • Cancellation is cooperative. A cancelled task resumes with CancelledError at its next await — if it is blocked in C code it will not stop at all.
  • Never swallow CancelledError. except Exception will not catch it — it inherits from BaseException, deliberately. What breaks shutdown is except BaseException or a bare except: that catches it and carries on.
  • Fire-and-forget tasks get garbage collected. create_task without keeping a reference can have the task vanish mid-flight; hold it in a set and discard on completion.
  • Two hundred coroutines is memory too — each holds its own frame and its own response buffer. Bounded concurrency bounds peak memory as well.
  • The real limit is often theirs, expressed as 429. Read the Retry-After header rather than guessing your own backoff.
Python · 29

A file that does not fit in memory

The job: load an 8 GB compressed export into the database. The version that works and the version that gets OOM-killed differ by one word — list — and the generator topic earlier is the reason.

RESIDENT MEMORY, SAME 8 GB FILE rows = list(csv.DictReader(fh)) container limit, 2 GB OOMKilled, 40% through for batch in batched(valid(rows(p)), 1000): container limit, 2 GB Flat at about 30 MB — one batch, whatever the file size. WHAT EACH STAGE HOLDS rows() one line valid() one Order batched(1000) 1000 — the only knob executemany one round trip Counter one small dict Nothing in the chain ever holds more than one batch, so the size of the input stops being a variable your service has to survive.
The knob is the batch size, and it is the only one. Everything upstream of it is lazy, so peak memory is a number you chose rather than a number the input chose for you.
# each stage is lazy; nothing is read until the for loop pulls
def rows(path: str) -> Iterator[dict[str, str]]:
    with gzip.open(path, "rt", newline="") as fh:      # decompresses as it reads
        yield from csv.DictReader(fh)

def valid(raw: Iterable[dict[str, str]]) -> Iterator[Order]:
    for i, r in enumerate(raw, start=2):               # line 1 is the header
        try:
            yield Order.model_validate(r)              # pydantic, at the edge again
        except ValidationError as e:
            log.warning("skipped row", line=i, errors=e.error_count())
# the driver: one transaction per batch, so a failure loses 1000 rows, not 8 GB
from itertools import batched            # 3.12+; recipe in the docs before that

totals: Counter[str] = Counter()
for n, batch in enumerate(batched(valid(rows("orders.csv.gz")), 1000), start=1):
    with session.begin():
        session.execute(insert(OrderRow), [o.model_dump() for o in batch])
    totals.update(o.currency for o in batch)
    if n % 100 == 0:
        log.info("progress", batches=n, rows=n * 1000, totals=dict(totals))
L1 · Beginner

What the code does

  • yield makes the function lazy — calling rows(path) reads nothing; the for loop at the end pulls one row at a time through the whole chain.
  • yield from forwards every item of an inner iterable without a loop.
  • with gzip.open(...) decompresses on the fly; the file is never on disk uncompressed and never fully in memory.
  • batched turns a stream of items into a stream of tuples of n.
  • Counter.update accumulates counts without building an intermediate list.
L2 · Intermediate

Why it is written this way

  • Each stage does one thing, so each can be tested with a three-item list instead of an 8 GB file.
  • Bad rows are skipped with the line number, not silently dropped and not fatal — an import that dies on row 4 million is an import you run four times.
  • One transaction per batch bounds both the lock duration and how much work a crash costs.
  • Batch inserts, not row inserts. A thousand single INSERTs is a thousand round trips; the batch is one.
  • Log progress by batch count. A silent job that runs for an hour is indistinguishable from a hung one.
L3 · Advanced

What bites in production

  • A generator holds its resources open. The with inside rows only closes when the generator is exhausted or garbage collected — break out early and the file handle lingers.
  • Restartability beats speed. Record the last committed batch so a rerun resumes rather than starting again; with a unique key the insert can be an upsert and the whole job idempotent.
  • The database is usually the bottleneck, not the parse. Measure before reaching for multiprocessing — then COPY beats every Python-side optimisation by an order of magnitude.
  • Generators and exceptions interact badly under concurrency: a generator is not thread-safe, and sharing one across workers corrupts its state silently.
  • Watch fragmentation, not just peak. A long-running loop can hold flat RSS while the allocator never returns freed arenas to the OS.
Part ten

Production Python

A dynamic language moves a class of errors from compile time to run time, which is where tests, logs and configuration validation have to catch them instead. None of this is Python-specific in principle; all of it is Python-specific in the details.

Python · 30

Tests that catch what Python actually gets wrong

There is no compiler to tell you that a name is misspelt or an argument is the wrong type on the path nobody ran. Tests are the type checker for the paths you actually care about — and pytest’s mechanics decide how much they cost to keep.

ONE PYTEST RUN — 6 TESTS IN 2 FILES scope="session" postgres container: built once, torn down last scope="module" test_orders.py: schema loaded test_billing.py: schema loaded scope="function" tmp_path tmp_path tmp_path tmp_path tmp_path tmp_path the test body test 1 test 2 test 3 test 4 test 5 test 6 Setup runs top down, teardown bottom up. Scope is the only thing deciding how often the expensive line runs. ONE BODY, MANY CASES @parametrize("raw, want", […]) def test_parse(raw, want): assert parse(raw) == want raw="3" want=3 raw=" 3 " want=3 raw="x" want=None pass pass fail — and the report names the case monkeypatch.setattr(mod, "now", …) injected here, undone at teardown Each case reports under its own name — test_parse[padded] — so a failure tells you which input broke before you open the file.
Scope is the dial between speed and isolation. A session-scoped database is fast and lets one test leak state into the next; a function-scoped one is slow and immaculate. The usual answer is a session-scoped container with a function-scoped transaction rolled back at teardown — expensive setup once, clean state every time.
import pytest
from app.orders import Order, parse_qty

@pytest.fixture(scope="module")
def store(tmp_path_factory):
    path = tmp_path_factory.mktemp("data") / "orders.db"
    s = Store(path)          # setup: once per test file
    yield s                  # the tests in this module run here
    s.close()                # teardown: runs even if a test fails

@pytest.mark.parametrize(
    "raw, want",
    [("3", 3), (" 3 ", 3), ("03", 3), ("", None), ("x", None)],
    ids=["plain", "padded", "leading-zero", "empty", "not-a-number"],
)
def test_parse_qty(raw, want):
    assert parse_qty(raw) == want

def test_rejects_negative_quantity(store, monkeypatch):
    monkeypatch.setattr("app.orders.now", lambda: FIXED_TIME)
    with pytest.raises(ValueError, match="quantity"):
        store.add(Order(qty=-1))
L1 · Beginner

pytest, and one behaviour per test

  • pytest finds test_*.py and functions named test_*. No class, no base class, no registration.
  • A plain assert is enough — pytest rewrites it so a failure shows both sides, which is why you never need assertEqual.
  • Arrange, act, assert, in that order, with a blank line between the three.
  • One behaviour per test, named for the behaviour: test_rejects_negative_quantity, not test_order_2.
  • Test the public function, not the private helper. The helper must stay free to change.
L2 · Intermediate

Fixtures, parameters, and the seams

  • A fixture is setup and teardown around a yield; naming it as an argument is the entire wiring mechanism. Shared ones live in conftest.py, imported by nobody.
  • scope decides how often it runs: function (the default), class, module, session. Session for a container or a pool; function for anything mutable.
  • parametrize with ids fans one body into many named cases, each reported separately, so the failure names the input.
  • monkeypatch for environment variables, attributes and the working directory — reverted automatically, unlike mock.patch applied by hand.
  • tmp_path for files, pytest.raises(ValueError, match=…) for errors, and markers plus -m "not slow" to keep the fast set fast.
L3 · Advanced

Tests that survive a refactor

  • Fakes over mocks. A small in-memory implementation of your own interface tests behaviour; assert_called_once_with tests today’s call graph and breaks on every refactor that found no bug.
  • Patch only at seams you own. mock.patch("app.module._helper") welds the test to an implementation detail — passing the collaborator in does not.
  • Time is an input. Inject a clock (now: Callable[[], datetime]) or use freezegun; a time.sleep in a test is a slow flake waiting for a loaded CI runner.
  • Property-based testing with Hypothesis: state the invariant — parse(format(x)) == x — and let it find the empty string, the surrogate pair and the boundary you would never have typed.
  • Coverage is a signal, not a target: it shows what never ran. A suite with no assertions can reach 100%. Mutation testing (mutmut) asks the sharper question — if I break the code, does anything fail?
Python · 31

Logging, configuration, and shipping it

Three decisions decide whether a running service can be debugged: what the logs carry, where the configuration comes from, and what exactly is inside the image. All three are made once, at start-up, in main.

ONE REQUEST STDOUT client edge / gateway handler database mints request_id a ContextVar carries it and so does the outbound header {"lvl":"info","req":"7f3a1c", "msg":"handling","route":"/pay"} {"lvl":"error","req":"7f3a1c", "msg":"charge failed","ms":812} one object per line, same req The correlation id is the join key — it turns a pile of independent lines into one story, and it is the same id the trace carries. CONFIGURATION ARRIVES FROM THE ENVIRONMENT DATABASE_URL=… LOG_LEVEL=info TIMEOUT_S=2.0 Config.from_env() parsed and validated once, at start-up valid → bind the port and serve missing or unparseable → exit 1, before the first request WHAT ACTUALLY SHIPS pyproject + uv.lock uv sync --frozen slim image, by digest There is no static binary: the interpreter and every wheel are part of the artefact.
Each band removes one class of 3am question. The correlation id answers “what else happened during that request”; start-up validation turns a typo in a Helm value into a failed deploy rather than a 500 an hour later; and the lockfile means the thing that worked in CI is byte-for-byte the thing that is running.
# any library module: take a logger, never configure one
log = logging.getLogger(__name__)

def charge(order_id: str) -> None:
    log.info("charging", extra={"order_id": order_id, "req": request_id.get()})

# main.py: configure once, fail fast, drain on SIGTERM
def main() -> None:
    cfg = Config.from_env()          # SystemExit now beats a 500 later
    logging.basicConfig(level=cfg.log_level, handlers=[JsonHandler(sys.stdout)])
    server = make_server(cfg)

    stopping = threading.Event()

    def on_sigterm(signum, frame) -> None:
        ready.clear()                # 1. readiness starts failing
        stopping.set()               # 2. flip a flag -- a handler must never block

    signal.signal(signal.SIGTERM, on_sigterm)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    stopping.wait()                  # main thread parks here until SIGTERM
    time.sleep(5)                    # 3. keep serving while the LB notices
    server.shutdown()                # 4. stop accepting, drain in flight
L1 · Beginner

Logging and configuration, plainly

  • logging, not print: levels, a timestamp, the module name, and a destination the operator chooses rather than you.
  • The levels mean something — DEBUG for you, INFO for lifecycle events, WARNING for something a human may need to look at, ERROR for a request that failed, CRITICAL for the process.
  • Configure exactly once, in main, at start-up. A library that calls basicConfig has stolen a decision that belongs to the application.
  • In every module: log = logging.getLogger(__name__). The dotted name gives per-package level control for free.
  • log.exception("…") inside an except records the traceback; log.error(str(e)) throws it away.
L2 · Intermediate

Structured logs, env config, a locked image

  • One JSON object per line, with fields — request_id, order_id, duration_ms — not an English sentence you will later have to write a regex against.
  • A correlation id minted at the edge, kept in a ContextVar so every record and every outbound header carries it. This is where these logs join the traces from the cloud page.
  • Config from environment variables, parsed into one frozen object and validated at start-up. A missing setting should kill the process in the first second, not the first request.
  • A committed lockfile and uv sync --frozen (or pip install -r a compiled requirements file) so CI and production resolve to identical versions.
  • A slim base image, dependencies in their own layer, non-root user. python -X dev turns on extra runtime checks in staging; python -O strips asserts, which is why an assert must never be your validation.
L3 · Advanced

What running it actually costs

  • Log volume is money and cardinality. One INFO line per request at 5,000 req/s is over 400 million lines a day: sample the routine ones, keep every error, and never put a user id or a full URL in a metric label.
  • SIGTERM, in the Go page’s order: fail readiness, wait for the load balancer to notice, stop accepting, drain in-flight work, close pools, exit. Python’s default on SIGTERM is to die immediately, mid-request.
  • Liveness and readiness are different questions — “should you kill me” versus “should you send me traffic”. A liveness probe that checks the database restarts your app whenever the database is slow.
  • Worker models: gunicorn with N sync workers is one process per core, no shared memory, and the safe default for blocking code; uvicorn/ASGI workers give I/O concurrency inside each process. Threads help only where the work releases the GIL.
  • A Python process is not a static binary. The interpreter, the C extensions and every wheel are part of what you ship, so the image — pinned by digest — is your dependency contract. There is nothing else to hand over.
Python · 32

The Python review checklist

Fifteen things worth a second look in any Python diff. Each takes about five seconds to check and can cost an afternoon to debug later — and roughly half of them a linter will find before you do.

CORRECTNESS No mutable default argument — use None. is compares identity, == compares value: only None, True and False deserve is. No bare except: catch what you handle. Nothing shadows a builtin (list, id, type). No list or dict mutated while iterating it. RESOURCES AND ERRORS with for every file, socket, lock, session. No exception swallowed: handle or re-raise. Wrapped with raise X from e, so the original cause survives the retag. Concurrency bounded by a pool or a semaphore, not one task per item. Every network call has a timeout. CLARITY AND TYPES Public functions annotated, and the annotations checked in CI. Records are dataclasses, not bare dicts. No Any at a module boundary. __repr__ on every domain type. Module scope holds definitions, not work: no I/O at import time. About half of this list is a linter’s job. Put ruff and mypy in CI and spend the review on the half no tool can check.
Read it as three questions, not fifteen rules. Does this do what it says on the inputs nobody tried; does every resource and every failure have an owner; and will someone who has never seen this module be able to tell what a function takes and returns without running it.

The two questions behind all fifteen

“Who owns this object, and when is it released?” — which covers files, sockets, pools, caches, tasks and the references that keep them all alive. And “what does this do when the thing it calls never answers?” — the same question the Go page ends on, and the one that decides whether a slow dependency becomes a slow endpoint or a dead service. Every item above is one of those two, asked about a specific line.

# the sixty seconds before you open the pull request
uv run ruff format . && uv run ruff check --fix .   # half the list, automatically
uv run mypy .
uv run pytest -q -x --ff              # --ff: last run's failures first
uv run pytest --cov --cov-report=term-missing | tail -20

# then read your own diff — these three greps catch what the tools do not:
git diff --cached | grep -nE 'print\(|breakpoint\(|TODO|XXX'
git diff --cached | grep -nE 'except (Exception|BaseException):\s*$'
git diff --cached | grep -nE 'def \w+\([^)]*=\s*(\[\]|\{\}|set\(\))'
Part eleven

A whole service, file by file

Everything before this was a mechanism or a snippet. This is the repository — one small service, built in the order you would actually build it, with every file that has to exist before anyone will let you deploy it. The domain is a link shortener, chosen because it is small enough to hold in your head and still needs every production concern there is: validation, a database and a migration, idempotency, authentication, rate limiting, structured logs, health checks, tests that run in CI, and an image that starts as a non-root user. Read it top to bottom once; after that each topic stands alone.

Python · 33

Where the files go, and why

Layout is the first architectural decision and the one nobody revisits. Two choices matter here and both have a real reason: the code lives under src/ so the tests cannot accidentally import the working copy instead of the installed package, and the modules are layered so that the direction of every import is a rule you can check rather than a habit you hope holds.

shortlink/
  pyproject.toml          project metadata, dependencies, and every tool's config
  uv.lock                 the resolved, hashed dependency graph. Committed.
  .env.example            every variable the app reads, with safe placeholders
  compose.yaml            Postgres and Redis for local work, nothing else
  Dockerfile              multi-stage; the runtime stage has no build tools
  Makefile                the four commands everyone needs, named the same way

  src/
    shortlink/
      __init__.py
      main.py             the app factory and its lifespan. No business logic.
      config.py           settings, read once from the environment
      domain.py           the rules. Imports nothing from this package.
      schemas.py          the wire contract: what the API accepts and returns
      errors.py           one exception hierarchy, one handler
      db.py               engine, session factory, the session dependency
      models.py           SQLAlchemy tables
      repository.py       the only module that writes SQL
      service.py          use cases. Knows the domain, not HTTP.
      api.py              routers, status codes, dependencies
      security.py         API keys and the rate limiter
      observability.py    logging, tracing, metrics, health

  migrations/             Alembic: env.py plus one file per schema change
    versions/
      0001_create_links.py

  tests/
    conftest.py           fixtures: a real database, an in-process client
    test_domain.py        pure, fast, no I/O
    test_api.py           through the app, against a real Postgres
    test_errors.py        the failure contract, which is also an API

  .github/workflows/ci.yaml
LayerMay importMust never import
domain.pyThe standard library, and nothing elseFastAPI, SQLAlchemy, config. It is the part you can test in a millisecond.
schemas.pyPydantic, domainSQLAlchemy. The wire contract and the table are different shapes and change for different reasons.
repository.pymodels, domain, SQLAlchemyFastAPI. A repository that raises HTTPException cannot be used by a worker.
service.pyrepository, domainFastAPI, and the Request object in particular.
api.pyEverythingNothing — but it should contain no branching that is not about HTTP.

That table is the whole architecture. It is also enforceable: a single ruff rule fails the build when an inner layer reaches outward, which is worth more than any diagram, because diagrams do not fail pull requests.

# pyproject.toml -- one file for the package, the dependencies and every tool.
[project]
name = "shortlink"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115",
    "pydantic>=2.9",
    "pydantic-settings>=2.5",
    "sqlalchemy[asyncio]>=2.0.35",
    "asyncpg>=0.30",
    "alembic>=1.13",
    "redis>=5.1",
    "structlog>=24.4",
    "uvicorn[standard]>=0.31",
]

[dependency-groups]                 # PEP 735: dev deps that are NOT shipped
dev = [
    "pytest>=8.3",
    "pytest-asyncio>=0.24",
    "httpx>=0.27",
    "testcontainers[postgres]>=4.8",
    "mypy>=1.11",
    "ruff>=0.6",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
line-length = 100
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "S", "ASYNC", "TID"]
# S101 is "assert used" -- the point of a test file.
per-file-ignores = { "tests/*" = ["S101"] }

[tool.ruff.lint.flake8-tidy-imports.banned-api]
# The layering rule from the table above, enforced. Inner layers import
# outward over somebody's dead body, and now over a failing build.
"fastapi".msg = "domain/, repository/ and service/ must not import FastAPI"

[tool.mypy]
strict = true
files = ["src", "tests"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "-q --strict-markers"
# Makefile -- four verbs, the same four in every repository you own.
# The value is not the shortening; it is that CI and a new joiner run
# identical commands, so "works on my machine" has one less place to hide.
.PHONY: install fmt check test run

install:
	uv sync --all-groups          # exact versions from uv.lock, dev included

fmt:
	uv run ruff format .
	uv run ruff check --fix .

check:                            # what CI runs, runnable locally
	uv run ruff format --check .
	uv run ruff check .
	uv run mypy

test:
	uv run pytest

run:
	uv run uvicorn shortlink.main:app --reload
L1 · Beginner

The defaults to copy

  • Use the src/ layout. Without it, import shortlink in a test finds the source directory, and you never test the thing you package.
  • Commit the lockfile. uv.lock is what makes the build reproducible; pyproject.toml only records intent.
  • One file per responsibility, named for the responsibility. utils.py is where design goes to die.
  • Keep __init__.py empty. Import side effects are the hardest bugs on this page.
L2 · Intermediate

Make the boundary real

  • Layer by dependency direction, not by file type. “Everything that talks to the database” is a layer; “all the classes” is not.
  • Enforce it in the linter. A rule that fails the build beats a convention that survives until the first deadline.
  • Split by feature when the layers get long. At roughly a dozen tables, links/ and accounts/ packages each with their own layers read better than one flat set.
  • Put tooling config in pyproject.toml. Five dotfiles is five places to disagree.
L3 · Advanced

What this buys later

  • The domain stays testable at speed. If test_domain.py ever needs a fixture, something has leaked inward.
  • The service becomes reusable. The same service.py backs the HTTP API, a CLI, and a queue consumer, because none of it knows what a request is.
  • Swapping the framework becomes a day, not a quarter — the FastAPI-shaped part is one file.
  • Dependency groups keep pytest and mypy out of the runtime image, which is both smaller and a smaller attack surface.
Python · 34

Configuration, read once, validated at boot

Configuration is the first thing that kills a deployment, and it does it in the worst possible way: at three in the morning, on the one code path nobody exercised. The fix is to read every setting once at start-up, give each one a type, and refuse to start when something is missing — a container that fails to boot is a rollback, and a container that boots and then fails on the tenth request is an incident.

# src/shortlink/config.py
from functools import lru_cache
from typing import Literal

from pydantic import Field, PostgresDsn, RedisDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",              # local convenience only; absent in production
        env_prefix="SHORTLINK_",      # so a stray DATABASE_URL cannot leak in
        extra="forbid",               # a typo in a variable name fails the boot
    )

    environment: Literal["local", "staging", "production"] = "local"
    # No default. A missing DSN must stop the process, not silently point at
    # localhost -- which is exactly how a staging deploy ends up writing to a
    # database that does not exist and passing its own health check.
    database_url: PostgresDsn
    redis_url: RedisDsn

    base_url: str = "http://localhost:8000"
    db_pool_size: int = Field(default=10, ge=1, le=100)
    rate_limit_per_minute: int = Field(default=60, ge=1)
    log_level: str = "INFO"

    @field_validator("base_url")
    @classmethod
    def _no_trailing_slash(cls, v: str) -> str:
        # Every short link is base_url + "/" + code. One trailing slash here
        # produces a double slash in every URL the service has ever issued.
        return v.rstrip("/")

    @property
    def debug(self) -> bool:
        return self.environment == "local"


@lru_cache
def get_settings() -> Settings:
    """Read the environment exactly once per process.

    Cached because settings are immutable for the life of the process, and
    because FastAPI will call this as a dependency on every request.
    """
    return Settings()  # type: ignore[call-arg]  # values come from the environment
# .env.example -- committed. The real .env is not, and .gitignore says so.
#
# This file is documentation that cannot go stale: `extra = "forbid"` means a
# variable the app does not know about stops the boot, and a missing one
# without a default does too. So the set of names here is the set it reads.

SHORTLINK_ENVIRONMENT=local
SHORTLINK_DATABASE_URL=postgresql+asyncpg://shortlink:shortlink@localhost:5432/shortlink
SHORTLINK_REDIS_URL=redis://localhost:6379/0
SHORTLINK_BASE_URL=http://localhost:8000
SHORTLINK_LOG_LEVEL=DEBUG

Secrets are not configuration

The pattern above is right for a database URL and wrong for a signing key. Anything secret should arrive from the platform’s secret store — a mounted file, a sidecar, an injected environment variable the deployment system owns — and never have a default, never be logged, and never appear in .env.example with a real-looking value. Two habits that cost nothing: keep secrets as pydantic.SecretStr so an accidental repr() of the settings object prints **********, and assert at boot that a production environment did not pick up a development default.

Python · 35

The core, which knows nothing about the web

This is the part worth protecting. It holds the rules — what a valid destination is, how a short code is generated, when a link has expired — and it imports nothing from the rest of the package. That constraint is not purity for its own sake: it is what makes these tests run in a millisecond with no fixtures, and it is what lets the same rules back an HTTP API, a batch import and a queue consumer without being rewritten.

# src/shortlink/domain.py -- the standard library, and nothing else.
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime

# Base62 without look-alikes. Removing 0/O and 1/l/I costs four characters of
# alphabet and saves a support ticket every time somebody reads a code aloud.
ALPHABET = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"
CODE_LENGTH = 7
BLOCKED_SCHEMES = frozenset({"javascript", "data", "file", "vbscript"})


class DomainError(Exception):
    """Something the caller did. Never a 500."""


class InvalidDestination(DomainError):
    pass


@dataclass(frozen=True, slots=True)
class Link:
    code: str
    destination: str
    owner_id: str
    created_at: datetime
    expires_at: datetime | None = None

    def is_expired(self, now: datetime | None = None) -> bool:
        if self.expires_at is None:
            return False
        # Always compare aware datetimes in UTC. A naive datetime here is the
        # bug that appears twice a year and is blamed on the database.
        return (now or datetime.now(UTC)) >= self.expires_at


def new_code(length: int = CODE_LENGTH) -> str:
    """A short code with ~41 bits of entropy at the default length.

    secrets, not random: these are guessable identifiers for other people's
    content. The collision is handled by a unique index, not by checking
    first -- see repository.py, where the race is impossible rather than
    unlikely.
    """
    return "".join(secrets.choice(ALPHABET) for _ in range(length))


def validate_destination(raw: str) -> str:
    """Normalise and reject. Returns the URL to store, or raises."""
    url = raw.strip()
    scheme, _, rest = url.partition("://")
    if not rest:
        raise InvalidDestination("destination must be an absolute URL")
    if scheme.lower() in BLOCKED_SCHEMES:
        # An open redirector that will emit javascript: is a stored XSS
        # delivery service with your domain's reputation attached.
        raise InvalidDestination(f"scheme {scheme!r} is not allowed")
    if scheme.lower() not in {"http", "https"}:
        raise InvalidDestination("only http and https destinations are allowed")
    if len(url) > 2048:
        raise InvalidDestination("destination is too long")
    return url
# src/shortlink/schemas.py -- the wire contract. Deliberately not the table.
from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field

from shortlink.domain import Link


class CreateLinkRequest(BaseModel):
    # forbid: a client sending {"destinaton": ...} gets a 422 naming the
    # field, instead of a link quietly pointing nowhere.
    model_config = ConfigDict(extra="forbid")

    destination: str = Field(min_length=1, max_length=2048)
    expires_at: datetime | None = None


class LinkResponse(BaseModel):
    code: str
    short_url: str
    destination: str
    created_at: datetime
    expires_at: datetime | None

    @classmethod
    def of(cls, link: Link, base_url: str) -> "LinkResponse":
        # The mapping lives here, in one place. Returning the ORM object
        # directly is how a password hash ends up in a JSON response the
        # first time somebody adds a column.
        return cls(
            code=link.code,
            short_url=f"{base_url}/{link.code}",
            destination=link.destination,
            created_at=link.created_at,
            expires_at=link.expires_at,
        )
L1 · Beginner

Two models, not one

  • The request model is not the table. They change for different reasons: one when the API changes, one when the schema does.
  • Never return the ORM object. Every column you add later becomes public the day you add it.
  • Set extra="forbid" on input. Silent field-name typos are the cheapest bug class to eliminate.
  • Validate at the edge, once, and trust the object afterwards.
L2 · Intermediate

Where the rules live

  • Pydantic validates shape; the domain validates meaning. “Is a string under 2048 characters” is shape. “Is not a javascript: URL” is meaning, and belongs where a CLI can reach it too.
  • Use frozen=True and slots=True on domain objects: immutable by default, and measurably smaller.
  • Aware datetimes, always UTC. Convert at the edge, never in the middle.
  • secrets, not random, for anything another person could try to guess.
L3 · Advanced

The parts that bite later

  • Entropy is a product decision. Seven base-58 characters is about 41 bits; at ten million links the chance any two collide is small but the chance one is guessed by a scanner is not zero. Size it against what the links protect.
  • Open redirects are a real vulnerability class, not a theoretical one — they are used to launder phishing links through domains people trust. The allow-list above is the minimum.
  • Normalising is a decision you cannot take back: once you store the normalised form, the original is gone. Keep both if anyone might audit it.
  • Keep the domain importable with no environment set. If it needs settings, it is not the domain.
Python · 36

Persistence: engine, session, and the first migration

Three separate things get confused here constantly. The engine is a connection pool and lives for the life of the process. The session is a unit of work and lives for the life of one request. The migration is a versioned, reviewed change to the schema that runs before the new code does. Getting the lifetimes right is most of what makes a service survive load.

# src/shortlink/models.py -- SQLAlchemy 2.0 typed declarative mapping.
from datetime import datetime

from sqlalchemy import DateTime, Index, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class LinkRow(Base):
    __tablename__ = "links"

    # The short code is the natural key and the only thing ever looked up by,
    # so it is the primary key. A surrogate id here would buy an index nobody
    # queries and a join nobody needs.
    code: Mapped[str] = mapped_column(String(16), primary_key=True)
    destination: Mapped[str] = mapped_column(String(2048), nullable=False)
    owner_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)

    # server_default, not default: the database clock is the one clock every
    # writer agrees on, including a migration or a manual INSERT.
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

    # Idempotency: the same key from the same owner must return the first
    # link, not create a second. Enforced by the database, because two
    # concurrent requests will pass any check you write in Python.
    idempotency_key: Mapped[str | None] = mapped_column(String(64))

    __table_args__ = (
        Index("uq_links_owner_idem", "owner_id", "idempotency_key", unique=True,
              postgresql_where=idempotency_key.isnot(None)),
    )


class ApiKeyRow(Base):
    __tablename__ = "api_keys"

    id: Mapped[int] = mapped_column(primary_key=True)
    # The sha256 of the key, never the key. Unique and indexed, because every
    # authenticated request looks a caller up by exactly this value.
    key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
    owner_id: Mapped[str] = mapped_column(String(64), nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    # Revoke by setting a timestamp rather than deleting the row: you will
    # want to know which key was used after somebody turns one off.
    revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# src/shortlink/db.py -- one engine per process, one session per request.
from collections.abc import AsyncIterator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from shortlink.config import get_settings

settings = get_settings()

engine = create_async_engine(
    str(settings.database_url),
    pool_size=settings.db_pool_size,
    max_overflow=0,          # a hard ceiling: better to queue than to exhaust Postgres
    pool_pre_ping=True,      # one cheap round trip beats a stale-connection 500
    pool_recycle=1800,       # under most proxies, connections do not live forever
)

# expire_on_commit=False: without it, touching any attribute after commit
# triggers a fresh SELECT -- inside a response serialiser, on a closed
# session, which is the classic MissingGreenlet at the worst moment.
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)


async def get_session() -> AsyncIterator[AsyncSession]:
    """The transaction boundary, and the only place commit is called.

    One request, one transaction. Anything raised below -- a domain error, a
    bug, a cancelled request -- unwinds through here and rolls back, so a
    handler can never leave half a write behind.
    """
    async with SessionFactory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
# migrations/versions/0001_create_links.py
"""create links

Revision ID: 0001
"""
import sqlalchemy as sa
from alembic import op

revision = "0001"
down_revision = None


def upgrade() -> None:
    op.create_table(
        "links",
        sa.Column("code", sa.String(16), primary_key=True),
        sa.Column("destination", sa.String(2048), nullable=False),
        sa.Column("owner_id", sa.String(64), nullable=False),
        sa.Column("created_at", sa.DateTime(timezone=True),
                  server_default=sa.func.now(), nullable=False),
        sa.Column("expires_at", sa.DateTime(timezone=True)),
        sa.Column("idempotency_key", sa.String(64)),
    )
    op.create_index("ix_links_owner_id", "links", ["owner_id"])
    op.create_index(
        "uq_links_owner_idem", "links", ["owner_id", "idempotency_key"],
        unique=True, postgresql_where=sa.text("idempotency_key IS NOT NULL"),
    )


def downgrade() -> None:
    op.drop_table("links")

# Two rules this file obeys, and both are learned the hard way:
#
#   Migrations are forward-only in production. `downgrade` exists so the
#   test suite can round-trip; nobody runs it against real data at 3am.
#
#   Every migration must be safe to run while the OLD code is still serving.
#   Deploys are not atomic. Adding a NOT NULL column with no default takes an
#   exclusive lock and rejects every insert the running version makes -- so
#   it is three deploys: add nullable, backfill, then enforce.

Why expire_on_commit=False is not an optimisation

By default SQLAlchemy marks every attribute stale after a commit, so the next attribute access re-reads it from the database. In a synchronous script that is a mild waste. In an async request handler it is a crash: the commit happens in the dependency as the response is being built, so the refresh is attempted on a session that is closing, from a thread with no greenlet context, and you get MissingGreenlet pointing at a line that looks completely innocent. Turn it off, and convert ORM rows to domain objects or response models before the transaction ends — which the repository below does anyway.

Python · 37

The repository, and the race you cannot check for

One module writes SQL, and it returns domain objects rather than ORM rows so nothing above it is coupled to the table. It also contains the single most important idea in this whole part: where two requests can collide, do not check and then act — ask the database to enforce it and handle the refusal. A SELECT followed by an INSERT is a race with a comfortable-looking test suite.

# src/shortlink/repository.py -- the only module that knows SQL exists.
from datetime import datetime

from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from shortlink.domain import Link
from shortlink.models import LinkRow


class CodeCollision(Exception):
    """The generated code was already taken. Caller retries with a new one."""


def _to_domain(row: LinkRow) -> Link:
    return Link(
        code=row.code,
        destination=row.destination,
        owner_id=row.owner_id,
        created_at=row.created_at,
        expires_at=row.expires_at,
    )


class LinkRepository:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def get(self, code: str) -> Link | None:
        row = await self._session.get(LinkRow, code)
        return _to_domain(row) if row else None

    async def find_by_idempotency_key(self, owner_id: str, key: str) -> Link | None:
        stmt = select(LinkRow).where(
            LinkRow.owner_id == owner_id, LinkRow.idempotency_key == key
        )
        row = (await self._session.execute(stmt)).scalar_one_or_none()
        return _to_domain(row) if row else None

    async def add(
        self,
        code: str,
        destination: str,
        owner_id: str,
        expires_at: datetime | None,
        idempotency_key: str | None,
    ) -> Link:
        """Insert, or raise CodeCollision if that code is taken.

        This is INSERT ... ON CONFLICT DO NOTHING on the idempotency index,
        which makes a repeated request a no-op at the database rather than a
        second link. If nothing came back, either the code collided or the
        request is a retry -- the caller can tell them apart, and only one of
        them is worth retrying.
        """
        stmt = (
            insert(LinkRow)
            .values(
                code=code,
                destination=destination,
                owner_id=owner_id,
                expires_at=expires_at,
                idempotency_key=idempotency_key,
            )
            .on_conflict_do_nothing(index_elements=["owner_id", "idempotency_key"])
            .returning(LinkRow)
        )
        try:
            row = (await self._session.execute(stmt)).scalar_one_or_none()
        except IntegrityError as exc:
            # The other unique constraint: the primary key. Two writers drew
            # the same random code, which is rare and entirely expected.
            raise CodeCollision(code) from exc

        if row is None:
            # ON CONFLICT swallowed it: this owner already used this key.
            existing = await self.find_by_idempotency_key(owner_id, idempotency_key or "")
            if existing is None:  # pragma: no cover -- only under manual deletion
                raise CodeCollision(code)
            return existing
        return _to_domain(row)
# src/shortlink/service.py -- the use cases. No Request, no HTTPException.
from datetime import UTC, datetime

from shortlink import domain
from shortlink.domain import DomainError, Link
from shortlink.repository import CodeCollision, LinkRepository

MAX_CODE_ATTEMPTS = 5


class LinkNotFound(DomainError):
    pass


class LinkExpired(DomainError):
    pass


class LinkService:
    def __init__(self, repo: LinkRepository) -> None:
        self._repo = repo

    async def create(
        self,
        destination: str,
        owner_id: str,
        expires_at: datetime | None = None,
        idempotency_key: str | None = None,
    ) -> Link:
        clean = domain.validate_destination(destination)

        # Bounded retry on collision. Unbounded would turn a misconfigured
        # short code length into an infinite loop holding a transaction open.
        for _ in range(MAX_CODE_ATTEMPTS):
            try:
                return await self._repo.add(
                    code=domain.new_code(),
                    destination=clean,
                    owner_id=owner_id,
                    expires_at=expires_at,
                    idempotency_key=idempotency_key,
                )
            except CodeCollision:
                continue
        raise RuntimeError("could not allocate a unique code")  # a real 500: page someone

    async def resolve(self, code: str) -> Link:
        link = await self._repo.get(code)
        if link is None:
            raise LinkNotFound(code)
        if link.is_expired(datetime.now(UTC)):
            # Distinct from "not found" on purpose: the caller gets 410 Gone,
            # which tells a crawler to stop asking. 404 does not.
            raise LinkExpired(code)
        return link
L1 · Beginner

Keep SQL in one place

  • One module writes queries. When a query is slow, there is one file to open.
  • Return domain objects, not ORM rows — then nothing above cares which ORM you used.
  • Never build SQL with f-strings. Parameters are not about tidiness; they are the injection boundary.
  • The service raises domain errors. Translating those to status codes is the API layer's job.
L2 · Intermediate

Let the database enforce it

  • Check-then-act is a race. SELECT ... IF NOT EXISTS ... INSERT passes every test and fails under concurrency.
  • Idempotency is a unique index plus a key from the client. It is the difference between a retry and a duplicate charge.
  • Bound every retry loop, and make the exhausted case loud.
  • Distinguish 404 from 410. It costs one exception class and it changes crawler behaviour.
L3 · Advanced

Transactions and the N+1

  • The repository does not commit. Only the request-scoped dependency does, so one request is one atomic unit however many repositories it touches.
  • Watch for lazy loads after commit. Converting to a domain object inside the transaction is what makes that impossible rather than unlikely.
  • Partial unique indexes let idempotency keys be optional without making NULL collide with itself — Postgres treats every NULL as distinct.
  • If the collision retry ever fires measurably, the code space is too small. Alert on it rather than discovering it in a bug report.
Python · 38

The HTTP edge, and the app that wires it

This layer should be boring, and if it is not, something below it is missing. Its whole job is to turn a request into a call and a result into a status code — no business rules, no SQL, no branching that is not about HTTP. The interesting part is the app factory: what gets created once at start-up and torn down cleanly, which is what lifespan exists for.

# src/shortlink/api.py -- routers, dependencies, status codes. Nothing else.
from typing import Annotated

from fastapi import APIRouter, Depends, Header, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession

from shortlink.config import Settings, get_settings
from shortlink.db import get_session
from shortlink.repository import LinkRepository
from shortlink.schemas import CreateLinkRequest, LinkResponse
from shortlink.security import ApiKey, require_api_key, rate_limit
from shortlink.service import LinkService

router = APIRouter()


def get_service(session: Annotated[AsyncSession, Depends(get_session)]) -> LinkService:
    return LinkService(LinkRepository(session))


ServiceDep = Annotated[LinkService, Depends(get_service)]
SettingsDep = Annotated[Settings, Depends(get_settings)]


@router.post(
    "/links",
    response_model=LinkResponse,
    status_code=status.HTTP_201_CREATED,
    dependencies=[Depends(rate_limit)],
    summary="Create a short link",
)
async def create_link(
    body: CreateLinkRequest,
    service: ServiceDep,
    settings: SettingsDep,
    caller: Annotated[ApiKey, Depends(require_api_key)],
    response: Response,
    idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> LinkResponse:
    link = await service.create(
        destination=body.destination,
        owner_id=caller.owner_id,
        expires_at=body.expires_at,
        idempotency_key=idempotency_key,
    )
    # 201 without Location is a 201 the client has to guess about.
    response.headers["Location"] = f"{settings.base_url}/{link.code}"
    return LinkResponse.of(link, settings.base_url)


@router.get("/{code}", include_in_schema=False)
async def follow(code: str, service: ServiceDep) -> RedirectResponse:
    link = await service.resolve(code)
    # 307, not 302: 307 preserves the method, and a permanent redirect would
    # be cached by every proxy on earth -- making the link impossible to
    # revoke, expire or measure.
    return RedirectResponse(link.destination, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
# src/shortlink/main.py -- the app factory and everything with a lifetime.
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import redis.asyncio as redis
from fastapi import FastAPI

from shortlink import observability
from shortlink.api import router
from shortlink.config import get_settings
from shortlink.db import engine
from shortlink.errors import install_error_handlers


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Start-up before the first request; shutdown after the last one.

    Everything expensive and long-lived is built here, once. Building a Redis
    client per request is the commonest cause of a service that is fine in
    staging and exhausts file descriptors in production.
    """
    settings = get_settings()
    observability.configure(settings)
    app.state.redis = redis.from_url(str(settings.redis_url), decode_responses=True)
    try:
        yield
    finally:
        # Ordered, and always runs: an unclosed pool keeps the process alive
        # past SIGTERM and turns a rolling deploy into a five-minute wait.
        await app.state.redis.aclose()
        await engine.dispose()


def create_app() -> FastAPI:
    settings = get_settings()
    app = FastAPI(
        title="shortlink",
        version="0.1.0",
        lifespan=lifespan,
        docs_url="/docs" if settings.debug else None,   # not in production
        redoc_url=None,
    )
    install_error_handlers(app)
    observability.install_middleware(app)
    app.include_router(observability.health_router)
    app.include_router(router)
    return app


# uvicorn shortlink.main:app -- a module-level app so the reloader can find it.
app = create_app()

The route order that costs a day

GET /{code} is a wildcard at the root, so it will happily swallow /healthz, /docs and anything else you add later — FastAPI matches routes in registration order, and the first match wins. That is why the health router is included before the application router above, and why in a real service the catch-all is usually mounted last and deliberately, with a comment saying so. The same reasoning applies to a prefix: putting the API under /v1 and leaving the bare root for redirects removes the ambiguity entirely, and is what you want the day you add a second endpoint.

Python · 39

Failure is part of the contract

Every endpoint has a happy path and several unhappy ones, and clients integrate against both. If each handler invents its own error body, the client ends up with a parser per endpoint and a comment saying “sometimes it is detail, sometimes message”. One exception hierarchy, one handler, one documented shape — and RFC 9457 already specifies that shape, so there is nothing to design.

# src/shortlink/errors.py -- one shape for every failure that leaves the app.
import structlog
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

from shortlink.domain import DomainError, InvalidDestination
from shortlink.service import LinkExpired, LinkNotFound

log = structlog.get_logger()

# The one place a domain error becomes a status code. Adding an error means
# adding a row here, which a reviewer can see, rather than a raise buried in
# a handler, which they cannot.
STATUS_BY_ERROR: dict[type[DomainError], int] = {
    InvalidDestination: status.HTTP_422_UNPROCESSABLE_ENTITY,
    LinkNotFound: status.HTTP_404_NOT_FOUND,
    LinkExpired: status.HTTP_410_GONE,
}


def problem(
    request: Request, status_code: int, title: str, detail: str, **extra: object
) -> JSONResponse:
    """RFC 9457 problem details. A standard shape beats a bespoke one."""
    body = {
        "type": f"https://errors.shortlink.dev/{title.lower().replace(' ', '-')}",
        "title": title,
        "status": status_code,
        "detail": detail,
        "instance": str(request.url.path),
        # The value a support ticket should quote. It is in the logs too.
        "request_id": request.state.request_id,
        **extra,
    }
    return JSONResponse(
        body, status_code=status_code, media_type="application/problem+json"
    )


def install_error_handlers(app: FastAPI) -> None:
    @app.exception_handler(DomainError)
    async def _domain(request: Request, exc: DomainError) -> JSONResponse:
        code = STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
        # Expected. Logged at info, and it must never page anyone.
        log.info("domain_error", error=type(exc).__name__, status=code)
        return problem(request, code, type(exc).__name__, str(exc))

    @app.exception_handler(RequestValidationError)
    async def _validation(request: Request, exc: RequestValidationError) -> JSONResponse:
        return problem(
            request,
            status.HTTP_422_UNPROCESSABLE_ENTITY,
            "Validation failed",
            "The request body did not match the schema.",
            errors=[
                {"field": ".".join(str(p) for p in e["loc"][1:]), "message": e["msg"]}
                for e in exc.errors()
            ],
        )

    @app.exception_handler(Exception)
    async def _unhandled(request: Request, exc: Exception) -> JSONResponse:
        # A bug. Log the whole thing on our side, tell the caller nothing --
        # a stack trace in a response body is a free map of your dependencies.
        log.exception("unhandled_error", path=request.url.path)
        return problem(
            request,
            status.HTTP_500_INTERNAL_SERVER_ERROR,
            "Internal error",
            "The request could not be completed. Quote the request id if you contact support.",
        )
SituationStatusWhy that one
Body does not match the schema422Handled by FastAPI before your code runs. Return the failing field names; clients cannot fix what they cannot see.
Destination is a javascript: URL422Well-formed request, unacceptable content. A domain rule said no.
No such code404Nothing to say beyond that. Do not confirm whether it ever existed.
Code existed, has expired410Tells a crawler to stop asking. 404 invites a retry next week.
Missing or wrong API key401With WWW-Authenticate. 403 means “I know who you are and no”.
Over the rate limit429With Retry-After, or the client will simply hammer you faster.
Anything unhandled500Generic body, full detail in the logs, one request id joining the two.

The catch-all handler and the one thing it must not do

Registering a handler for bare Exception means the framework no longer re-raises, so anything that used to surface in your error tracker now quietly becomes a tidy 500. That is only safe because the handler logs with log.exception — which captures the traceback — before returning. If you take one thing from this topic: an error the client sees and an error you can debug are two different artefacts, and the request id is the only thing joining them. Put it in the response body, in every log line, and in the X-Request-Id response header.

Python · 40

Who is calling, and how often

Two edge concerns that are always someone else’s job until the incident. Authentication here is an API key, which is the right choice for machine-to-machine calls and has exactly two rules: store a hash, never the key, and compare in constant time. Rate limiting is the thing that keeps one badly-written client from being everybody’s outage, and it has to be shared across processes, which means it lives in Redis rather than in a dictionary.

# src/shortlink/security.py
import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import Annotated

from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from shortlink.config import Settings, get_settings
from shortlink.db import get_session
from shortlink.models import ApiKeyRow

bearer = HTTPBearer(auto_error=False)


@dataclass(frozen=True, slots=True)
class ApiKey:
    owner_id: str


def hash_key(raw: str) -> str:
    """What goes in the database. Never the key itself.

    sha256 and not bcrypt on purpose: an API key is already 256 bits of
    randomness that we issued, so there is no low-entropy password to make
    expensive to guess -- and this runs on every single request.
    """
    return hashlib.sha256(raw.encode()).hexdigest()


async def load_key(session: AsyncSession, digest: str) -> ApiKeyRow | None:
    """Look the caller up by the hash, never by the key.

    Indexed on key_hash, so this is one index probe, and the raw key never
    reaches a query, a log line, or a slow-query report.
    """
    stmt = select(ApiKeyRow).where(
        ApiKeyRow.key_hash == digest, ApiKeyRow.revoked_at.is_(None)
    )
    return (await session.execute(stmt)).scalar_one_or_none()


async def require_api_key(
    creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
    session: Annotated[AsyncSession, Depends(get_session)],
) -> ApiKey:
    unauthorized = HTTPException(
        status.HTTP_401_UNAUTHORIZED,
        "invalid or missing credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    if creds is None:
        raise unauthorized

    digest = hash_key(creds.credentials)
    row = await load_key(session, digest)
    # Belt and braces: the lookup already matched on the hash, but comparing
    # the stored value in constant time means a later change to that query --
    # a prefix match, a LIKE, a cache that returns a near miss -- cannot
    # quietly become an authentication bypass.
    if row is None or not hmac.compare_digest(row.key_hash, digest):
        raise unauthorized

    # One identical failure for "no such key" and "revoked key". Telling them
    # apart is free reconnaissance for whoever is working through a list.
    return ApiKey(owner_id=row.owner_id)


async def rate_limit(
    request: Request, settings: Annotated[Settings, Depends(get_settings)]
) -> None:
    """A fixed-window counter in Redis, shared by every worker.

    An in-process dict is not a rate limit: with four workers behind one load
    balancer the effective limit is four times what you configured, and it
    resets on every deploy.
    """
    key = f"rl:{request.client.host if request.client else 'unknown'}:{int(time.time() // 60)}"
    redis = request.app.state.redis

    # One round trip, not two -- and INCR is atomic, so concurrent requests
    # cannot both read 59 and both decide they are under the limit.
    pipe = redis.pipeline()
    pipe.incr(key)
    pipe.expire(key, 120)          # longer than the window: cheap insurance against clock skew
    used, _ = await pipe.execute()

    limit = settings.rate_limit_per_minute
    if used > limit:
        retry_after = 60 - int(time.time() % 60)
        raise HTTPException(
            status.HTTP_429_TOO_MANY_REQUESTS,
            "rate limit exceeded",
            headers={
                "Retry-After": str(retry_after),
                "RateLimit-Limit": str(limit),
                "RateLimit-Remaining": "0",
            },
        )
L1 · Beginner

The non-negotiables

  • Store a hash, never the key. A leaked database should not be a leaked credential.
  • Show the key once, at creation. If you can display it later, you stored it.
  • 401 for “who are you”, 403 for “not allowed”. They are different bugs to debug.
  • Never log the Authorization header. Redact it in the middleware, not by remembering.
L2 · Intermediate

Make it operable

  • Rate limit state must be shared. Per-process counters silently multiply the limit by your worker count.
  • Return the headers. Retry-After turns a retry storm into a queue.
  • Key on the caller, not the IP, once you have authenticated one — otherwise a whole office behind one NAT shares a bucket.
  • Give keys a prefix (sl_live_...) so secret scanners can find them in a public repository before an attacker does.
L3 · Advanced

Where this design runs out

  • A fixed window allows a double burst at the boundary: the full limit at 11:59:59 and again at 12:00:00. A sliding window or a token bucket in a Lua script fixes it when that matters.
  • Decide what happens when Redis is down. Fail open and you have no limit; fail closed and Redis is now a hard dependency of every request. Choose deliberately and write it down.
  • Rate limiting at the edge is cheaper — a gateway or CDN rejects before you pay for a worker. Keep the in-app limit as the backstop.
  • Key rotation needs two valid keys at once, which means the lookup must accept a set, not a value.
Python · 41

Logs, traces, and the two health checks

You will debug this service from its output, not from a debugger, so the output is part of the design. Three things pay for themselves in the first week: logs that are JSON with a request id on every line, a trace that spans the whole request, and two different health endpoints — because “is this process alive” and “should this process receive traffic” have different answers and confusing them takes an entire cluster down.

# src/shortlink/observability.py
import time
import uuid
from collections.abc import Awaitable, Callable

import structlog
from fastapi import APIRouter, FastAPI, Request, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy import text

from shortlink.config import Settings
from shortlink.db import engine

log = structlog.get_logger()


def configure(settings: Settings) -> None:
    """JSON in production, colour locally. Same events either way."""
    renderer = (
        structlog.dev.ConsoleRenderer()
        if settings.debug
        else structlog.processors.JSONRenderer()
    )
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,   # request_id, without passing it around
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt="iso", utc=True),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            renderer,
        ],
        wrapper_class=structlog.make_filtering_bound_logger(settings.log_level),
        cache_logger_on_first_use=True,
    )


def install_middleware(app: FastAPI) -> None:
    @app.middleware("http")
    async def request_context(
        request: Request, call_next: Callable[[Request], Awaitable[Response]]
    ) -> Response:
        # Trust an inbound id so one id spans every service in the call, but
        # generate one when absent -- a request with no id is unsupportable.
        request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
        request.state.request_id = request_id

        # contextvars, not a parameter: every log line anywhere below this,
        # including inside the repository, carries the id without plumbing.
        structlog.contextvars.bind_contextvars(request_id=request_id)
        started = time.perf_counter()
        try:
            response = await call_next(request)
        finally:
            structlog.contextvars.clear_contextvars()

        elapsed_ms = (time.perf_counter() - started) * 1000
        response.headers["X-Request-Id"] = request_id
        log.info(
            "http_request",
            method=request.method,
            # route template, not the URL: /links/{code} aggregates, and the
            # raw path turns every distinct code into its own metric series.
            path=request.scope.get("route").path if request.scope.get("route") else request.url.path,
            status=response.status_code,
            duration_ms=round(elapsed_ms, 2),
        )
        return response


health_router = APIRouter(tags=["health"])


@health_router.get("/healthz", include_in_schema=False)
async def liveness() -> dict[str, str]:
    """Is the process alive? Nothing else.

    It must not touch the database. A liveness probe that checks a dependency
    restarts every pod in the fleet the moment that dependency has a bad
    minute -- turning a brief database blip into a full outage with no
    capacity left to recover.
    """
    return {"status": "ok"}


@health_router.get("/readyz", include_in_schema=False)
async def readiness() -> Response:
    """Should this process receive traffic? Now the dependencies matter."""
    try:
        async with engine.connect() as conn:
            await conn.execute(text("SELECT 1"))
    except Exception:
        log.warning("readiness_failed", check="database")
        return JSONResponse(
            {"status": "degraded", "database": "unreachable"},
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
        )
    return JSONResponse({"status": "ready"})
SignalAnswersThe mistake to avoid
Structured logs“What happened to this request?”Free-text messages. f"user {uid} failed" cannot be filtered, counted or joined.
Metrics“How is the service doing overall?”High-cardinality labels. A label per short code is a monitoring bill, not a dashboard.
Traces“Where did the 900 ms go?”Sampling everything. 1–10% plus every error is the usual shape.
/healthz“Restart me?”Checking dependencies. That is how one slow database restarts the entire fleet.
/readyz“Send me traffic?”Returning 200 while the pool is exhausted, so the load balancer keeps feeding a dead process.

The one field that makes logs useful

A request id, bound in a contextvar at the edge and merged into every event automatically. Without it, a production investigation is grep and hope; with it, one identifier from a support ticket returns the whole story across every module and, if the id is propagated on outbound calls, across every service. It costs the middleware above and nothing else. Note the route template rather than the raw path in the log line, for the same reason: /links/{code} is a thing you can count, and /links/aB3xK9p is ten million things you cannot.

Python · 42

Tests that would actually have caught it

The tests worth writing are the ones that fail when the behaviour changes and pass when only the code does. That rules out mocking the database — a mocked session cannot fail a unique constraint, which is precisely the behaviour the idempotency logic depends on. So: a real Postgres in a container, the app driven in-process over ASGI with no network, and the domain tested on its own with no fixtures at all.

# tests/conftest.py -- a real database, a real app, no network.
from collections.abc import AsyncIterator, Iterator

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from testcontainers.postgres import PostgresContainer

from shortlink.db import get_session
from shortlink.main import create_app
from shortlink.models import Base


@pytest.fixture(scope="session")
def postgres_url() -> Iterator[str]:
    # One container for the whole session: starting it is the slow part, and
    # isolation comes from the per-test transaction below, not from restarts.
    with PostgresContainer("postgres:16-alpine", driver="asyncpg") as pg:
        yield pg.get_connection_url()


@pytest.fixture
async def session(postgres_url: str) -> AsyncIterator[AsyncSession]:
    """Each test runs in a transaction that is rolled back afterwards.

    Faster than recreating the schema per test, and it means tests cannot
    leak state into one another however carelessly they are written.
    """
    engine = create_async_engine(postgres_url)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    connection = await engine.connect()
    transaction = await connection.begin()
    factory = async_sessionmaker(bind=connection, expire_on_commit=False)
    async with factory() as s:
        yield s
    await transaction.rollback()
    await connection.close()
    await engine.dispose()


@pytest.fixture
async def client(session: AsyncSession) -> AsyncIterator[AsyncClient]:
    """The real app, with only the session swapped for the test one.

    ASGITransport means no socket is opened and no port is bound, so the
    suite runs anywhere and cannot flake on a port collision.
    """
    app = create_app()
    app.dependency_overrides[get_session] = lambda: session
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c
    app.dependency_overrides.clear()
# tests/test_api.py -- through the real app, against a real database.
import pytest
from httpx import AsyncClient


async def test_create_returns_201_with_location(client: AsyncClient, api_key: str) -> None:
    response = await client.post(
        "/links",
        json={"destination": "https://example.com/a"},
        headers={"Authorization": f"Bearer {api_key}"},
    )
    assert response.status_code == 201
    body = response.json()
    assert body["short_url"].endswith(body["code"])
    # The header a client needs and that nobody remembers to assert on.
    assert response.headers["Location"].endswith(body["code"])


async def test_same_idempotency_key_returns_the_same_link(
    client: AsyncClient, api_key: str
) -> None:
    """The test that a mocked database physically cannot perform.

    Two identical requests, one link. The guarantee lives in a partial
    unique index, so only a real Postgres can prove it holds.
    """
    headers = {"Authorization": f"Bearer {api_key}", "Idempotency-Key": "abc-123"}
    first = await client.post("/links", json={"destination": "https://example.com"}, headers=headers)
    second = await client.post("/links", json={"destination": "https://example.com"}, headers=headers)

    assert first.status_code == 201
    assert first.json()["code"] == second.json()["code"]


@pytest.mark.parametrize(
    "destination",
    ["javascript:alert(1)", "data:text/html,<script>", "ftp://example.com", "not a url"],
)
async def test_dangerous_destinations_are_rejected(
    client: AsyncClient, api_key: str, destination: str
) -> None:
    response = await client.post(
        "/links",
        json={"destination": destination},
        headers={"Authorization": f"Bearer {api_key}"},
    )
    assert response.status_code == 422
    # The failure contract is an API too: assert the shape, not just the code.
    assert response.headers["content-type"].startswith("application/problem+json")
    assert response.json()["status"] == 422


async def test_unknown_code_is_404_and_expired_is_410(client: AsyncClient) -> None:
    assert (await client.get("/nope123")).status_code == 404
L1 · Beginner

What to write first

  • One test per behaviour, named for the behaviour. test_create_returns_201_with_location tells you what broke from the failure line alone.
  • Test the domain without fixtures. If it needs one, the layering has slipped.
  • Assert the status code and the body shape, not the exact prose of a message.
  • Parametrise the variants instead of writing four near-identical tests.
L2 · Intermediate

Make them fast and honest

  • A real database, one container, per-test transactions. Rollback isolation is faster than recreating a schema and stricter than trusting cleanup.
  • Drive the app over ASGI — no port, no server, no flake, and the middleware and error handlers are all still exercised.
  • Override only the seam you must. Overriding the service under test proves nothing.
  • Mock what you do not own and cannot run: a third-party payment API, yes. Your own database, no.
L3 · Advanced

The tests that earn their keep later

  • Test the error contract. Clients parse it, so it is an interface, and it will otherwise change silently.
  • Run the migrations in CI against a copy of the previous schema, so an unsafe lock is caught before it takes production offline.
  • Property tests for the pure parts — a code generator and a validator are exactly what Hypothesis is good at.
  • Coverage is a smoke detector, not a goal. Untested error paths matter more than the last 4%.
Python · 43

The image, the pipeline, and the shutdown

The last mile, and the one where Python services most often go wrong in ways nobody notices until the first rolling deploy drops requests. Three things to get right: an image that carries no build tools and runs as nobody, a pipeline that runs exactly what the Makefile runs, and a process that finishes its in-flight requests when the orchestrator asks it to stop.

# Dockerfile -- two stages: one that can compile, one that ships.
FROM python:3.12-slim AS builder

# uv is copied from its own published image rather than pip-installed, which
# keeps this layer tiny and the version pinned by digest upstream.
COPY --from=ghcr.io/astral-sh/uv:0.4 /uv /bin/uv

ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app

# Dependencies before source. Editing a handler must not re-resolve or
# re-download anything -- this one ordering decision is most of your CI time.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev

COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev


FROM python:3.12-slim AS runtime

# A real user, created in the image. Running as root inside a container is
# one kernel bug away from being root on the node.
RUN useradd --create-home --uid 10001 app
WORKDIR /app

COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1

USER app
EXPOSE 8000

# Exec form, so uvicorn is PID 1 and receives SIGTERM directly. In shell form
# the shell is PID 1, it does not forward signals, and every deploy waits for
# the 30-second kill timeout while dropping in-flight requests.
CMD ["uvicorn", "shortlink.main:app", \
     "--host", "0.0.0.0", "--port", "8000", \
     "--workers", "4", "--timeout-graceful-shutdown", "20"]
# .github/workflows/ci.yaml -- the same commands the Makefile runs.
name: ci
on:
  push: { branches: [main] }
  pull_request:

concurrency:                        # a new push cancels the old run
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true
          cache-dependency-glob: uv.lock

      # --frozen: fail if uv.lock disagrees with pyproject.toml, rather than
      # silently resolving something nobody reviewed.
      - run: uv sync --frozen --all-groups

      - run: make check          # format, lint, types -- identical locally
      - run: make test           # testcontainers uses the runner's Docker

      - name: Build the image
        run: docker build -t shortlink:${{ github.sha }} .

Graceful shutdown is not automatic, and it is what a rolling deploy needs

When Kubernetes replaces a pod it sends SIGTERM and starts a countdown. Three things have to line up or you drop requests on every deploy. The signal must reach the server, which means the exec form of CMD so it is PID 1 rather than a shell that ignores signals. The server must then stop accepting new connections but finish the ones in flight, which is what --timeout-graceful-shutdown bounds. And the pod must leave the load balancer before it stops accepting, which is what a preStop sleep of a few seconds buys you, because endpoint removal is eventually consistent and traffic keeps arriving for a moment after the signal. Get all three right and a deploy is invisible; miss the first and every deploy is a small outage nobody attributes to the deploy.

Before it is “done”Why it is on this list
Migrations run as a separate step, not at start-upFour replicas starting at once run four migrations. Run them once, in a job, before the new version rolls.
/readyz wired to the readiness probeOtherwise traffic arrives before the pool is warm and the first requests fail.
Resource requests and limits setWithout a memory limit, one large response takes down the node instead of one pod.
Logs to stdout, nowhere elseThe platform collects stdout. A log file in a container is a disk that fills.
The image scanned, and the lockfile auditeduv lock --check plus a vulnerability scan in CI; supply chain is part of the build now.
An alert on the 500 rate and on p99 latencyDashboards are for after somebody notices. Alerts are for before.