DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Django 6.1's FETCH_PEERS collapses a 2,001-query loop into 2

A loop over 2,000 Django model instances that touched a foreign key on each one fired 2,001 queries against Postgres and took, at its fastest run, 1.14 seconds on localhost. That is the N+1 problem everyone who has used Django has hit at least once. Django 6.1, released on 5 August 2026, added a way to fix it without adding select_related() or prefetch_related() calls at every call site: a queryset-level setting called fetch_mode.

I built a small Postgres-backed project, ran the loop both ways, then spent the rest of the afternoon trying to break the new mode. It mostly held up. One combination silently didn't work at all, and I only found it because I went looking for the cases the documentation doesn't mention.

The setup

Two models, a classic one-to-many:

class Author(models.Model):
    name = models.CharField(max_length=100)
    bio = models.TextField(default="")

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
    description = models.TextField(default="")
    price_cents = models.IntegerField(default=999)
Enter fullscreen mode Exit fullscreen mode

50 authors, 2,000 books, seeded with bulk_create into Postgres 17 running in Docker, no cloud, no managed service. Django 6.1.1 on Python 3.12, psycopg2-binary as the driver. I counted queries with django.test.utils.reset_queries() and len(connection.queries), and timed each run with time.perf_counter(), five repetitions per case.

The headline number

def default_n_plus_1():
    total = 0
    for book in Book.objects.all():
        total += len(book.author.name)
    return total

def fetch_peers():
    total = 0
    for book in Book.objects.fetch_mode(models.FETCH_PEERS):
        total += len(book.author.name)
    return total
Enter fullscreen mode Exit fullscreen mode
approach queries fastest of 5 runs
default (FETCH_ONE) 2,001 1,144.6 ms
fetch_mode(FETCH_PEERS) 2 13.1 ms
select_related("author") 1 13.7 ms

FETCH_PEERS took the 2,001-query loop down to 2 queries and cut the wall time by roughly 87x. It landed within a millisecond of select_related, despite firing one extra query, because that second query is a single WHERE id IN (...) batch fetch rather than 2,000 round trips. I checked the actual SQL Django ran:

SELECT "blog_book"."id", "blog_book"."title", "blog_book"."author_id", ... FROM "blog_book"
SELECT "blog_author"."id", "blog_author"."name", "blog_author"."bio" FROM "blog_author" WHERE ("blog_author"."id") IN ((1), (2), (3), ...
Enter fullscreen mode Exit fullscreen mode

That is exactly what prefetch_related() produces, except you didn't have to write it. The documentation's claim that this "works like an on-demand prefetch_related()" and "reduces most cases of the N+1 problem to two queries" held up precisely, down to the query count.

It held for deferred fields too, not just foreign keys. Loading books with .only("id", "title") and then touching the deferred description field, under FETCH_PEERS, also went from 2,001 queries and roughly 1.3 seconds to 2 queries and 14ms.

What it refuses: the many side

FETCH_PEERS documentation lists what it applies to: forward foreign keys, one-to-one fields and their reverse accessors, deferred fields, generic relations. It does not list reverse foreign key managers, and testing confirmed that gap is real, not an oversight in my reading.

def reverse_fk_fetch_peers():
    total = 0
    for author in Author.objects.fetch_mode(models.FETCH_PEERS):
        for book in author.books.all():
            total += len(book.title)
    return total
Enter fullscreen mode Exit fullscreen mode
approach queries
default, author.books.all() per author 51
fetch_mode(FETCH_PEERS), same loop 51

Identical. Setting fetch_mode on the Author queryset does nothing for author.books.all(), because that call returns a fresh RelatedManager queryset rather than fetching a field value. If your N+1 problem is on the "many" side of a relation, fetch_mode will not touch it; you still need prefetch_related("books"). That is a real limitation for anyone reading the feature announcement and assuming it replaces prefetch_related() everywhere.

What it rejects outright

FETCH_RAISE is the third mode, meant to catch accidental lazy loading in code that should already have everything it needs:

book = Book.objects.only("id", "title").fetch_mode(models.FETCH_RAISE).first()
book.description
Enter fullscreen mode Exit fullscreen mode
django.core.exceptions.FieldFetchBlocked: Fetching of Book.description blocked.
Enter fullscreen mode Exit fullscreen mode

Same exception, same message shape, for a blocked forward foreign key: Fetching of Book.author blocked. Both are precise enough to paste into a bug report and know exactly which field and which model tripped it.

The gotcha nobody warns you about

FETCH_PEERS works by looking at every other instance that came out of the same queryset evaluation and batching the missing field across all of them. That requires the queryset to have materialised its full result list in memory, because peer tracking is done through weak references stored on each instance once the list exists.

QuerySet.iterator() exists specifically to avoid that materialisation, for cases where a queryset is too large to hold in memory at once. So I tried combining them:

for book in Book.objects.fetch_mode(models.FETCH_PEERS).iterator(chunk_size=200):
    total += len(book.author.name)
Enter fullscreen mode Exit fullscreen mode

Result: 2,001 queries. Not an error, not a warning, just the full N+1 pattern the feature exists to prevent. I checked the Django source (django/db/models/fetch_modes.py) to confirm this wasn't a bug in my own code:

class FetchPeers(FetchMode):
    def fetch(self, fetcher, instance):
        instances = [p for w in instance._state.peers if (p := w()) is not None]
        if len(instances) > 1:
            fetcher.fetch_many(instances)
        else:
            fetcher.fetch_one(instance)
Enter fullscreen mode Exit fullscreen mode

With .iterator(), each instance's peers list never grows past itself, so len(instances) > 1 is never true, and every access quietly falls through to a single-row fetch. I could not find this interaction called out anywhere in the 6.1 release notes or the fetch modes documentation page I read. Anyone who reaches for .iterator() on a large table for memory reasons, which is exactly when you'd want the query-count win most, gets none of it and no signal that anything went wrong.

What else it costs

FETCH_PEERS batches eagerly, not lazily per access. I built a 2,000-row queryset and touched the author field on only every fourth book, 500 accesses out of 2,000 possible:

books=2000 accessed=500 queries=2
Enter fullscreen mode Exit fullscreen mode

Still 2 queries. The first access to any instance's author field fetches authors for every peer in the queryset, whether or not you go on to touch the rest. That's the right trade-off if you're going to touch most of the set, and a waste if you only needed a slice of it, in which case a plain filtered queryset with select_related would cost less.

Peer tracking also turned out to be correctly scoped even when instances get mixed together after the fact. I built two separate querysets, both under FETCH_PEERS, concatenated their result lists into one Python list, then accessed .author across the combined list:

queries after touching .author on ALL 199 combined elements: 2
Enter fullscreen mode Exit fullscreen mode

Two queries, one per original queryset, not one per Python list. Objects remember which evaluation they came from regardless of how you recombine them afterwards, which is the behaviour I'd want but hadn't seen stated anywhere.

Memory cost was measurable but small at this scale: materialising 2,000 Book instances added about 1.77 MiB over baseline, and the FETCH_PEERS batch fetch of 50 authors added a further 400 KiB. For a table with a genuinely large row count, that first number is the one to watch, since it scales with how many rows you load before touching anything, independent of whether you use FETCH_PEERS at all.

Under 10 concurrent threads, each running its own independent FETCH_PEERS loop against the same Postgres instance, every thread still saw exactly 2 queries, and results were correct in all 10. Per-thread wall time rose from 13ms solo to between 87ms and 179ms under contention, which is Postgres connection load, not a fetch_mode problem: the query-count discipline held regardless of how many callers were doing it at once.

What I got wrong on the way

My first version of the query-counting harness used django.test.utils.CaptureQueriesContext, which slices into connection.queries_log, a deque capped at 9,000 entries. Running the 2,001-query N+1 case five times in a row, inside the same process, blew past that cap partway through, and every benchmark after the cap was hit silently reported 0 queries instead of an error. I only noticed because a select_related case that should have shown 1 query showed 0, which was too suspicious to accept. Switching to reset_queries() before each run, so the deque never had to hold more than one run's worth of history, fixed it. The lesson generalises: any tool that logs to a fixed-size buffer will lie to you quietly once you exceed it, and a suspiciously clean zero is worth more suspicion than a suspiciously large number.

Run it yourself

This needs Python 3.12+ (Django 6.1 requires it) and either a local Postgres or Docker:

docker run -d --name pgtest -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb -p 5432:5432 postgres:17
python3.12 -m venv venv && ./venv/bin/pip install django psycopg2-binary
./venv/bin/django-admin startproject testproj .
# add 'blog' to INSTALLED_APPS, point DATABASES at testdb/postgres/postgres/127.0.0.1
./venv/bin/python manage.py makemigrations blog && ./venv/bin/python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Then the benchmark itself, saved and run as bench.py from inside the project directory (so it can import testproj.settings):

import os, django, time
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings")
django.setup()
from django.db import models, connection
from django.test.utils import reset_queries
from blog.models import Book

def timed(fn, reps=5):
    for _ in range(reps):
        reset_queries()
        t0 = time.perf_counter()
        fn()
        print(len(connection.queries), (time.perf_counter() - t0) * 1000, "ms")

def default_n_plus_1():
    for book in Book.objects.all():
        _ = book.author.name

def fetch_peers():
    for book in Book.objects.fetch_mode(models.FETCH_PEERS):
        _ = book.author.name

timed(default_n_plus_1)
timed(fetch_peers)
Enter fullscreen mode Exit fullscreen mode

What to do with this

If you're on Django 6.1 and have a view or serializer with a loop that touches a related object per instance, fetch_mode(models.FETCH_PEERS) on the queryset is a genuine drop-in fix, cheaper to write than auditing every call site for missing select_related. Don't reach for it on the reverse side of a relation, it won't help there. And if your reason for using .iterator() in the first place was a large table, check your query count after adding fetch_mode, don't assume it's working just because the code runs without error.

Top comments (0)