Filter concepts by levelShowing all levels.

Django · Performance Fundamentals

Performance Fundamentals

Round trips as the unit of cost, the four bottlenecks, and the three costs behind one slow endpoint

  • Count what leaves the processO(n) in memory is invisible; O(n) round trips is the bug
  • Waiting vs working vs killedthat single observation names the bottleneck layer
  • Never add workers to a saturated databaseidle-looking workers are blocked on a socket — more of them add queueing
  • Memory does not degrade, it terminatesthe OOM kill takes unrelated in-flight requests with it
  • Fast SQL + slow endpoint = the serializera SerializerMethodField touching a relation is an N+1 no slow-query log shows

1.Big-O where it counts: round trips, not instructions

Big-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.

Key features

  • The unit of cost in a request is a round trip — a query, a cache `GET`, an HTTP call — not a Python statement
  • O(1) queries with an O(n) Python loop is fine; O(n) queries with a tidy loop is the bug
  • Constant factors inside the process rarely matter next to removing a single boundary crossing
  • Space grows with rows held, not rows read: `.iterator()` streams, `list(qs)` materialises the whole table
  • Per-request memory multiplies by concurrency — 40 MB per request is 1.2 GB at thirty in flight
  • Django's own advice is to profile first: "remember to profile after every change to ensure that the change is a benefit"

Same loop shape, wildly different cost

Same loop shape, wildly different cost
CodePython workRound tripsVerdict
`sum(o.total for o in orders)`O(n)0 extrafine at any realistic page size
`[o.customer.name for o in orders]`O(n)**O(n)**N+1 — the classic bug
`orders.select_related("customer")`O(n)1the fix: same loop, one trip
`orders.aggregate(Sum("total"))`O(1) in Python1best when you need only the number
`for o in orders: charge(o)`O(n)O(n) **HTTP**move it to a task; never in a request
Two O(n) loops — one is invisible, one takes the site down

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
  • 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

Command

python
# count what leaves the process, not what runs inside it
with django_assert_num_queries(1):
    total = sum(o.total for o in Order.objects.filter(month=9))

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.

2.The four bottlenecks, and how to tell which one you have

A slow request is slow for one of four reasons: it is waiting on the **database**, waiting on the **network** (another service, a cache, a queue), burning **CPU**, or running out of **memory**. They are worth separating because their symptoms overlap and their fixes do not. The quickest way to tell them apart is to ask what the process is doing during the slow part. Waiting on a socket is database or network. Actually computing is CPU. Being killed rather than slowed is memory. Get this wrong and you tune the wrong layer — adding workers to a database-bound service makes it slower, not faster.

Key features

  • Waiting (database, network) shows as low CPU with high latency; working (CPU) shows as high CPU with high latency
  • More workers help a waiting service only if the thing it waits on has spare capacity — otherwise they add queueing
  • Memory does not degrade gracefully: the process is killed, so unrelated in-flight requests die with it
  • Database bottleneck ≠ slow database — it is usually too many queries, not one slow one
  • A network bottleneck inside a request is the worst kind, because its latency is set by someone else's system
  • Indexes are "a number one priority, after you have determined from profiling what indexes should be added"

Telling the four apart from what you can see

Telling the four apart from what you can see
BottleneckWhat you observeWhat actually fixes it
Databasehigh latency, low CPU, query count grows with rowsfewer queries (`select_related`), an index, a cache
Networkhigh latency, low CPU, time sits in one outbound calltimeout + move the call out of the request
CPUhigh latency **and** high CPU, latency rises with concurrencyserialize less, precompute, add cores
Memorythe process is killed; unrelated requests fail togetherstream it, `.iterator()`, paginate, cap the response
Four bottlenecks, four different fixes — and the fix that makes each one worse

Database — waiting

Usually too MANY queries

not one slow query — count them first

Fix: select_related, index, cache

remove trips, do not add workers

Makes it worse: more workers

queueing plus connection exhaustion

Network — waiting

Latency is set by someone else

you cannot optimise their system

Fix: timeout, then move it to a task

a request should not wait on a third party

Makes it worse: retrying in-request

multiplies the hold on the worker

CPU — working

Serialization is the usual culprit

thousands of objects, nested serializers

Fix: fewer objects, precompute, more cores

values() beats instances

Makes it worse: more workers per core

context switching, no extra capacity

Memory — terminal

Held rows, not read rows

list(qs) materialises everything

Fix: iterator, streaming, pagination

bound the response, not just the query

Symptom lies: unrelated requests fail

the OOM kill takes the whole process

  • Database — waiting — low CPU, latency grows with row count
    • Usually too MANY queries — not one slow query — count them first
    • Fix: select_related, index, cache — remove trips, do not add workers
    • Makes it worse: more workers — queueing plus connection exhaustion
  • Network — waiting — time sits inside one outbound call
    • Latency is set by someone else — you cannot optimise their system
    • Fix: timeout, then move it to a task — a request should not wait on a third party
    • Makes it worse: retrying in-request — multiplies the hold on the worker
  • CPU — working — high CPU, worsens with concurrency
    • Serialization is the usual culprit — thousands of objects, nested serializers
    • Fix: fewer objects, precompute, more cores — values() beats instances
    • Makes it worse: more workers per core — context switching, no extra capacity
  • Memory — terminal — does not slow down, it dies
    • Held rows, not read rows — list(qs) materialises everything
    • Fix: iterator, streaming, pagination — bound the response, not just the query
    • Symptom lies: unrelated requests fail — the OOM kill takes the whole process

Command

python
# per request, record all four so triage is a lookup, not a guess
log.info("request", extra={
    "duration_ms": ..., "db_ms": ..., "db_queries": ..., "external_ms": ...,
})

Ask what the process is doing during the slow part: blocked on a socket is database or network, busy is CPU, and killed rather than slowed is memory. Waiting is fixed by waiting less — never by adding workers to a resource that is already the constraint, which adds queueing and exhausts connections at the same time. Record four numbers per request (total, database time, query *count*, external time), because database time and query count point at opposite fixes.

3.Query cost, serialization cost, and cache behaviour

A query has two costs, and they are separate: what the database spends finding the rows, and what your process spends turning them into objects. A query returning 50,000 rows can be fast in the database and still take seconds, because Django builds 50,000 model instances and a serializer then walks every field of every one. Caching is the third cost, and the number that decides whether it helped is the **hit rate** — a cache at 20% hits adds a round trip to four requests out of five and saves the fifth. Measure the hit rate before believing a cache made anything faster.

Key features

  • Database time and row count are separate costs — an unindexed 10-row query and a fast 50,000-row query fail differently
  • Model instantiation is per row: `.values()` skips it entirely, `.only()`/`.defer()` shrink it
  • Serializer cost is invisible in the SQL log — the database is fast and the endpoint is not
  • A nested serializer accessing a related object per row is an N+1 the query log will show but the serializer code will not
  • Cache value = hit rate × cost avoided, minus the round trip paid on every miss — below roughly 50% hits, question it
  • A cache key containing a timestamp or a unique id is a key that is never read twice

Where a "slow query" actually spends its time

Where a "slow query" actually spends its time
SymptomWhere the time isFix
`EXPLAIN` shows a sequential scanin the databasean index, or filter earlier
fast SQL, slow endpoint, many rowsbuilding model instances`.values()` / `.only()` / paginate
fast SQL, slow endpoint, few rowsthe serializer or a per-row callprofile the view, not the query
query count grows with rowsa lazy relation, often inside the serializer`select_related` / `prefetch_related`
cache added, latency unchangeda low hit ratemeasure hits; widen the key's lifetime or scope
One serializer call, and the cost hidden in each argument

payload = OrderSerializer(page, many=True, context={"request": request}).data

OrderSerializer

field walk per row — every declared field runs its `to_representation` for every object — a `SerializerMethodField` that queries turns this into an N+1 the serializer code never shows.

page

the row count, and the whole ceiling — this is the number that decides everything downstream. Pass a page, never a full queryset — an unbounded row count cannot be made fast by any later change.

many=True

the per-row multiplier — turns every cost above from "once" into "once per row". A field that costs 40 microseconds is nothing at n=20 and two seconds at n=50,000.

context={"request": request}

where per-row permission checks hide — fields that read `context["request"].user` to decide visibility often trigger a permission lookup per row unless the result is computed once and passed in.

.data

the moment it all runs — the serializer is lazy until here. A profile that stops before `.data` is read shows none of this cost, which is why it gets attributed to "the database".

  • Whole: payload = OrderSerializer(page, many=True, context={"request": request}).data
  • OrderSerializer — field walk per row: every declared field runs its `to_representation` for every object — a `SerializerMethodField` that queries turns this into an N+1 the serializer code never shows.
  • page — the row count, and the whole ceiling: this is the number that decides everything downstream. Pass a page, never a full queryset — an unbounded row count cannot be made fast by any later change.
  • many=True — the per-row multiplier: turns every cost above from "once" into "once per row". A field that costs 40 microseconds is nothing at n=20 and two seconds at n=50,000.
  • context={"request": request} — where per-row permission checks hide: fields that read `context["request"].user` to decide visibility often trigger a permission lookup per row unless the result is computed once and passed in.
  • .data — the moment it all runs: the serializer is lazy until here. A profile that stops before `.data` is read shows none of this cost, which is why it gets attributed to "the database".

Command

python
print(Order.objects.filter(month=9).explain())   # database cost
# then time the view with and without .data           # serialization cost

Split "slow query" into database time, row count, and what you built from the rows — they have different fixes, and the row count is the ceiling on the other two. Suspect the serializer whenever the SQL is fast and the endpoint is not, because a `SerializerMethodField` touching a relation is an N+1 that no slow-query log will show. And treat a cache as a claim to be measured: emit hit and miss counters, keep keys stable and shared, and remember that below a decent hit rate a cache costs a round trip on every request and saves almost nothing.

Remember

  • Carry two numbers per endpoint: how round trips grow with the data, and how memory grows with the response.
  • Database time and query *count* point at opposite fixes — 431 fast queries is an N+1, 3 slow ones is an index.
  • Row count is the ceiling on everything downstream; nothing later can rescue an unbounded result set.
  • A cache below a decent hit rate is a net loss: every miss pays the round trip and the database.

Pro tip

  • Log total_ms, db_ms, db_queries and external_ms on every request. Triage becomes a lookup instead of a guess about which layer to open.
  • A cache key containing a timestamp or a unique id is a key that is never read twice — that is how a cache reaches a 2% hit rate.

Next up

  • Django Performance Debugging
  • Pagination and Large Result Sets
  • Large Data Operations

Sources

  • Database access optimization — docs.djangoproject.com/en/5.2/topics/db/optimization/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.