Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Django select_related: measure the foreign-key N plus one query pattern

Last updated: 30 Sept 20264 min read
tutorial
IntermediateBy AITrove Editorial

Select_related joins supported single-valued relationships into a queryset so reading those related objects need not issue a query per row.

Download Python source kit

Operation contract

Three stored receipts share one partner. Reading each receipt’s partner from a plain queryset issues the receipt query and three related-object queries. A fresh queryset using select_related retrieves the required fields in one joined query. Both paths return the same region labels, so the fixture checks query count without changing the answer.

Failure and ownership boundary

QuerySets are lazy and can cache evaluated results. Measure a fresh queryset for each comparison; reusing an already evaluated one can hide database work. Select_related is not the tool for every many-valued relation, and a wide join may transfer unused columns. Django models: database constraints survive an unchecked save and Django keyset pagination: stable ordering and a bounded next page should shape the query before optimizing it.

Tested environment

Dependency check: this program was executed on CPython 3.14.6 with Django==5.2.17. Install these versions in a separate virtual environment. The download includes the recorded environment snapshot; no third-party package is part of the website runtime.

Working program

python
from django.conf import settings
settings.configure(INSTALLED_APPS=[], DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
                   DEFAULT_AUTO_FIELD="django.db.models.AutoField", USE_TZ=True)
import django
django.setup()
from django.db import connection, models, IntegrityError, transaction

class DispatchPartner(models.Model):
    region = models.CharField(max_length=3, unique=True)
    class Meta:
        app_label = "receipt_fixture"

class StoredReceipt(models.Model):
    receipt_id = models.CharField(max_length=6, unique=True)
    partner = models.ForeignKey(DispatchPartner, on_delete=models.PROTECT)
    amount = models.IntegerField()
    class Meta:
        app_label = "receipt_fixture"
        constraints = [models.CheckConstraint(condition=models.Q(amount__gte=0), name="receipt_amount_nonnegative")]

with connection.schema_editor() as schema:
    schema.create_model(DispatchPartner)
    schema.create_model(StoredReceipt)

from django.test.utils import CaptureQueriesContext
partner = DispatchPartner.objects.create(region="DEL")
for number in (41, 42, 43):
    StoredReceipt.objects.create(receipt_id=f"R-{number:04d}", partner=partner, amount=125)
with CaptureQueriesContext(connection) as plain_queries:
    plain = [receipt.partner.region for receipt in StoredReceipt.objects.order_by("id")]
with CaptureQueriesContext(connection) as joined_queries:
    joined = [receipt.partner.region for receipt in StoredReceipt.objects.select_related("partner").order_by("id")]
print("same regions:", plain == joined)
print("plain queries:", len(plain_queries))
print("joined queries:", len(joined_queries))
connection.close()

Output

Output
same regions: True
plain queries: 4
joined queries: 1

Costs and limits

The plain path performs 1+n query round trips here. The joined path performs one query but still transfers n result rows and the selected related fields. Query count alone does not measure latency, index use or total transferred bytes.

Common Mistakes

  • Measure fresh querysets rather than cached results.
  • Use prefetching or a different query for many-valued relationships.

Connected lessons

Django models: database constraints survive an unchecked save, Django keyset pagination: stable ordering and a bounded next page, Pandas joins: cardinality validation and missing lookup keys.

Follow the related contract

Django REST Framework object permissions: check the retrieved record, not only login.

python
django-query-counts
Storage details