Backend / Web frameworks / Django / 11_select_related_vs_prefetch_related.md

N+1 in Django: select_related, prefetch_related, fetch modes

Updated 4 interview angles 6 min read source
On this page10
  1. The problem, counted
  2. select_related: one query with a JOIN
  3. prefetch_related: a second query, joined in Python
  4. Which one, and when the choice matters
  5. Django 6.1: fetch modes
  6. Where it breaks in production
  7. Detecting it at three layers
  8. The trade not taken
  9. Related
  10. Interview angle

N+1 in Django: select_related, prefetch_related, fetch modes

Article.

An N+1 is one query for a list, then one more query per item when the code touches a relation the list did not load. Django makes it easy to write and hard to see: book.author.name in a template looks like attribute access and is a database round trip.

How do you prove an endpoint has no N+1, and fix one without loading more than you need? Every count below comes from a runnable script on Django 6.1.2 with SQLite: 100 books, each with an author (5 kB bio), an optional editor, two tags and two reviews.

The problem, counted

python
for book in Book.objects.all():          # 1 query
    print(book.author.name)              # +1 per book
Access pattern Queries
book.author.name, lazy 101
select_related("author") 1
book.tags.all(), lazy 101
prefetch_related("tags") 2

Count queries in tests, not by eye:

python
from django.db import connection
from django.test.utils import CaptureQueriesContext

with CaptureQueriesContext(connection) as ctx:
    render_book_list()
assert len(ctx.captured_queries) <= 3, [q["sql"] for q in ctx.captured_queries]

self.assertNumQueries(n) in TestCase, or pytest-django’s django_assert_max_num_queries, do the same. (connection.queries is only filled when DEBUG=True; the context manager works regardless.)

For forward ForeignKey and OneToOneField. Django adds the related table to the query and builds both objects from each row:

text
select_related("author")   ... FROM "shop_book" INNER JOIN "shop_author" ON ...
select_related("editor")   ... FROM "shop_book" LEFT OUTER JOIN "shop_author" ON ...

A non-null FK becomes an INNER JOIN; a nullable one a LEFT OUTER JOIN, so books without an editor are kept with book.editor = None. It follows chains (select_related("author__publisher")); called with no arguments it follows every non-null FK, which is rarely what you want.

For reverse FKs, many-to-many, and generic relations. Django runs the main query, collects the primary keys, then runs one query per relation:

text
SELECT ... FROM "shop_book"
SELECT ("shop_book_tags"."book_id") AS "_prefetch_related_val_book_id", "shop_tag".*
  FROM "shop_tag" INNER JOIN "shop_book_tags" ON ...
  WHERE "shop_book_tags"."book_id" IN (...)

The results are matched to parents in Python and stored in each instance’s prefetch cache, which book.tags.all() then reads.

What keeps the prefetch, and what throws it away

After prefetch_related("reviews") Queries
book.reviews.all() / .count() 2
book.reviews.filter(stars=5) 102
Prefetch("reviews", queryset=..., to_attr="top") 2

.all(), and .count() or .exists() on it, use the cache. Any new queryset method on the related manager (filter, exclude, order_by) builds a fresh query per book, and the prefetch is wasted. Filter inside the prefetch instead:

python
books = Book.objects.prefetch_related(
    Prefetch("reviews", queryset=Review.objects.filter(stars=5), to_attr="top")
)
for b in books:
    b.top            # a plain list, already filtered

to_attr keeps the filtered list separate from book.reviews, so code that expects all reviews is not silently given a subset.

Which one, and when the choice matters

The relation’s direction usually decides: forward FK, select_related; reverse FK or M2M, prefetch_related. But prefetch_related also works on a forward FK, and sometimes it is better:

text
select_related("author"):    ~501 kB of column values for 100 books
prefetch_related("author"):  ~50 kB for the 10 distinct authors

The JOIN repeats each author’s 5 kB bio on every one of its books’ rows. With few distinct parents and wide parent rows, two queries ship a tenth of the data. With many distinct parents and narrow rows, the JOIN wins. Chaining select_related across several to-one hops is fine; joining across several collections multiplies rows and is why Django refuses select_related on M2M.

Django 6.1: fetch modes

Django 6.1 lets a queryset decide what happens when code touches a field it did not load:

python
from django.db import models

Book.objects.fetch_mode(models.FETCH_PEERS)   # load it for all peers at once
Book.objects.fetch_mode(models.FETCH_RAISE)   # refuse: FieldFetchBlocked
# models.FETCH_ONE is the classic behaviour, one query per instance
Lazy access under FETCH_PEERS Queries
book.author.name (forward FK) 2
book.isbn after only("title") 2
book.reviews.all() (reverse FK) 101
book.tags.all() (M2M) 101

FETCH_PEERS turns the forward-FK and deferred-field N+1 into 2 queries without naming the relation, but reverse FKs and M2M still need prefetch_related. FETCH_RAISE is the useful one in tests and development: Fetching of Book.author blocked. points at the exact line that would have queried.

Where it breaks in production

The column-level N+1

Book.objects.only("title") and then a template reading book.isbn: 101 queries, one per book, to load a deferred column. only() and defer() are optimisations that turn into N+1s the moment someone touches another field. FETCH_PEERS or FETCH_RAISE makes this visible.

Filtering a prefetched relation in a loop

A serializer method does obj.reviews.filter(approved=True). The view already prefetches reviews, the query count is still N+1 (102 here), and nobody understands why. Move the filter into a Prefetch with to_attr.

Prefetching too much

prefetch_related on 50,000 parents sends an IN list of 50,000 ids and holds every child in memory: the endpoint got faster and the worker’s memory doubled. Paginate first, or stream with iterator(chunk_size=...), which since Django 4.1 prefetches per chunk: 20-book chunks over 100 books ran 6 queries (one for the books, one per chunk).

Async views

In an async view, touching an unloaded relation does not cost a query, it raises: SynchronousOnlyOperation: You cannot call this from an async context. Load relations explicitly (select_related, prefetch_related, or aprefetch_related_objects); the same mistake in SQLAlchemy async raises MissingGreenlet.

Hidden N+1s

Properties that query (@property def latest_order), permission checks per object, __str__ that reads a relation (admin list pages), DRF SerializerMethodFields. They do not look like relation access at all.

Detecting it at three layers

  • Tests: query-count assertions on every list endpoint, and FETCH_RAISE in the test settings’ querysets where practical. nplusone, often recommended, has had no release since 2018.
  • APM: Sentry’s N+1 detector, or the count of database spans per request in OpenTelemetry traces.
  • Database: in pg_stat_statements an N+1 is a key lookup with a huge calls count and one row per call. There, the loop above cost the database 21 ms and the client 1.1 s in round trips (pg_stat_statements).

The trade not taken

Eager loading still builds every related object in Python. When the page only needs a number or a short list, let the database compute it: annotate(review_count=Count("reviews")), a Subquery for the latest item, or ArrayAgg / JSONBAgg from django.contrib.postgres to return children inside the parent row. values() skips model instances entirely.

Denormalised counters avoid the work at read time. These cost flexibility and sometimes correctness under concurrency, but on hot list endpoints they beat any loading strategy.

Interview angle 4

  • “select_related or prefetch_related?” - select_related joins forward FKs and one-to-ones into the same query (INNER JOIN, LEFT OUTER for nullable). prefetch_related runs one extra IN query per relation and joins in Python, for reverse FKs and M2M. 101 queries became 1 and 2.
    • Follow-up: “When is select_related worse, even for a FK?” - when few parents with wide rows repeat on many children: 501 kB through the JOIN against 50 kB with a prefetch.
  • “prefetch_related made the endpoint faster but memory doubled. Why?” - it loads every child of every parent into Python. Paginate, use iterator(chunk_size=...), or aggregate in the database.
  • “I prefetch reviews and still get N+1. Why?” - .filter() on the related manager ignores the cache: 102 queries. Use Prefetch(queryset=..., to_attr=...).
  • “How do you prove there is no N+1?” - query-count assertions in tests; in production, span counts per request, or pg_stat_statements rows with huge calls and one row per call.
    • Follow-up: “What does Django 6.1 change?” - FETCH_PEERS collapses forward-FK and deferred-field N+1s to 2 queries automatically; FETCH_RAISE turns any lazy fetch into an error. Reverse FKs and M2M still need prefetch_related.