Typorithm · medium

Measure related-object loading during serialization

pythondjangodrfindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Measure related-object loading during serialization

0%

Concept and contract

Use select_related deliberately and verify the whole rendered collection query budget.

A bounded page can still perform excessive database work if each serialized row loads a related object separately. The serializer field looks simple, but reading company.name can issue a query for every role unless the queryset provides the relationship efficiently. Measure the serialized result, not just queryset construction.

The fixture uses real owned Role and Company rows. A public serializer includes a read-only company name through source="company.name". The queryset joins that single-valued relation with select_related, and CaptureQueriesContext measures evaluation while serializer.data is actually accessed.

Mechanism and worked example

Querysets are lazy, so creating one outside the measurement block does not prove that its SQL has run.

Querysets are lazy, so creating one outside the measurement block does not prove that its SQL has run. The test captures the complete data access and compares exact public rows plus the number of executed queries. Two Ukrainian rows can be serialized with one joined query in this fixture.

The scope remains owner and country filtered. Optimization must not widen that selection or change emitted fields. A zero-result query still performs one database read; returning an empty list without querying would be a different implementation and should not be confused with a verified selected scope.

Complete worked example

python

from drf_lab import setup
setup()

from django.db import connection
from django.test.utils import CaptureQueriesContext
from rest_framework import serializers
from drf_lab import reset_fixture
from drf_lab.models import Role

class PublicRole(serializers.ModelSerializer):
    company = serializers.CharField(source="company.name", read_only=True)
    class Meta:
        model = Role
        fields = ["id", "company"]

def inspect_example(country):
    alice, _, _ = reset_fixture()
    selected = Role.objects.filter(owner=alice, country=country).order_by("id").select_related("company")
    with CaptureQueriesContext(connection) as captured:
        rows = list(PublicRole(selected, many=True).data)
    return {"rows": rows, "queries": len(captured)}
Reasoning
Querysets are lazy, so creating one outside the measurement block does not prove that its SQL has run. The test captures the complete data access and compares exact public rows plus the number of executed queries. Two Ukrainian rows can be serialized with one joined query in this fixture. The scope remains owner and country filtered. Optimization must not widen that selection or change emitted fields. A zero-result query still performs one database read; returning an empty list without querying would be a different implementation and should not be confused with a verified selected scope.

Focused decision: context is in the complete example

python

selected = Role.objects.filter(owner=alice, country=country).order_by("id")
selected = selected.select_related("company")
with CaptureQueriesContext(connection) as captured:
    rows = list(PublicRole(selected, many=True).data)
Focused decision
Reconstruct this focused decision after the warm-up. The complete imports, model or application, and test client remain in the worked example. Typing records practice; only the downloadable computer lab executes Python.

Counterexamples and common mistakes

The counterexample removes select_related.

The counterexample removes select_related. The serializer then follows each company relationship separately, producing an N+1 pattern. Even if every row has the same company ID, independently loaded model instances do not automatically share a related-object cache across the collection.

Do not assume every relationship needs the same optimization. select_related suits single-valued foreign keys and one-to-one relations, while prefetch_related supports other shapes with a separate query strategy. Loading every related table eagerly can also waste memory and work.

Controlled mistake: observe the actual wrong behavior

python

from drf_lab import setup
setup()

from django.db import connection
from django.test.utils import CaptureQueriesContext
from rest_framework import serializers
from drf_lab import reset_fixture
from drf_lab.models import Role

class PublicRole(serializers.ModelSerializer):
    company = serializers.CharField(source="company.name", read_only=True)
    class Meta:
        model = Role
        fields = ["id", "company"]

def inspect_example(country):
    alice, _, _ = reset_fixture()
    selected = Role.objects.filter(owner=alice, country=country).order_by("id")
    with CaptureQueriesContext(connection) as captured:
        rows = list(PublicRole(selected, many=True).data)
    return {"rows": rows, "queries": len(captured)}
Reasoning
The counterexample removes select_related. The serializer then follows each company relationship separately, producing an N+1 pattern. Even if every row has the same company ID, independently loaded model instances do not automatically share a related-object cache across the collection. Do not assume every relationship needs the same optimization. select_related suits single-valued foreign keys and one-to-one relations, while prefetch_related supports other shapes with a separate query strategy. Loading every related table eagerly can also waste memory and work.

Independent practice

Expose the owned username as a read-only related field and retain the one-query budget.

Expose the owned username as a read-only related field and retain the one-query budget. Add the serializer field and include owner in select_related. This is one additional relationship, not permission to expose private notes or remove the collection predicates.

Check Ukrainian rows, the Israeli row and an empty United States selection. Verify both the new owner value and the query count. Updating only an expected count would not demonstrate that the serializer stopped triggering per-row loads.

Step 1: Add a deliberate read-only owner field and join it so serialization still needs only one query. Step 2: Retain the working imports and application structure. Change the stated policy, run the supplied practice cases, then explain the newly accepted or rejected boundary.

Practice starter: solve before reading the solution

python

from drf_lab import setup
setup()

from django.db import connection
from django.test.utils import CaptureQueriesContext
from rest_framework import serializers
from drf_lab import reset_fixture
from drf_lab.models import Role

class PublicRole(serializers.ModelSerializer):
    company = serializers.CharField(source="company.name", read_only=True)
    class Meta:
        model = Role
        fields = ["id", "company"]

def inspect_example(country):
    alice, _, _ = reset_fixture()
    selected = Role.objects.filter(owner=alice, country=country).order_by("id").select_related("company")
    with CaptureQueriesContext(connection) as captured:
        rows = list(PublicRole(selected, many=True).data)
    return {"rows": rows, "queries": len(captured)}

# Independent task: Add a deliberate read-only owner field and join it so serialization still needs only one query.
Reasoning
Add a deliberate read-only owner field and join it so serialization still needs only one query.
  • Hint 1: Find the one field or branch that owns this policy.
  • Hint 2: Compare the first changed acceptance case with the retained baseline.

Solution, tests and trade-offs

The solution joins company and owner, and the serializer emits the deliberately selected username.

The solution joins company and owner, and the serializer emits the deliberately selected username. The same request scope and exact public row sequence remain intact. The acceptance cases establish that both related fields can be rendered within one query for this owned schema.

Production latency also depends on indexes, row widths, database contention and network distance. A one-query fixture is not a promise that every real query is fast. Keep semantic tests, query budgets and realistic measurements separate, then choose the smallest optimization justified by evidence.

Practice solution and acceptance checks

python

from drf_lab import setup
setup()

from django.db import connection
from django.test.utils import CaptureQueriesContext
from rest_framework import serializers
from drf_lab import reset_fixture
from drf_lab.models import Role

class PublicRole(serializers.ModelSerializer):
    company = serializers.CharField(source="company.name", read_only=True)
    owner = serializers.CharField(source="owner.username", read_only=True)
    class Meta:
        model = Role
        fields = ["id", "company", "owner"]

def inspect_example(country):
    alice, _, _ = reset_fixture()
    selected = Role.objects.filter(owner=alice, country=country).order_by("id").select_related("company", "owner")
    with CaptureQueriesContext(connection) as captured:
        rows = list(PublicRole(selected, many=True).data)
    return {"rows": rows, "queries": len(captured)}
Reasoning
The solution joins company and owner, and the serializer emits the deliberately selected username. The same request scope and exact public row sequence remain intact. The acceptance cases establish that both related fields can be rendered within one query for this owned schema. Production latency also depends on indexes, row widths, database contention and network distance. A one-query fixture is not a promise that every real query is fast. Keep semantic tests, query budgets and realistic measurements separate, then choose the smallest optimization justified by evidence.

Interview reasoning and next step

Explain why measuring an unevaluated queryset misses the expensive work.

Explain why measuring an unevaluated queryset misses the expensive work. Then identify the serializer access that causes N+1 in the counterexample and explain why identical company IDs do not guarantee cross-instance cache reuse.

Next, protect multi-step writes with a transaction. Efficient reads and atomic writes solve different problems; reducing query count does not provide rollback or protect an invariant across several changes.

Optional stretch — after the core task works: Add one independently reasoned boundary case and explain its expected value before running it.

Computer practice: https://applydjinn.com/typorithm/drf-lab-20261105.zip. Install pinned dependencies in an isolated Python environment. Run verify.py, then change the working exercise baseline and run --practice drf-query-budget. The real DRF clients, serializers, schemas and SQLite transactions use owned fixtures only. No production database, feed, account or paid service is used. This lab executes Python; the PC typing trainer and native reading progress do not execute it.

Use select_related deliberately and verify the whole rendered collection query budget.