Django 6.1 Fetch Modes: The N+1 Fix With a Catch
The endpoint was a DRF list view on a fleet-compliance API: drivers, each with a carrier and a most-recent inspection, three serializer levels deep. Django Debug Toolbar showed a few hundred queries for one page of results. Nothing was broken. The select_related() chain in the viewset had simply stopped matching the fields the serializer touched, which is where every serializer older than about a year ends up — the query lives in the view, the field access lives in the serializer, and no part of Django connects the two.
Django 6.1, released on 5 August 2026, ships a feature aimed squarely at that gap. Fetch modes let you tell a queryset what to do when something later asks for a field the query didn’t load. The release announcement leads with it: “Model field fetch modes for configuring on-demand fetching behavior”. I’ve now put it into two client codebases, and it does roughly what it says — with one boundary that is easy to miss and will make you think it doesn’t work.
What the default has always been
Take the canonical pair:
from django.db import models
class Author(models.Model):
name = models.TextField()
class Book(models.Model):
title = models.TextField()
author = models.ForeignKey(Author, on_delete=models.CASCADE)
for book in books:
print(book.author.name)
Without select_related("author"), that loop runs one query for the books and one more per book for its author. The fetch modes guide names this behaviour FETCH_ONE and documents it as the default: it “fetches the missing field for the current instance only.” The guide is blunt about the result — 1 query for the books plus N for the authors, the N+1 problem, in the framework’s own words.
What’s new is that this is now a named, swappable policy instead of an unwritten law of the ORM.
FETCH_PEERS, and the two-query claim
from django.db import models
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
print(book.author.name)
QuerySet.fetch_mode() sets the mode for every instance the queryset produces. Under FETCH_PEERS, the first book.author access doesn’t fetch one author — it fetches the author for that instance and for every other instance that came out of the same queryset. The guide puts the reasoning plainly: the mode assumes “that if you need a field for one instance, you probably need it for all instances in the same batch, since you’ll likely process them all identically.” Query count for the loop above: 1 for the books, 1 for the authors.
Two details matter more than the headline.
First, the mode propagates. Django copies an instance’s fetch mode onto any related object it fetches, so “the mode applies to a whole tree of relationships, not just the top-level model in the initial QuerySet”. This is the part that actually fixed my serializer. I did not have to predict the traversal depth; Driver.objects.fetch_mode(models.FETCH_PEERS) at the viewset covered the carrier two levels down because the carrier instances inherited the mode on the way through.
Second, peers are held as weak references. The guide notes they’re tracked “in a list of weak references, which avoids memory leaks when some peers are discarded.” That’s the difference between this and keeping the whole batch pinned in memory, and it’s why I’d use it on a paginated list view without worrying much.
You can make it the default for a model with a manager, which is what I ended up doing for the two read-heavy models rather than touching every call site:
from django.db import models
class DriverManager(models.Manager):
def get_queryset(self):
return super().get_queryset().fetch_mode(models.FETCH_PEERS)
class Driver(models.Model):
carrier = models.ForeignKey("Carrier", on_delete=models.CASCADE)
objects = DriverManager()
The relation it does not cover
Here is the boundary. The guide lists exactly what fetch modes apply to: ForeignKey fields, OneToOneField fields and their reverse accessors, fields deferred by defer() or only(), and generic relations.
Reverse foreign keys and many-to-many are not on that list. They go through related managers, and the same guide says the mode is copied into related managers “even though fetch modes don’t affect such managers’ queries”.
So this loop is still N+1 under FETCH_PEERS:
authors = Author.objects.fetch_mode(models.FETCH_PEERS)
for author in authors:
print(author.book_set.count()) # still one query per author
The practical reading: FETCH_PEERS is an on-demand replacement for the select_related() you forgot, not for prefetch_related(). Forward and one-to-one relations get automatic; collections still need you to ask. On the compliance API, that split was clean — drivers → carrier was forward (fixed by the mode), drivers → inspections was reverse (still an explicit prefetch_related). If you switch the mode on, see the query count halve, and conclude it’s unreliable, this is almost certainly why.
FETCH_RAISE, and where I actually get value
The third mode is the one I expect to keep longest. FETCH_RAISE makes any on-demand fetch an error rather than a query: it raises FieldFetchBlocked, new in this release and documented in django.core.exceptions as “raised when a field would be fetched on-demand and the FETCH_RAISE fetch mode is active.”
This is a regression test, not a runtime strategy:
from django.db import models
from django.core.exceptions import FieldFetchBlocked
def test_driver_list_does_not_lazy_load(self):
qs = Driver.objects.select_related("carrier").fetch_mode(models.FETCH_RAISE)
data = DriverSerializer(qs, many=True).data # raises if select_related drifts
self.assertEqual(len(data), 3)
The point is that the test fails the day someone adds carrier.region.name to the serializer without touching the viewset. That is the exact failure mode I started this post with, and until now catching it meant either counting queries with assertNumQueries — which tells you that something changed, not where — or reaching for a third-party library.
Those libraries are still worth knowing, and they’re not obsolete. django-zen-queries does something adjacent but broader: queries_disabled as a context manager or decorator blocks all queries in a block and raises QueriesDisabledError, with escape hatches (queries_dangerously_enabled), a render replacement that disables queries during template rendering, and DRF mixins. FETCH_RAISE only blocks on-demand field fetches on instances from a given queryset. If you want “nothing in this template may touch the database”, the library still wins. If you want “this serializer must not discover a relation I didn’t load”, the built-in is narrower and needs no dependency.
What I’d actually do on an existing codebase
The honest cost-benefit, having done it twice:
- Don’t rewrite your
select_related()calls. An explicit join is still one query whereFETCH_PEERSis two, and the explicit version documents intent at the point of the query. Leave the ones that work. - Add
FETCH_PEERSas a safety net on read paths that fan out — list serializers, admin changelists, anything where the traversal isn’t visible from the queryset. It turns the worst case from linear to constant without you predicting the shape. - Add
FETCH_RAISEto the tests for those same paths. This is the piece that stops the drift coming back. - Check your floor before planning any of it. Django 6.1 supports Python 3.12, 3.13 and 3.14, and it drops PostgreSQL 14, MySQL below 8.4 and MariaDB below 10.11. On a client project, the database floor is usually the thing that decides the timeline, not the feature you wanted.
One scheduling note that caught me out. Django 6.1 is not an LTS release — 5.2 LTS is the current long-term-support series, with extended support into April 2028, while 6.1’s own mainstream support ends well before that. If you’re on 5.2 LTS for a client who pays for stability, fetch modes are a reason to plan the jump, not a reason to make it this quarter.
The broader thing I take from this release is a change in where the N+1 problem is allowed to live. For a decade the fix has been “name every relation you’ll touch, at the top, before you know what you’ll touch.” Fetch modes move part of that decision to the moment of access, where the information actually is. The catch is that it moved only part of it — forward relations, not collections — and the release notes don’t put a box around that. The guide does, in one clause, and it’s the clause worth reading twice.
Sources
- Django 6.1 release notes — Python support, dropped database versions, feature list
- Fetch modes topic guide —
FETCH_ONE,FETCH_PEERS,FETCH_RAISE, propagation and applicability QuerySet.fetch_mode()referenceFieldFetchBlockedin Django core exceptions- Django 6.1 release announcement
- Django supported versions and support dates
- django-zen-queries