Build1 publisher3 min readPublished
FETCH_PEERS trades 1,999 Django round trips for one IN clause
Django 6.1's queryset-level fetch_mode took a 2,001-query loop down to two in a Postgres test and did the same for deferred fields, but not for the reverse foreign key managers on the many side of a relation.
The Engineer · Build desk

What happened
- Django 6.1 shipped on 5 August 2026 with fetch_mode, a queryset-level setting that removes the need for select_related() or prefetch_related() calls at every call site.
- In a Postgres 17 test with 50 authors and 2,000 books, FETCH_PEERS took a foreign key loop from 2,001 queries to 2 and cut wall time by roughly 87x.
- The third mode, FETCH_RAISE, turns a blocked lazy load into a FieldFetchBlocked exception naming the model and field, such as "Fetching of Book.description blocked."
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision A codebase that spread select_related() across call sites can now set the mode where the queryset is constructed, so the query plan is reviewable in one place instead of at every attribute access.
- exposure Anyone who reads the release as a full prefetch_related() replacement ships the N+1 unchanged on the many side, and review will not catch it: the code raises no exception and the query count does not change.
- constraint Peer tracking lives in weak references on materialised instances, so the batching is only available to code that is willing to hold the whole result list in memory.
- capability FETCH_RAISE lets a team enforce query discipline in tests: an accidental lazy load fails loudly at a service boundary instead of quietly costing a round trip in production.
Under FETCH_PEERS, touching a lazily loaded attribute does not go and fetch one row. Django looks at every other instance that came out of the same queryset evaluation and batches the missing field across all of them [16]. In the test that produced two statements: a SELECT over the book table, then a SELECT over the author table with an IN list of ids [7]. The dev.to write-up says that is exactly what `prefetch_related()` produces, minus the call [8]. The documentation describes fetch_mode as working like an on-demand `prefetch_related()`, and claims it "reduces most cases of the N+1 problem to two queries"; both held in this test down to the query count [9][10].
2,001 queries in 1.14 seconds is about 0.57 ms per query [2][1]. FETCH_PEERS did the same work in roughly 13 ms across two queries, about 6.5 ms each [2], so each batch query costs around eleven times an average query from the loop [3]. Query count fell about 1,000-fold; wall time fell about 87-fold [5][5]. It also landed within a millisecond of `select_related`, despite firing one query more [6]. The counts came from `reset_queries()` and `len(connection.queries)`, five repetitions per case [4].
So the 87x is a localhost number. The loop pays connection latency 2,001 times and FETCH_PEERS pays it twice [4]. Point the same code at a managed Postgres a millisecond down the wire and the loop's time grows while the two-query version barely moves.
The fixture matters more than the network. 50 authors and 2,000 books [3] is about 40 books per author [7], so that second query resolves 2,000 attribute accesses out of 50 rows and its IN list holds at most 50 ids [8]. With one author per book, the same code still emits two queries, but the second carries a 2,000-element IN list and returns 2,000 rows.
The mode stops at the many side of a relation. Setting fetch_mode on an `Author` queryset left the query count unchanged when the loop called `author.books.all()`, because that call returns a fresh `RelatedManager` queryset instead of fetching a field value [13]. The documentation lists forward foreign keys, one-to-one fields and their reverse accessors, deferred fields and generic relations; reverse foreign key managers are not on that list [12]. At runtime Django raises no exception, logs no warning, and runs the same query count as before [13]. You still write `prefetch_related("books")` [13].
The deferred-field case is the strongest result in the set. Books loaded with `.only("id", "title")`, then touched on `description`, went from 2,001 queries and roughly 1.3 seconds to 2 queries and 14 ms [11], which is about 93x [6].
FETCH_RAISE is the enforcement half, meant to catch accidental lazy loading in code that should already have everything it needs [14]. A blocked deferred field raises `django.core.exceptions.FieldFetchBlocked` with the message "Fetching of Book.description blocked." [14]; a blocked forward foreign key gives the same shape, "Fetching of Book.author blocked." [15]. Both name the model and the field.
Peer tracking is held as weak references on each instance once the result list exists, so the queryset has to materialise its full result set in memory before any batching can happen [16]. The optimisation is therefore a property of an evaluated list, not of the model. The write-up's account of `QuerySet.iterator()` breaks off mid-sentence [17].
What to watch
- Whether the Django docs add reverse foreign key managers to the FETCH_PEERS page as an explicit exclusion, or a ticket lands to cover them.
- Whether FETCH_RAISE trips on a reverse manager access, which would turn the silent miss into a named exception.
- Measurements on high fan-out data, where the second query carries a multi-thousand-element IN list, and on a network-attached database.