skip to content

A Django 6.1 ticket-list API runs 101 queries because each row shows its assignee; when is FETCH_PEERS the right fix, and when is explicit loading better?

level: seniorimportance: should knowfreq 30%

answer

  1. implicit batches versus a named plan
  2. two queries versus one join
  3. peers exist only for one evaluation
  4. reverse managers are not covered

basics

~10 s

FETCH_PEERS turns 101 queries into 2 without naming relations, suiting code whose field access varies. Explicit loading wins when needs are known: select_related is one JOIN, and only prefetch_related covers reverse and many-to-many sets.

solid answer

~40 s

Adding `.fetch_mode(models.FETCH_PEERS)` to the list QuerySet makes the first `ticket.assignee` load every peer's assignee in one `IN` query: 2 queries, no list of relations to maintain, and the mode follows into `assignee.team` too. That is the right fix when the serializer or template decides what to touch and changes often. Explicit loading is better when you know the need: `select_related("assignee")` is a single JOIN, and only `prefetch_related()` or an annotation fixes `ticket.comments.all()` or a comment count, because fetch modes do not batch related managers. FETCH_PEERS also only helps across instances of **one evaluation** — `.get()` or code that re-queries per row gains nothing — and it loads lazily, so the second query's timing depends on the loop. A common production pattern is FETCH_PEERS as a safety net plus explicit `select_related`/`prefetch_related` on hot endpoints.

code

python · 22 lines
python
from django.db import models
from django.http import JsonResponse

from helpdesk.models import Ticket


def open_tickets(request):
    tickets = (
        Ticket.objects.filter(status="open")
        .fetch_mode(models.FETCH_PEERS)     # assignee, project: batched on first access
        .prefetch_related("watchers")        # many-to-many: not covered by fetch modes
        .order_by("-created")[:100]
    )
    rows = [
        {
            "title": t.title,
            "assignee": t.assignee.get_full_name() if t.assignee else None,
            "watchers": [w.username for w in t.watchers.all()],
        }
        for t in tickets
    ]
    return JsonResponse({"results": rows})

go deeper

for a junior

Recall that FETCH_PEERS reduces a per-row foreign key loop to two queries without listing relations.

for a middle

Explain how peers are defined, how the mode propagates to related objects, and why select_related is one query where FETCH_PEERS is two.

for a senior

Choose per endpoint: FETCH_PEERS as a safety net, explicit loading where shape is known, annotations or prefetch for reverse managers, and verify with query counts.

for a principal

Set the team default: which models get FETCH_PEERS through their manager, which endpoints must declare an explicit plan, and how that is enforced in review.

## The scenario A support-desk API lists open tickets. Each JSON row includes the ticket's title, status and the assignee's display name. The view does `Ticket.objects.filter(status="open")[:100]` and the serializer reads `ticket.assignee.get_full_name()`. The query log shows **1 query for tickets and 100 for users** — Django's default `FETCH_ONE` mode loading each assignee on first access. ## Fix A: `FETCH_PEERS` ```python from django.db import models qs = Ticket.objects.filter(status="open").fetch_mode(models.FETCH_PEERS)[:100] ``` When the serializer first reads `ticket.assignee`, Django collects every **peer** — the other ticket instances produced by the same evaluation — that has not cached its assignee, and loads them all with one `SELECT ... FROM auth_user WHERE id IN (...)`. Total: **2 queries**. Strengths: - **No relation list to maintain.** If next month the row also shows `ticket.project.name`, that relation is batched too, with no code change in the view. - **It follows the tree.** The mode is copied onto fetched objects, so `ticket.assignee.profile` (a one-to-one) is also loaded in one batch across all assignees. - **Deferred columns too.** If the queryset uses `only()` and the serializer reads a skipped field, that becomes one query per field rather than one per row. ## Fix B: explicit loading ```python qs = Ticket.objects.filter(status="open").select_related("assignee")[:100] ``` `select_related()` joins the user table into the ticket query: **1 query**. For collections, `prefetch_related("watchers")` issues one extra query per relation. ## Choosing | Question | Points to | |---|---| | Is the set of relations touched stable and known? | explicit `select_related`/`prefetch_related` | | Does the serializer/template vary what it reads? | `FETCH_PEERS` | | Does the row need `ticket.comments.all()` or a many-to-many? | `prefetch_related()` or an annotation — fetch modes do not batch related managers | | Is the endpoint hot enough that 1 query vs 2 matters? | `select_related()` | | Must the query plan be reviewable up front? | explicit loading | ## Traps that make `FETCH_PEERS` look broken 1. **One instance, no peers.** `Ticket.objects.fetch_mode(models.FETCH_PEERS).get(pk=7)` has a peer list of one; access behaves like `FETCH_ONE`. 2. **Re-querying per row.** Code that calls `Ticket.objects.get(pk=row.id)` inside the loop creates new, unrelated evaluations; nothing is shared. 3. **Streaming with `iterator()`.** Instances are built one at a time as you loop, so when a row's assignee is first read, its peers are only the rows already built; batching gains little, and a large export still needs an explicit strategy. 4. **Related managers.** `ticket.comments.count()` or `.all()` runs per ticket in every mode. 5. **Discarded instances.** Peers are weak references; instances you have dropped are not fetched. ## Measuring the change Whichever fix you choose, prove it with numbers rather than intuition: - **Count queries before and after.** Wrap the endpoint call in a query-capturing context in a test or shell session and assert the new total — 2 for `FETCH_PEERS`, 1 for `select_related()` on this list. - **Look at the second query.** Under `FETCH_PEERS` it should be a single `WHERE id IN (...)` with as many ids as distinct assignees on the page, not one statement per row. - **Check the join cost.** `select_related()` widens every ticket row with user columns; on a wide user table that can outweigh a second narrow query. `only("title", "assignee__username")` keeps the join narrow. - **Watch for new relations.** After switching to `FETCH_PEERS`, add a test that renders the page with a realistic number of rows, so a future relation added to the serializer shows up as one more query, not N more. ## A pattern that works Many teams adopt `FETCH_PEERS` as a **default** on frequently listed models through a custom manager, so forgotten relations degrade to 2 queries instead of N+1, and keep **explicit** `select_related()`/`prefetch_related()` on the few endpoints where query shape is tuned and measured. Django 6.1 nudges in this direction: calling `select_related()` with no arguments is deprecated, with `FETCH_PEERS` named as the alternative, and `ModelAdmin.list_select_related = True` is deprecated in favour of naming fields.

  • Why does FETCH_PEERS not help on a detail view using get()?
    Peers are the instances from the same QuerySet evaluation. A `get()` produces one instance, so its peer list has a single member and each lazy access behaves exactly like `FETCH_ONE`. On a detail page, `select_related()` is what removes the extra query.
  • How would you show the number of comments per ticket without N+1 under FETCH_PEERS?
    Fetch modes do not touch reverse managers, so `ticket.comments.count()` still queries per row. Annotate instead — `annotate(comment_count=Count("comments"))` — which returns the count in the list query itself.

saying these in an interview costs you the question

  • Believes FETCH_PEERS joins the related table into the first query
  • Thinks FETCH_PEERS batches reverse ForeignKey managers such as comments.all()
  • Expects FETCH_PEERS to help a get() on a single row
  • Treats select_related() and FETCH_PEERS as producing the same number of queries
  • Adds FETCH_PEERS and removes every explicit prefetch without measuring