Big-O where it counts: round trips, not instructions
coreintermediateBig-O describes how work grows as the input grows. In a web request the unit that matters is rarely a Python instruction — it is a **round trip**: one query, one cache lookup, one HTTP call. An O(n) Python loop over 500 rows in memory is not the problem. An O(n) loop that does one query per row is, because each iteration pays for a network hop and a database plan. Space complexity works the same way: what fills memory is holding *whole rows* — a `list(Order.objects.all())` grows with the table, and one large response can use more memory than the rest of the request put together.
Think of it as
Count the things that leave the process. Inside the process, Python is doing tens of millions of simple operations per second, so an O(n) or even O(n log n) pass over the few hundred objects a page shows is invisible. Outside the process, every unit costs a round trip — a request over a socket, a wait, a response — and those are the units your latency is actually made of. This reframes the usual advice. "Avoid nested loops" is not the rule; the rule is "avoid a loop that crosses a boundary", because `for order in orders: order.customer.name` is an innocent-looking single loop that is O(n) *queries*. Two more consequences follow. First, an algorithmic improvement that removes a boundary crossing beats a constant-factor improvement inside the process by orders of magnitude, which is why `select_related` matters more than any Python micro-optimisation you could make on the same view. Second, complexity in a web service is measured per request but paid per request *times concurrency*: an endpoint that holds 40 MB while it builds a response is fine alone and out of memory at thirty concurrent calls. So carry two numbers for any endpoint — how its work grows with the data it touches, and how its memory grows with the response it builds.
What we're doing: Take one report endpoint from O(n) round trips and O(n) memory down to a fixed cost, and show which change mattered.
- 4–8
- Three boundary crossings per row. At 5,000 orders that is 15,001 round trips for a page that shows one table — and every one of them is a wait, not a computation.
- 14
- `get_many` collapses n cache round trips into one. The Python-side lookup that replaces it on line 27 is a dict access, which is not measurable at this scale.
- 20
- `select_related("customer__region")` follows the chain in a single JOIN, so both attribute accesses that used to cost a query now cost nothing.
- 21
- `.values()` stops building model instances at all. The row dicts hold four fields instead of every column, which is where the memory reduction comes from.
- 31
- `.iterator(chunk_size=2000)` keeps 2,000 rows in memory rather than the whole result. The time complexity is unchanged; the *space* complexity is what moved from O(n) to O(chunk).
Why this works: The Python work is O(n) in both versions and that was never the problem. The rewrite removes 15,000 round trips and caps memory at a chunk, which are the two dimensions a request is actually judged on.
Optimising the Python loop instead of the boundary crossings
Wrong
Better
What you see: A day spent on comprehensions, generators and `__slots__` moves the endpoint from 4.0 s to 3.9 s, because 99% of the time was spent waiting on queries that neither change touched.
Why: Comprehensions are a constant-factor improvement on the part of the work that was already cheap. The `.customer.name` access inside is the actual cost, and it is untouched by how the loop is written. Profiling before changing anything shows this immediately — Django's own guidance is to find out "what queries you are doing and what they are costing you" first — and it is why query counts, not loop style, are the thing to assert in tests.
- O(n) in memory — fine
- n iterations of Python work, zero extra round trips
- Cost is measured in microseconds at page-sized n
- Scales with CPU, which you have plenty of
- Optimising this is usually wasted effort
- O(n) round trips — the bug
- n iterations, each crossing a process boundary
- Cost is n × (network + plan + fetch)
- Scales with the slowest shared resource you have
- Removing the boundary is the only fix that works
Same loop shape, wildly different cost
Together
Remember: Count what leaves the process. An O(n) Python loop over a page of rows is invisible; an O(n) loop that crosses a boundary each iteration is the bug, and no amount of comprehension-tuning touches it. Carry two numbers for every endpoint — how its round trips grow with the data, and how its memory grows with the response — because memory is the one that multiplies by concurrency and kills the whole process rather than slowing it down. Profile before changing anything: Django's own advice is to find out what your queries cost first.
See also: the four bottlenecks · the n plus 1 pattern · iterator batching and streaming responses

