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?
answer
- implicit batches versus a named plan
- two queries versus one join
- peers exist only for one evaluation
- reverse managers are not covered
basics
~10 sFETCH_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 sAdding `.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 linesfrom 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
Recall that FETCH_PEERS reduces a per-row foreign key loop to two queries without listing relations.
Explain how peers are defined, how the mode propagates to related objects, and why select_related is one query where FETCH_PEERS is two.
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.
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