Debugging slow pages: measure first
"The page is slow" is a symptom, not a diagnosis. Performance bugs are the ones where guessing costs the most: you can spend a day adding caches and indexes to code that only needed one line of queryset. The skill this section trains is turning a vague complaint into a number you can watch go down.
Turn the complaint into a measurement
A useful performance investigation starts with three questions. What exactly is slow (one page, one endpoint, one customer)? How slow, compared with what? And what changed? A ticket rarely answers them, so your first job is to reproduce the slowness yourself with data that looks like the reporter's. A customer with two orders and a customer with two hundred can behave completely differently.
Once you can reproduce it, pick something to measure that will still be meaningful after you change the code. Wall-clock time on your laptop is noisy. The number of database statements a request runs is exact, repeatable, and usually what the slowness is made of.
Count queries, not milliseconds
Django will tell you every statement it runs: the debug toolbar, the django.db.backends logger, connection.queries, or CaptureQueriesContext in a test. Load the page once with a small amount of data and once with a lot, and compare the counts. If the count grows with the amount of data, the code is doing work per row that could be done once for all rows. That shape has a name, N+1, and it is the most common cause of pages that get slower as customers get older.
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
client.get("/orders/")
print(len(ctx), "queries")
for q in ctx.captured_queries[:5]:
print(q["sql"][:120])A count that does not grow is a good sign; a count that does is a defect, whatever the timings say. It also gives you the assertion for the regression test you will write at the end.
Laziness is where the cost hides
Django querysets are lazy: nothing hits the database until something evaluates them, and the results are cached on the queryset only after a full evaluation. Related objects are lazy too: order.customer runs a query the first time you touch it, for every order. In templates this is invisible, because {{ order.customer.name }} looks like reading an attribute. The same laziness bites in a subtler way when you call count(), exists() and then iterate: each is its own query on an unevaluated queryset.
Read code with this question in mind: at which line does each queryset actually run, and how many times? The fixes are usually small (select_related, prefetch_related, evaluating once), but you only find the right line by tracing evaluation, not by reading the model definitions.
In the labs that follow, the ticket gives you a symptom and a customer-shaped hint. The workflow is always the same: reproduce, count, find the line that evaluates, fix it there, and write a test that fails on the old code because the count is higher than it should be.