Find an N+1 query in a Django list endpoint

An endpoint returning twenty projects can execute one list query followed by twenty owner queries. The response may look correct, so the problem stays hidden until more rows appear. Start by measuring queries around the actual serialization step.
Compare unprepared and eager-loaded access on the same ordered synthetic project records.
Reproduce the relationship access
Assume a toy Django model Project has an owner ForeignKey and a title. With synthetic fixtures created before the measurement:
from django.db import connection
from django.test.utils import CaptureQueriesContext
def project_rows(queryset):
return [
{"title": project.title, "owner": project.owner.username}
for project in queryset
]
with CaptureQueriesContext(connection) as observed:
rows = project_rows(Project.objects.order_by("pk"))
print("unprepared:", len(observed))
with CaptureQueriesContext(connection) as observed:
prepared = project_rows(
Project.objects.select_related("owner").order_by("pk")
)
print("prepared:", len(observed))
assert rows == prepared
The snippet belongs inside a configured Django application with Project imported. Give every fixture an owner and create several distinct owners so accidental reuse cannot hide the relation access. Keep fixture creation outside the measured context.
For a forward single-valued relation, select_related can retrieve the related fields through a join. A many-valued relation generally calls for a different loading strategy. Inspect which relationships the serializer actually traverses before choosing an optimization.
Turn the finding into a useful check
Add a test around the endpoint or serializer that asserts the expected query count for the fixture. Then increase the number of projects. The count should reflect the intended loading strategy rather than grow once per item.
A queryset may already have been evaluated elsewhere in a test, which changes what is measured. Use a fresh queryset for each comparison. Also watch serializer properties that call new filters on a related manager: preloading a relation does not automatically satisfy every subsequent query.
Query count is only one diagnostic. A giant join can transfer unnecessary data or duplicate rows. Once repeated lookups are removed, inspect the database plan and measure representative response behavior before claiming a performance improvement.
When adding a query-count assertion, document why that number is expected. Unrelated authentication queries can change an endpoint-level count without reintroducing the original relation-loading problem. The Nirogidhara project provides related Django application context. Django's database optimization guide recommends measurement, and its QuerySet reference describes relationship-loading APIs.