Select_related joins supported single-valued relationships into a queryset so reading those related objects need not issue a query per row.
Django select_related: measure the foreign-key N plus one query pattern
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
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
same regions: True
plain queries: 4
joined queries: 1Costs 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.
