Filter concepts by levelShowing all levels.

Django · Section 71

Performance Fundamentals

Level
intermediate
Read
30 min
Concepts
3

Big-O is the right tool with the wrong unit. In a web request the unit that decides latency is almost never a Python instruction — it is a round trip: one query, one cache lookup, one HTTP call. An O(n) loop summing totals over a page of rows is invisible; an O(n) loop that reads `order.customer.name` is the classic bug, and no amount of rewriting it as a comprehension touches the cost, because the cost is n network hops rather than n statements. Space works the same way: what fills memory is rows *held*, not rows read, and memory is the one resource that does not degrade gracefully — a container over its limit is killed, so unrelated in-flight requests die with no error from the code that caused it. Which makes two numbers worth carrying for any endpoint: how its round trips grow with the data, and how its memory grows with the response. When something is slow, four resources can be the reason, and the fastest way to tell them apart is to ask what the process is doing during the slow part. Blocked on a socket is database or network; genuinely busy is CPU; killed rather than slowed is memory. That distinction matters because the fixes are opposites — waiting is fixed by waiting less, and adding workers to a service that waits on an already-saturated database makes latency worse while exhausting the connection limit on the way. Even "the database is slow" is two diagnoses, not one: high database time with a high query count is a structural loading problem no index will help, while high database time across three queries is exactly what `EXPLAIN` and an index are for. Finally, one endpoint hides three separate costs. The database spends time finding rows; your process spends time turning them into objects; and the serializer walks every field of every one — a cost invisible in the SQL log, which is why a fast query beside a slow endpoint so often gets blamed on the network. Caching is the third, and it is a claim to be measured rather than a fix to be assumed: emit hit and miss counters, keep keys stable and shared, and remember that a key containing a timestamp is never read twice.

What is true here

  1. The unit of cost is a round trip, not an instruction — count what leaves the process.
  2. Waiting (database/network) vs working (CPU) vs killed (memory) names the bottleneck in one observation.
  3. Never add workers to a resource that is already the constraint; it adds queueing and exhausts connections.
  4. Split "slow query" into database time, row count, and what you built from the rows.
  5. A cache is a measurable claim — without hit/miss counters there is no evidence it helped.

What you will be able to do

  • Tell an expensive loop from an irrelevant one by counting boundary crossings
  • Name the bottleneck layer from four numbers instead of guessing which one to open
  • Recognise a serialization cost that the slow-query log cannot show
  • Judge whether a cache is earning its round trip
Where the danger is: cost that grows with the data, on a resource you wait for
template render
constant, in-process — almost never worth optimising
serializing 50,000 objects
CPU and memory both grow with n; fix by capping n, not by tuning fields
one indexed lookup
a boundary crossing, but a fixed one — this is what good looks like
N+1 relation access
the dangerous quadrant: one round trip per row, on the slowest shared resource you have
per-row external API call
the same quadrant, worse — latency set by someone else's system, inside your request
  • template render: fixed cost per request, in-process (CPU, memory) — constant, in-process — almost never worth optimising
  • serializing 50,000 objects: grows with row count, in-process (CPU, memory) — CPU and memory both grow with n; fix by capping n, not by tuning fields
  • one indexed lookup: fixed cost per request, across a boundary (database, network) — a boundary crossing, but a fixed one — this is what good looks like
  • N+1 relation access: grows with row count, across a boundary (database, network) — the dangerous quadrant: one round trip per row, on the slowest shared resource you have
  • per-row external API call: grows with row count, across a boundary (database, network) — the same quadrant, worse — latency set by someone else's system, inside your request

Complexity, in the unit that matters

Why an O(n) loop can be free or fatal depending on whether it crosses a process boundary.

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

coreintermediate

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.

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.

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

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.

reports/services.pypython
def monthly_summary_slow(month):
    rows = []
    for order in Order.objects.filter(month=month):        # 1 query
        rows.append({
            "customer": order.customer.name,               # +1 query per order
            "region": order.customer.region.name,          # +1 query per order
            "total": order.total,
            "rate": cache.get(f"fx:{order.currency}"),     # +1 Redis trip per order
        })
    return rows


def monthly_summary(month):
    rates = cache.get_many([f"fx:{c}" for c in CURRENCIES])   # 1 Redis trip, all rates

    rows = (
        Order.objects
        .filter(month=month)
        .select_related("customer__region")                   # 1 query, joined
        .values("customer__name", "customer__region__name", "total", "currency")
    )

    return [
        {
            "customer": row["customer__name"],
            "region": row["customer__region__name"],
            "total": row["total"],
            "rate": rates.get(f"fx:{row['currency']}"),        # dict lookup, no trip
        }
        for row in rows.iterator(chunk_size=2000)              # bounded memory
    ]
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

python
# "the loop is slow — make it a comprehension and use __slots__"
rows = [
    {"customer": o.customer.name, "total": o.total}      # still 1 query per order
    for o in Order.objects.filter(month=month)
]

Better

python
rows = list(
    Order.objects.filter(month=month)
    .values("customer__name", "total")                    # 1 query, no instances
)

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.

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

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

Together

python
# one query, whatever n is
total = Order.objects.filter(customer=customer).aggregate(Sum("total"))["total__sum"]

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

Advertisement

The four bottlenecks

Database, network, CPU and memory — the observation that tells them apart, and the fix that makes each one worse.

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

coreintermediate

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.

Think of it as

The distinction that does most of the work is *waiting* versus *working*. Database and network bottlenecks are waiting: the process is blocked on a socket, using almost no CPU, and adding more concurrency seems attractive because the workers look idle. That instinct is a trap when the thing being waited on is shared and already saturated — thirty workers queueing on one overloaded database do not go faster, they make each other slower and exhaust the connection limit on the way. The fix for waiting is always to wait less: fewer queries, better indexes, a cache, a timeout, or moving the work out of the request entirely. CPU is the opposite: the process is genuinely busy, more concurrency on the same cores makes everything slower through context switching, and the fix is to do less work — cheaper serialization, fewer objects, a precomputed result — or to add cores. Memory is different from all three because it does not degrade, it terminates. A container over its limit is killed, so the failure appears as unrelated requests dying at once with no error from the code that caused it. Keep one more thing in mind: the four interact. Connection exhaustion is a database bottleneck caused by too many workers, which were added to fix a network bottleneck. Following the symptom to the wrong layer is the most common way a performance investigation goes wrong for a week.

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": ...,
})

What we're doing: Instrument a request so the answer to "which bottleneck" is in the log line instead of in an afternoon of guessing.

observability/middleware.pypython
class RequestCostMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start = time.monotonic()
        queries_before = len(connection.queries_log)

        response = self.get_response(request)

        db_ms = sum(float(q["time"]) for q in connection.queries_log) * 1000
        total_ms = (time.monotonic() - start) * 1000

        log.info(
            "request",
            extra={
                "path": request.path,
                "total_ms": round(total_ms, 1),
                "db_ms": round(db_ms, 1),
                "db_queries": len(connection.queries_log) - queries_before,
                "external_ms": round(getattr(request, "external_ms", 0.0), 1),
                "cpu_ms": round(total_ms - db_ms - getattr(request, "external_ms", 0.0), 1),
            },
        )
        return response

# total 2400ms · db 2210ms · queries 431 · external 12ms · cpu 178ms
#   → database bottleneck, and the query COUNT says it is an N+1, not a slow query
11
`connection.queries_log` is only populated when `DEBUG` is `True`, so in production this comes from an APM hook or a database wrapper instead. The shape of the log line is the point, not this particular source.
16–23
Four numbers, one per bottleneck. Any one of them alone is ambiguous; together they name the layer without a second deploy.
24
CPU time is what is left after waiting is subtracted. It is approximate, and approximate is enough to tell "busy" from "blocked".
28
The read: almost all the time is database, and 431 queries for one page means the count is the problem. A single slow query would show as high `db_ms` with `db_queries` in single figures — a different fix entirely.

Why this works: Triage is a lookup once the four numbers are on every request. Without them, the same investigation starts with a guess about which layer to open, and the guess is wrong often enough to cost days.

Adding workers to a database-bound service

Wrong

python
# "the workers are idle, latency is high — scale up"
gunicorn --workers 40 shop.wsgi        # was 8
# p95 gets worse, then: FATAL: sorry, too many clients already

Better

python
# same 8 workers; remove the waiting instead
Order.objects.select_related("customer__region")   # 431 queries -> 1
# p95 falls; connection count unchanged

What you see: Latency rises after scaling out, and the database starts refusing connections. The dashboard shows idle workers and a saturated database, which reads as "the database is too small" and drives a costly upgrade that changes nothing.

Why: Workers look idle because they are blocked on a socket, and that is exactly what a database bottleneck looks like from the application side. Adding workers adds concurrent queries to a resource that is already the constraint, so each query now queues behind more work, and every worker also holds a connection — which is how a scale-up turns into `too many clients already`. Waiting is fixed by waiting less. Only after the query count is right does adding capacity do anything useful.

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

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

Together

python
# the one-line triage: is the process waiting, or working?
#   waiting  → database or network   (low CPU, high latency)
#   working  → CPU                   (high CPU, high latency)
#   gone     → memory                (no error, process restarted)

Remember: 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.

See also: query cost serialization cost and cache behaviour · locating the time in a slow request · worker multiplication and connection exhaustion

Advertisement

Query, serialization, and cache

Three costs behind one slow endpoint, and the reason the fastest query can sit inside the slowest view.

Query cost, serialization cost, and cache behaviour

coreintermediate

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.

Think of it as

Split every "the query is slow" claim into three questions. How long did the database take? How many rows came back? What did we build out of them? Those have different fixes and different ceilings. Database time responds to indexes and to filtering earlier. Row count responds to pagination, and it is usually the real problem, because nothing downstream can be fast when the row count is unbounded. Object-building responds to not building objects — `.values()` returns dicts, `.only()` returns lighter instances, and a nested serializer that touches a related field per row quietly reintroduces the query cost you removed. The serializer deserves particular suspicion because its cost is invisible in the SQL log: the database says two milliseconds, the endpoint takes three seconds, and nothing in the query view explains the gap. For caching, the mental shift is from "we added a cache" to "what fraction of reads does it serve, and what happens on a miss". A low hit rate is not neutral — it is a net loss, since every miss pays the cache round trip *and* the database. Two shapes decide the rate: how long the entry lives, and how many distinct keys exist. A key that includes a user id and a timestamp is a key that is almost never hit twice, which is how a cache ends up with a 2% hit rate and a straight face.

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

What we're doing: Separate the three costs on one endpoint, then fix each with the tool that actually addresses it.

reports/views.pypython
class OrderReportView(APIView):
    def get(self, request):
        month = int(request.query_params["month"])

        # 1. QUERY COST — measured with EXPLAIN, fixed with an index
        #    Before: Seq Scan on orders  (cost=0.00..48210.00 rows=5012)
        #    After:  Index Scan using orders_month_idx  (cost=0.29..812.44)
        qs = Order.objects.filter(month=month)

        # 2. ROW COUNT — the ceiling on everything below
        page = Paginator(qs.order_by("-placed_at", "-id"), 100).page(1)

        # 3. INSTANCE COST — no model objects at all for a read-only report
        rows = (
            qs.filter(pk__in=[o.pk for o in page])
            .select_related("customer")
            .values("id", "total", "placed_at", "customer__name")
        )

        # 4. CACHE — one key per month, not per user or per second
        key = f"report:orders:{month}"
        payload = cache.get(key)
        if payload is None:
            payload = list(rows)
            cache.set(key, payload, timeout=300)
            metrics.increment("report.cache.miss")
        else:
            metrics.increment("report.cache.hit")

        return Response(payload)

# report.cache.hit / (hit + miss) is the number that says whether step 4 helped.
# Below ~50%, every miss paid a Redis round trip AND the database.
5–7
The database half, and the only half `EXPLAIN` can speak to. The plan lines are what an index changed; nothing else in this view affects them.
11
The row count is capped before anything downstream sees it. Note the compound ordering — a non-unique sort key makes page boundaries unstable, so rows repeat or vanish between pages.
14–18
`.values()` returns dicts, so no `Order` instances and no serializer field walk. For a read-only report this removes the entire instance-building cost rather than reducing it.
21–23
The key is scoped to the month alone. Adding `request.user.id` here would give every user a private key, and a key read once has a hit rate near zero.
25–28
Counting hits and misses is what makes the cache claim checkable. Without these two counters, "we added caching" is an assertion nobody can evaluate later.

Why this works: Each numbered step targets a different cost with the only tool that moves it — an index for database time, pagination for row count, `.values()` for instance building, and a shared key for hit rate — and the counters make the last one falsifiable.

Blaming the database when the serializer is the cost

Wrong

python
class OrderSerializer(serializers.ModelSerializer):
    region = serializers.SerializerMethodField()

    def get_region(self, obj):
        return obj.customer.region.name       # 2 queries per row, from inside the serializer

# SQL log: "the main query took 3 ms" -> "the database is fine, must be the network"

Better

python
qs = Order.objects.select_related("customer__region")     # 1 query, joined

class OrderSerializer(serializers.ModelSerializer):
    region = serializers.CharField(source="customer.region.name", read_only=True)

What you see: The slow-query log is empty, the main query takes milliseconds, and the endpoint takes four seconds. The investigation moves to the network or the load balancer, because the database has been ruled out on the wrong evidence.

Why: A `SerializerMethodField` runs once per row and can do anything, including a lazy relation lookup that issues its own queries. Those queries are individually fast, so no slow-query log records them and the "main query" duration stays small — the cost is in the *count*, spread across the serialization phase. Profiling the view rather than the query shows it immediately, and `select_related` on the queryset removes it without touching the serializer's shape.

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

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

Together

python
Order.objects.filter(month=9).values("id", "total")      # no model instances
Order.objects.filter(month=9).only("id", "total")        # light instances
Order.objects.filter(month=9).count()                    # no rows at all

Remember: 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.

See also: the four bottlenecks · memory payload and serialization symptoms · cache backends keys and ttl

Advertisement