Every Django codebase has one. It passes review, passes tests, and ships — because on a development database with a dozen rows, the difference is single-digit milliseconds. The ORM gives you no signal at all: route.stops.all() reads like an attribute access and behaves like a network call.
The cost is linear in a number that only grows in production. Drag the slider.
views.py
routes = Route.objects.all()[:N]for route in routes:# one DB round-trip per routerender(route.stops.all())
connection.queries
13Timings are modelled from a fixed per-query round-trip, not measured against a live database — the shape of the curve is the point, not the absolute numbers.
Why it survives review
The fix is old news. What's worth internalising is the failure mode: the bug is invisible at the size you develop at. Nobody merges this because they don't know about prefetch_related — they merge it because the page felt fast when they checked, and the seed data had twelve rows in it.
That's also why "read the code more carefully" doesn't work as a remedy. The line that costs you 500 queries looks exactly like the line that costs you none. You cannot see the loop from inside the loop.
Make it fail at review time instead
The durable fix isn't the query — it's asserting on the query count, so the regression breaks a test on the branch rather than a dashboard at 2am:
def test_route_list_is_constant_query(client):
with assertNumQueries(2):
client.get("/api/routes/")Two queries, pinned. Add a route with a new relation and forget to prefetch it, and this test fails immediately with a diff that names the exact SQL that got added. It costs three lines and it converts an invisible, size-dependent performance bug into an ordinary red build.
The general shape is worth keeping: when a bug only appears at a scale you don't develop at, stop trying to catch it by looking, and put a number on it that a machine can check.