skip to content

In Django 6.1, what does QuerySet.fetch_mode() control, and how do FETCH_ONE, FETCH_PEERS and FETCH_RAISE differ?

level: juniorimportance: should knowfreq 35%

answer

  1. what happens on an unloaded attribute
  2. the default keeps old behaviour
  3. peers share one batch query
  4. an exception instead of a query

basics

~20 s

fetch_mode() decides what happens when code reads a field the query did not load: FETCH_ONE (default) queries for that instance, FETCH_PEERS loads it for every instance from the same QuerySet at once, FETCH_RAISE raises FieldFetchBlocked.

solid answer

~40 s

New in Django 6.1, `QuerySet.fetch_mode(mode)` sets how instances from that QuerySet handle **on-demand loading**: forward `ForeignKey` and `OneToOneField` access, reverse one-to-one access, fields excluded by `defer()`/`only()`, and generic foreign keys. `models.FETCH_ONE` is the default and matches older Django: loop over 100 tickets reading `ticket.assignee` and you get 1 + 100 queries. `models.FETCH_PEERS` loads the missing value for all **peers** — instances from the same QuerySet — on first access, so the same loop takes 2 queries. `models.FETCH_RAISE` raises `django.core.exceptions.FieldFetchBlocked` instead of querying. The mode is copied onto related objects Django fetches, so it covers a whole tree of relations. There is no project setting; make it a model's default with a custom manager whose `get_queryset()` calls `fetch_mode()`.

code

python · 19 lines
python
from django.db import models


class TicketManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().fetch_mode(models.FETCH_PEERS)


class Ticket(models.Model):
    title = models.CharField(max_length=200)
    assignee = models.ForeignKey(
        "auth.User", null=True, on_delete=models.SET_NULL, related_name="tickets"
    )

    objects = TicketManager()


# for t in Ticket.objects.filter(title__icontains="login"):
#     t.assignee  -> first access loads assignees for all tickets in one query

go deeper

for a junior

Recall the three modes and the default: FETCH_ONE queries per instance, FETCH_PEERS batches, FETCH_RAISE raises FieldFetchBlocked.

for a middle

Explain which accesses fetch modes cover, what peers are, how the mode propagates to related objects and how to set a model default.

for a senior

Show where fetch modes leave gaps, notably reverse and many-to-many managers, and how they fit alongside explicit select_related and prefetch_related.

for a principal

Decide whether a team adopts FETCH_PEERS as a default, and how that interacts with upgrade timing from the 5.2 LTS.

## The problem fetch modes address A Django model instance often holds fields it has **not loaded yet**: the object behind a `ForeignKey` (only the `assignee_id` column came back), a reverse `OneToOneField`, or a column skipped with `defer()`/`only()`. Reading such an attribute makes Django go back to the database. Until 6.1 there was one behaviour: fetch it for **this instance**, right now. Django 6.1 makes that behaviour configurable with **fetch modes**. ## Setting a mode `QuerySet.fetch_mode(mode)` returns a new QuerySet whose instances carry that mode: ```python from django.db import models tickets = Ticket.objects.filter(status="open").fetch_mode(models.FETCH_PEERS) ``` Points to know: - The modes live in `django.db.models.fetch_modes` and are re-exported as `models.FETCH_ONE`, `models.FETCH_PEERS`, `models.FETCH_RAISE`. - The mode is stored on each instance as `instance._state.fetch_mode` and **copied onto related objects** Django fetches, so `ticket.assignee.team` follows the same mode as `ticket.assignee`. - There is **no setting** for a project-wide default. To make a mode the default for one model, override `get_queryset()` in a custom manager and call `fetch_mode()` there. - Instances created directly (`Ticket(...)`) start with `FETCH_ONE`. ## The three modes | Mode | On first access to an unloaded field | Loop reading `ticket.assignee` over N tickets | |---|---|---| | `FETCH_ONE` (default) | queries for this instance only | 1 + N queries | | `FETCH_PEERS` | loads the field for every peer still in memory | 2 queries | | `FETCH_RAISE` | raises `FieldFetchBlocked` | exception at the first `ticket.assignee` | **Peers** are the instances produced by the same evaluation of the same QuerySet. Django keeps them in a list of **weak references**, so instances you have discarded are not kept alive or fetched. For a foreign key, the peer load works like an on-demand `prefetch_related()`: one `SELECT ... WHERE id IN (...)`. For a deferred column, it is one query per field that pulls that column for all peers by primary key. ## What fetch modes cover — and what they do not Covered, per the 6.1 documentation: 1. `ForeignKey` fields. 2. `OneToOneField` fields and their reverse accessors. 3. Fields deferred with `defer()` or `only()`. 4. Generic relations (`GenericForeignKey`). Not covered: **related managers** — the reverse side of a `ForeignKey` (`ticket.comments.all()`) and `ManyToManyField` managers. Those calls build a new QuerySet each time and query each time, whatever the mode. The mode is passed on to the objects they return, but the manager query itself is not batched or blocked. A forward foreign key whose column is `NULL` never triggers a fetch in any mode; the attribute is simply `None`. ## A worked count Take 50 open tickets loaded with `only("id", "title", "assignee")` and a loop that prints `ticket.assignee.username` and `ticket.description`: | Mode | Tickets query | Assignee loads | Description loads | Total | |---|---|---|---|---| | `FETCH_ONE` | 1 | 50 | 50 | 101 | | `FETCH_PEERS` | 1 | 1 | 1 | 3 | | `FETCH_RAISE` | 1 | — | — | exception on the first `ticket.assignee` | Two details explain the middle row: - The **foreign key** batch is one `IN` query over the peers' `assignee_id` values, like `prefetch_related("assignee")` run on demand. - The **deferred column** batch is one query that reads `description` for all peers by primary key; a second deferred field would add one more query, not fifty. If some tickets have no assignee, their `ticket.assignee` is `None` without any query in every mode, because there is no key to look up. ## Where it sits among the tuning tools - `select_related()` and `prefetch_related()` remain the **explicit** plan: you name the relations and Django loads them up front. - `FETCH_PEERS` is the **implicit** plan: whatever the loop touches is loaded in batches as it goes. The 6.1 optimisation guide recommends it as the easy first step, and the deprecation of argument-less `select_related()` points to it as the replacement for "join everything". - `FETCH_RAISE` is a **guard**: it turns a silent extra query into a loud failure where you have decided no lazy loads may happen.

  • Does FETCH_PEERS batch ticket.comments.all() on a reverse ForeignKey?
    No. Fetch modes cover forward foreign keys, one-to-one accessors, deferred fields and generic relations. Reverse foreign key and many-to-many managers build a fresh QuerySet on each call, so each ticket still issues its own query; use `prefetch_related()` or an annotation for those.
  • How do you make FETCH_PEERS the default for a Django model?
    Give the model a custom manager whose `get_queryset()` returns `super().get_queryset().fetch_mode(models.FETCH_PEERS)`. Django 6.1 has no project-wide setting for the fetch mode, so it is chosen per QuerySet or per manager.

FETCH_ONE is a waiter who walks to the kitchen separately for each diner's missing side dish; FETCH_PEERS brings every missing side dish for the table in one trip; FETCH_RAISE refuses to walk to the kitchen at all and tells you the order was incomplete.

saying these in an interview costs you the question

  • Says FETCH_PEERS is the default fetch mode in Django 6.1
  • Believes a project setting chooses the fetch mode globally
  • Thinks fetch modes batch reverse ForeignKey and many-to-many managers
  • Claims FETCH_RAISE returns None for unloaded fields
  • Assumes fetch modes exist in Django 5.2 LTS