DEV Community

Cover image for Django 6.1's FETCH_PEERS: Does It Replace select_related and prefetch_related?
Josh Perspective
Josh Perspective

Posted on

Django 6.1's FETCH_PEERS: Does It Replace select_related and prefetch_related?

A while back I wrote about hunting down and fixing N+1 queries in Django using select_related and prefetch_related. Django 6.1, released this August, adds something that goes after the same problem from a different angle: fetch modes. If you haven't looked at this feature yet, here's what it actually does, and where it fits alongside the tools you're probably already using.

What fetch modes are

New in Django 6.1: when your code accesses a model field that wasn't loaded as part of the original query, Django fetches it from the database on demand. This has always been true. What's new is that you can now configure how that on-demand fetch behaves, using QuerySet.fetch_mode().

There are three modes:

from django.db import models

# The existing behavior — fetch the missing field for this instance only
books = Book.objects.fetch_mode(models.FETCH_ONE)

# New — fetch the missing field for every instance from the same queryset, in one extra query
books = Book.objects.fetch_mode(models.FETCH_PEERS)

# New — raise an exception instead of fetching anything
books = Book.objects.fetch_mode(models.FETCH_RAISE)
Enter fullscreen mode Exit fullscreen mode

FETCH_PEERS: an automatic, on-demand prefetch

This is the headline feature, and it directly targets N+1 queries. Here's the classic problem again:

books = Book.objects.all()
for book in books:
    print(book.author.name)  # fires a separate query per book, without select_related
Enter fullscreen mode Exit fullscreen mode

With FETCH_PEERS set on the queryset, the first time any instance in the loop accesses author, Django fetches that field for every instance that came from the same queryset, not just the one being accessed, in a single additional query:

books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
    print(book.author.name)  # 1 query for books, 1 query for all authors = 2 total
Enter fullscreen mode Exit fullscreen mode

100 books goes from 101 queries down to 2, without you needing to know ahead of time which relations the template or serializer will touch. This is the meaningful difference from select_related/prefetch_related: those require you to declare, upfront, exactly which relations you intend to access. FETCH_PEERS reacts to what's actually accessed, at runtime, and fixes the whole batch reactively.

Fetch modes apply to foreign keys, one-to-one fields, fields deferred via defer()/only(), and generic relations, and the mode propagates down through related objects, so setting it once on a queryset applies to the whole tree of relationships it touches, not just the top level.

FETCH_RAISE: turning a silent N+1 into a loud failure

The other genuinely useful mode is FETCH_RAISE, which raises a FieldFetchBlocked exception instead of fetching anything:

books = Book.objects.fetch_mode(models.FETCH_RAISE)
for book in books:
    print(book.author.name)  # raises FieldFetchBlocked instead of quietly querying
Enter fullscreen mode Exit fullscreen mode

This is a guardrail for performance-critical code paths. The kind of endpoint where an accidental extra query per row is expensive enough that you'd rather the code fail loudly in development or CI than degrade quietly in production. If you've ever shipped an N+1 bug that only showed up once real traffic hit it, this is the tool that catches it before it ships, not after.

So does this replace select_related and prefetch_related?

Not exactly, they solve overlapping but distinct problems, and I'd still reach for the explicit tools first in most cases:

  • select_related/prefetch_related are declarative and upfront. You state exactly what you need, Django builds an efficient query (a JOIN for select_related, a second query for prefetch_related) for exactly that. This is still the right choice when you know your access patterns ahead of time, which, in a well-understood view or serializer, is most of the time.

  • FETCH_PEERS is reactive and safer as a fallback than doing nothing. It's genuinely useful in code paths where the exact fields accessed vary; for example, a generic admin view, a flexible reporting endpoint, or third-party/reusable code where you can't predict every relation that'll get touched. It turns what would've been a silent N+1 into a much cheaper 2-query pattern, without requiring you to enumerate every relation.

  • FETCH_RAISE is a testing and code-review tool as much as a runtime one-set it in tests or performance-sensitive views to make sure nothing is accidentally triggering per-row queries, and catch regressions before they ship.

One thing to check if you're upgrading

If you have code using bare select_related() with no arguments (which selects all non-nullable related fields), that usage is now deprecated in Django 6.1 in favor of either naming the fields explicitly or switching to FETCH_PEERS. Worth a quick search through your codebase if you're planning the upgrade. This is an easy one to miss since bare select_related() still works today, it just prints a deprecation warning.

Practical takeaway

If you're on Django 6.1 or planning the upgrade, I wouldn't rip out existing select_related/prefetch_related calls, they're still the more efficient, explicit choice where you already know your access patterns. But FETCH_PEERS is worth reaching for in the gaps: generic or flexible code paths where enumerating every relation upfront isn't practical, and FETCH_RAISE is worth adding to tests on your highest-traffic endpoints as a tripwire against future N+1 regressions.

Either way, this is a genuinely useful addition if N+1 queries have ever bitten you in production, it gives you a second line of defense that doesn't rely on remembering to audit every queryset by hand.

Top comments (0)