Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 4 – The ORM in depth: performance, integrity and PostgreSQL

N+1 queries, prefetching and 6.1 fetch modes

Count and fix Django N+1 queries with select_related, prefetch_related, Prefetch and only(), and use the 6.1 fetch modes FETCH_PEERS and FETCH_RAISE.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Detect N+1 queries by counting them with CaptureQueriesContext and assertNumQueries
  • Fix them with select_related() for foreign keys and prefetch_related() or Prefetch for reverse and many-to-many relations
  • Load fewer columns with only() and defer(), and predict what reading a deferred field costs
  • Choose the Django 6.1 fetch modes FETCH_PEERS and FETCH_RAISE for a QuerySet or as a manager's default
  • Write a test that pins a page's query budget and fails when an N+1 comes back

Before you start

What you’ll learn · Before you start

What you will learn

  • Detect N+1 queries by counting them with CaptureQueriesContext and assertNumQueries
  • Fix them with select_related() for foreign keys and prefetch_related() or Prefetch for reverse and many-to-many relations
  • Load fewer columns with only() and defer(), and predict what reading a deferred field costs
  • Choose the Django 6.1 fetch modes FETCH_PEERS and FETCH_RAISE for a QuerySet or as a manager's default
  • Write a test that pins a page's query budget and fails when an N+1 comes back

Before you start

On this page

A notice board that lists 30 notices with the name of each poster sent 31 queries to the database: one for the notices, then one for every poster. Nothing in the code looks wrong, the page is right, and the cost grows with every notice added; with 200 notices it would be 201 queries.

You will count the queries of a loop and see where an N+1 comes from, fix it with select_related(), prefetch_related() and Prefetch, leave columns out with only() and defer() without creating new queries, use the fetch modes new in Django 6.1, FETCH_PEERS and FETCH_RAISE, and pin a page’s query budget in a test. Every count on this page comes from a run on Django 6.1.2, with SQLite 3.51.0 as the database.

A loop over 30 notices sent 31 queries

The board reads 30 notices and, for each, notice.posted_by.name. The rows come from one file that every example imports first: 6 committee members, 30 notices and 43 comments.

seed.py: residents, notices and comments Python · nivas/seed.py
"""The notice board the examples of this lesson read: 6 residents of the committee, 30 notices and 43 comments, the
same on every run.

Importing it starts Django, creates every table with migrate and adds the rows to an empty database; running it as a
script does the same. compact() prints a query's SQL shorter, so that it fits the page: without the double quotes and
without the app prefix of the table names (society_, notices_). The SQL that was sent is the same, with them.
"""

import os
import re
from datetime import UTC, datetime, timedelta

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.core.management import call_command  # noqa: E402

from notices.models import Comment, Notice  # noqa: E402
from society.models import Resident  # noqa: E402

call_command('migrate', verbosity=0)


def compact(sql):
    """SELECT "notices_notice"."title" FROM "notices_notice" -> SELECT notice.title FROM notice"""
    return re.sub(r'"(?:society|notices)_(\w+)"', r'\1', sql).replace('"', '')


COMMITTEE = [('Asha K.', 'A-101'), ('Imran Q.', 'B-101'), ('Meera P.', 'A-1101'),
             ('Rohan M.', 'B-1205'), ('Neha D.', 'B-1203'), ('Vikram B.', 'A-102')]
TOPICS = ['Water tank cleaning', 'Lift maintenance', 'Power cut', 'Parking rules', 'Festival plans', 'Pest control']
START = datetime(2026, 9, 1, 9, 0, tzinfo=UTC)

if not Resident.objects.exists():
    people = [Resident.objects.create(name=name, flat=flat) for name, flat in COMMITTEE]
    for i in range(30):
        notice = Notice.objects.create(
            title=f'{TOPICS[i % 6]} ({i + 1})',
            body=f'Notice {i + 1} of the society. ' + 'Details for the residents of every tower. ' * 40,
            posted_by=people[i % 6],
            posted_at=START + timedelta(days=i),
        )
        for j in range(i % 4):  # 0, 1, 2 or 3 comments
            Comment.objects.create(notice=notice, author=people[(i + j + 1) % 6], text=f'Reply {j + 1}',
                                   posted_at=notice.posted_at + timedelta(hours=j + 1))

This program runs the same loop five ways and counts the queries of each with CaptureQueriesContext, the context manager behind Django’s query-count assertions. Look at the first way, the default, for now:

One loop, five ways, with the queries each one sent Python · nivas/query_counts.py
"""One loop, five ways: the board reads each notice's poster, notice.posted_by.name, and the program counts the
queries each way sends, with the first two of them.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import warnings

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.core.exceptions import FieldFetchBlocked
from django.db import connection, models
from django.test.utils import CaptureQueriesContext

from notices.models import Notice
from seed import compact

ways = {
    'default (FETCH_ONE)': Notice.objects.order_by('posted_at'),
    "select_related('posted_by')": Notice.objects.select_related('posted_by').order_by('posted_at'),
    "prefetch_related('posted_by')": Notice.objects.prefetch_related('posted_by').order_by('posted_at'),
    'fetch_mode(FETCH_PEERS)': Notice.objects.fetch_mode(models.FETCH_PEERS).order_by('posted_at'),
    'fetch_mode(FETCH_RAISE)': Notice.objects.fetch_mode(models.FETCH_RAISE).order_by('posted_at'),
}
for name, notices in ways.items():
    with CaptureQueriesContext(connection) as queries:
        try:
            posters = [notice.posted_by.name for notice in notices]
            result = f'{len(posters)} names'
        except FieldFetchBlocked as error:
            result = f'FieldFetchBlocked: {error}'
    count = f'{len(queries)} {"query" if len(queries) == 1 else "queries"}'
    print(f'{name:<30} {count:>10}  {result}')
    for query in queries[:2]:
        print('   ', compact(query['sql']))

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter('always')
    Notice.objects.select_related()
print('select_related() with no arguments:', caught[0].category.__name__, '|', caught[0].message)

Output

default (FETCH_ONE)            31 queries  30 names
    SELECT notice.id, notice.title, notice.body, notice.posted_by_id, notice.posted_at FROM notice ORDER BY notice.posted_at ASC
    SELECT resident.id, resident.name, resident.flat FROM resident WHERE resident.id = 1 LIMIT 21
select_related('posted_by')       1 query  30 names
    SELECT notice.id, notice.title, notice.body, notice.posted_by_id, notice.posted_at, resident.id, resident.name, resident.flat FROM notice INNER JOIN resident ON (notice.posted_by_id = resident.id) ORDER BY notice.posted_at ASC
prefetch_related('posted_by')   2 queries  30 names
    SELECT notice.id, notice.title, notice.body, notice.posted_by_id, notice.posted_at FROM notice ORDER BY notice.posted_at ASC
    SELECT resident.id, resident.name, resident.flat FROM resident WHERE (resident.id) IN ((1), (2), (3), (4), (5), (6))
fetch_mode(FETCH_PEERS)         2 queries  30 names
    SELECT notice.id, notice.title, notice.body, notice.posted_by_id, notice.posted_at FROM notice ORDER BY notice.posted_at ASC
    SELECT resident.id, resident.name, resident.flat FROM resident WHERE (resident.id) IN ((1), (2), (3), (4), (5), (6))
fetch_mode(FETCH_RAISE)           1 query  FieldFetchBlocked: Fetching of Notice.posted_by blocked.
    SELECT notice.id, notice.title, notice.body, notice.posted_by_id, notice.posted_at FROM notice ORDER BY notice.posted_at ASC
select_related() with no arguments: RemovedInDjango2028Warning | Calling select_related() with no arguments is deprecated. Specify the fields to fetch instead.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 query_counts.py

The first query read the notices, and each row held posted_by_id, the id of its poster, but not the poster. Reading notice.posted_by then needed the resident’s own row, so Django sent SELECT … FROM resident WHERE resident.id = 1, and the same again for the next notice. Step through the loop:

Interactive · message by message One loop over 30 notices, 31 queries Step through the loop. Count the queries as each notice's poster is read for the first time.
One loop over 30 notices, 31 queries, step 6 of 6. Messages so far: Django to SQLite: SELECT … FROM notice; Django to SQLite: SELECT resident WHERE id = 1; Django to SQLite: SELECT resident WHERE id = 2; Django to SQLite: SELECT resident WHERE id = 1; Django to SQLite: SELECT … JOIN resident.Your loop1 query in allDjangofetch mode FETCH_ONESQLiteSELECT … FROM noticeSELECT resident WHERE id = 1SELECT resident WHERE id = 2SELECT resident WHERE id = 1SELECT … JOIN resident

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The notices. Reading the QuerySet sends one SELECT for the 30 notices. Each row holds posted_by_id, the id of its poster, but not the poster.
  2. The first poster. notice.posted_by on the first notice needs the resident's own row, so Django sends a SELECT for resident 1. FETCH_ONE fetches it for this one notice.
  3. The second poster. The second notice is a different object with its own empty cache, so its poster costs another query, for resident 2.
  4. The same poster again. The seventh notice was posted by resident 1 again, and still sends a query: each notice remembers only its own poster, not the posters other notices loaded.
  5. 31 queries. After the 30th notice the loop has sent 1 query for the notices and 30 for their posters: the N+1 of its name, although only 6 residents posted.
  6. The same loop, joined. With select_related('posted_by') the first query joins the resident table, every notice arrives with its poster, and the loop sends nothing more: 1 query.

That is the N+1 queries: One query that reads N rows, then one more query for each of them to read something the first one left out, such as each notice's poster. The number of queries grows with the rows a page reads.: 1 query for N rows, then 1 more for each row. Only 6 residents posted, and it did not matter. Each notice is its own object with its own cache, and the default fetch mode, FETCH_ONE, fetches a missing field for the current instance only. In a template the loop is hidden: {{ notice.posted_by.name }} reads the relation, and Django’s documentation notes that templates call methods for you, so an attribute and a query look alike. Tools such as django-debug-toolbar, which Django’s documentation suggests, list the SQL queries of a page with their times; this lesson counts them in code.

Key idea

An N+1 is one query for the rows and one more per row for something the first query left out, so its cost grows with the rows a page reads, not with how many different related objects there are.

Checkpoint

Check: The N+1 queries problem

  1. Question 1 of 2 Only 6 residents posted the 30 notices. Why did the default loop still send 31 queries?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each notice fetched its own poster, because FETCH_ONE fetches the missing field for one instance only

    1 query for the notices and 1 for the poster of each of the 30: the N+1 of its name. The number of different posters does not matter, the number of notices read does.

  2. Question 2 of 2 The board grows to 200 notices. How many queries does the same default loop send?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 201 queries

    One for the notices and one per notice for its poster: 1 + 200. The count grows with the rows read, which is why an N+1 that is harmless in a test with 3 rows hurts with real data.

Joins for foreign keys, one more query for reverse relations

The second and third ways of the same run fixed the poster loop. select_related('posted_by') joined the resident table into the first query, so every notice arrived with its poster: 1 query. prefetch_related('posted_by') read the notices, then the posters of all of them at once, WHERE resident.id IN (1, 2, 3, 4, 5, 6), and matched them to the notices in Python: 2 queries.

The comments are the other way round. notice.comments is a reverse relation, with many rows per notice, and a join cannot bring a list into one row, so select_related() cannot follow it:

Comments of every notice: prefetch_related(), Prefetch and two traps Python · nivas/comments.py
"""The comments of every notice: a reverse relation, which select_related() cannot follow and fetch modes do not
batch. prefetch_related() and Prefetch objects read them in one more query; filter() on the prefetched comments throws
that away again.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.db import connection, models
from django.db.models import Prefetch
from django.test.utils import CaptureQueriesContext

from notices.models import Comment, Notice
from seed import compact


def count(label, read):
    with CaptureQueriesContext(connection) as queries:
        result = read()
    print(f'{label:<52} {len(queries):>2} {"query" if len(queries) == 1 else "queries"}  {result}')
    return queries


notices = Notice.objects.order_by('posted_at')
count('notice.comments.all(), default', lambda: sum(len(n.comments.all()) for n in notices.all()))
count('notice.comments.all(), FETCH_PEERS',
      lambda: sum(len(n.comments.all()) for n in notices.fetch_mode(models.FETCH_PEERS)))
count('notice.comments.all(), FETCH_RAISE',
      lambda: sum(len(n.comments.all()) for n in notices.fetch_mode(models.FETCH_RAISE)))
queries = count("prefetch_related('comments')",
                lambda: sum(len(n.comments.all()) for n in notices.prefetch_related('comments')))
print('   ', compact(queries[1]['sql']))
count("prefetch_related('comments'), then .count()",
      lambda: sum(n.comments.count() for n in notices.prefetch_related('comments')))
count("prefetch_related('comments'), then .filter()",
      lambda: sum(len(n.comments.filter(text='Reply 1')) for n in notices.prefetch_related('comments')))
thread = Prefetch('comments', queryset=Comment.objects.select_related('author').order_by('posted_at'),
                  to_attr='thread')
queries = count("Prefetch(..., select_related('author'), to_attr)",
                lambda: sorted({c.author.name for n in notices.prefetch_related(thread) for c in n.thread}))
print('   ', compact(queries[1]['sql']))
count("prefetch_related('comments__author')",
      lambda: len({c.author.name for n in notices.prefetch_related('comments__author') for c in n.comments.all()}))

Output

notice.comments.all(), default                       31 queries  43
notice.comments.all(), FETCH_PEERS                   31 queries  43
notice.comments.all(), FETCH_RAISE                   31 queries  43
prefetch_related('comments')                          2 queries  43
    SELECT comment.id, comment.notice_id, comment.author_id, comment.text, comment.posted_at FROM comment WHERE comment.notice_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
prefetch_related('comments'), then .count()           2 queries  43
prefetch_related('comments'), then .filter()         32 queries  22
Prefetch(..., select_related('author'), to_attr)      2 queries  ['Asha K.', 'Imran Q.', 'Meera P.', 'Neha D.', 'Rohan M.', 'Vikram B.']
    SELECT comment.id, comment.notice_id, comment.author_id, comment.text, comment.posted_at, resident.id, resident.name, resident.flat FROM comment INNER JOIN resident ON (comment.author_id = resident.id) WHERE comment.notice_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30) ORDER BY comment.posted_at ASC
prefetch_related('comments__author')                  3 queries  6

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 comments.py

prefetch_related('comments') read the 43 comments of the 30 notices in one more query, WHERE comment.notice_id IN (1, …, 30), and .count() on the prefetched comments used them without a query. .filter() did not: it builds a new QuerySet, which asks the database again, so the prefetch was wasted and the loop sent 32 queries. To load only some related rows, or in an order, give a Prefetch its own queryset, as the next line did, with the authors joined in by select_related('author') and the list kept apart under to_attr='thread': 2 queries. 'comments__author' without a Prefetch took 3, one per level.

The first three lines show what fetch modes do not change: a related manager such as notice.comments sent 31 queries under every mode. Answer three questions about your own loop, and the table marks the fixes that fit:

Interactive · answer and compare Which fix fits your loop? Answer three questions about the loop that sends too many queries. The table marks the fixes that fit.
Which way does the loop follow the relation?
Does the page need every related row as it is, or only some of them, or only a count?
Must the code also run on Django 5.2 LTS?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five fixes for an N+1 and one guard, against what a loop over related rows needs
Fix Foreign key forward Reverse or many-to-many Only some related rows On Django 5.2 LTS
select_related() Queries: 1, a JOIN Good one query with a JOIN: the 30 posters cost nothing more Poor cannot follow a reverse relation, which has many rows per notice Poor joins the related row as it is; there is nothing to filter Good there since long before 5.2
prefetch_related() Queries: 1 more per relation Good works too: a second query with WHERE id IN (…) Good the 43 comments in one more query, joined in Python Poor filter() on the prefetched manager queries again: 32 queries Good there since long before 5.2
Prefetch(queryset=…) Queries: 1 more per relation Mixed works, though select_related() is simpler for a foreign key Good one query, ordered and joined to the authors as you choose Good its queryset filters and orders; to_attr keeps the list apart Good there since long before 5.2
FETCH_PEERS Queries: 1 more when first read Good the first read fetches the posters of all 30 notices: 2 queries Poor related managers are not batched: comments still sent 31 queries Poor fetches the whole related row, the same for every notice Poor new in Django 6.1
annotate(Count(…)) Queries: 1, a GROUP BY Mixed gives numbers about the relation, not its objects Good a comment count per notice in the same query Good Count('comments', filter=Q(…)) counts some rows only Good there since long before 5.2
FETCH_RAISE Queries: none; reading raises Mixed fixes nothing: reading posted_by raised FieldFetchBlocked Mixed related managers' queries are not blocked by it Mixed a guard for tests and hot paths, not a way to load rows Poor new in Django 6.1
How the answers decide
  • “Which way does the loop follow the relation?” Backward or many-to-many, notice.comments: rules out select_related() and FETCH_PEERS. A JOIN cannot bring a list of comments into one row, and fetch modes leave related managers such as notice.comments alone; that loop sent 31 queries under every mode.
  • “Which way does the loop follow the relation?” Backward or many-to-many, notice.comments: rules out FETCH_RAISE. FETCH_RAISE loads nothing, and it does not block the queries of related managers either, so the comments loop still sent its 31 queries.
  • “Which way does the loop follow the relation?” Forward, notice.posted_by: adds a caveat to FETCH_RAISE. FETCH_RAISE loads nothing; reading posted_by raised FieldFetchBlocked. Keep it beside a real fix, in tests and measured code, so a forgotten join fails loudly.
  • “Does the page need every related row as it is, or only some of them, or only a count?” Every related row: rules out annotate(Count(…)). A Count annotation says how many related rows there are, not what they hold; a page that shows the rows needs a join or a prefetch.
  • “Does the page need every related row as it is, or only some of them, or only a count?” Only some, or in an order: rules out prefetch_related(), FETCH_PEERS and select_related(). Filtering a prefetched manager sends new queries; a Prefetch with its own queryset loads exactly the rows the page shows.
  • “Does the page need every related row as it is, or only some of them, or only a count?” Only some, or in an order: rules out annotate(Count(…)). A Count annotation gives a number per row, never the related rows themselves; showing some of them needs a Prefetch with its own queryset.
  • “Does the page need every related row as it is, or only some of them, or only a count?” Only how many: adds a caveat to prefetch_related() and Prefetch(queryset=…). Loading every comment only to count it reads rows the page never shows; a Count annotation answers in the same query.
  • “Must the code also run on Django 5.2 LTS?” Yes, on 5.2 as well: rules out FETCH_PEERS and FETCH_RAISE. Fetch modes are new in Django 6.1; on 5.2 LTS the loop needs select_related(), prefetch_related() and a query-count test.
  • “Which way does the loop follow the relation?” Forward, notice.posted_by and “Does the page need every related row as it is, or only some of them, or only a count?” Every related row: adds a caveat to prefetch_related() and Prefetch(queryset=…). Both work for a foreign key, with a second query; select_related() does it in the first query with a JOIN.

In short: select_related() for a foreign key you follow forwards, prefetch_related() for reverse and many-to-many relations, a Prefetch when only some related rows or an order matter, and a Count annotation when the page needs only how many.

Checkpoint

Check: select_related() and prefetch_related()

  1. Question 1 of 2 Why can select_related() not fix the loop over notice.comments.all()?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: comments is a reverse relation with many rows per notice, and select_related() only follows relations with one related row

    select_related() follows single-valued relations, a ForeignKey or a OneToOneField (in either direction), one related row per row. Reverse foreign keys and many-to-many relations need prefetch_related().

  2. Question 2 of 2 After prefetch_related('comments'), the loop calls notice.comments.filter(text='Reply 1') for each of the 30 notices. How many queries in all?

    Type a number.

    A hint

    Does filter() on a prefetched relation use the prefetched rows?

    Show the answer: Question 2 of 2

    Answer: 32 queries

    The notices and the prefetch were 2 queries, and every filter() built a new QuerySet that asked the database again: 2 + 30 = 32. A Prefetch with its own queryset filters in the prefetch query instead.

only() and defer() leave columns behind, and reading one is a query

A list of notices shows titles, and each notice also has a body of about 1,700 characters that the list never shows. only('title') loads the fields it names, and always the primary key; defer('body') loads every field except the ones it names. A field left out is a deferred field: A model field that a query left out with only() or defer(). Its value stays in the database until code reads it, and that read is a query of its own unless a fetch mode batches it., and reading it later is a query, exactly like a foreign key:

only('title'), then the bodies read in a loop under each fetch mode Python · nivas/deferred.py
"""only() and defer() leave columns out of the SELECT. Reading a column that was left out is a query of its own, the
same N+1 as a foreign key, and the fetch modes of Django 6.1 decide what it costs.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.core.exceptions import FieldFetchBlocked
from django.db import connection, models
from django.db.models import Sum
from django.db.models.functions import Length
from django.test.utils import CaptureQueriesContext

from notices.models import Notice
from seed import compact

with CaptureQueriesContext(connection) as queries:
    titles = [notice.title for notice in Notice.objects.only('title')]
print(f"only('title'), titles read:   {len(queries)} query, {len(titles)} titles")
print('   ', compact(queries[0]['sql']))
total = Notice.objects.aggregate(characters=Sum(Length('body')))['characters']
print(f"the 30 bodies hold {total:,} characters, which only('title') left in the database")

for label, notices in [('default (FETCH_ONE)', Notice.objects.only('title')),
                       ('FETCH_PEERS', Notice.objects.only('title').fetch_mode(models.FETCH_PEERS)),
                       ('FETCH_RAISE', Notice.objects.only('title').fetch_mode(models.FETCH_RAISE))]:
    with CaptureQueriesContext(connection) as queries:
        try:
            sizes = [len(notice.body) for notice in notices]
            result = f'{len(sizes)} bodies'
        except FieldFetchBlocked as error:
            result = f'FieldFetchBlocked: {error}'
    print(f"only('title'), bodies read, {label:<20} {len(queries):>2} {'query' if len(queries) == 1 else 'queries'}"
          f'  {result}')
    if len(queries) == 2:
        print('   ', compact(queries[1]['sql'])[:120], '…')

Output

only('title'), titles read:   1 query, 30 titles
    SELECT notice.id, notice.title FROM notice
the 30 bodies hold 51,171 characters, which only('title') left in the database
only('title'), bodies read, default (FETCH_ONE)  31 queries  30 bodies
only('title'), bodies read, FETCH_PEERS           2 queries  30 bodies
    SELECT notice.id AS pk, notice.body AS body FROM notice WHERE notice.id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1 …
only('title'), bodies read, FETCH_RAISE           1 query  FieldFetchBlocked: Fetching of Notice.body blocked.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 deferred.py

Code SELECT Reading notice.body for all 30
Notice.objects.all() every column, bodies included nothing more: 1 query in all
only('title') notice.id, notice.title 1 query per body: 31 in all
only('title') with FETCH_PEERS notice.id, notice.title one query for all 30 bodies: 2 in all
only('title') with FETCH_RAISE notice.id, notice.title FieldFetchBlocked at the first body

Leaving the bodies out kept 51,171 characters in the database, which is the point of only(). Reading them in a loop turned 1 query into 31, which Django’s documentation calls a pessimization: defer only what the page will not read, such as large text, and measure before and after.

Deferring a field the template reads

only('title') on the board’s QuerySet, with a template that also prints {{ notice.body|truncatewords:20 }}, sends one query per notice for the bodies. Defer only the fields no code on the page reads, and check with a query count, not by reading the view.

Checkpoint

Check: only() and defer()

  1. Question 1 of 2 Which columns did Notice.objects.only('title') read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: id and title

    The 30 bodies, 51,171 characters, stayed in the database. Reading one of them later costs a query of its own.

  2. Question 2 of 2 A loop over Notice.objects.only('title') reads notice.body of all 30 notices, in the default fetch mode. How many queries does it send?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 31 queries

    A deferred field is fetched on demand like a foreign key: 1 query for the titles, 1 per body. Without only(), the same loop takes 1 query, which is why the documentation calls careless deferring a pessimization.

Fetch modes decide what reading a missing field costs

Django 6.1 made the on-demand fetch configurable. A fetch mode: Django 6.1's setting of a QuerySet, chosen with fetch_mode(), for reading a field the query did not load: FETCH_ONE fetches it for one instance, FETCH_PEERS for every instance of the same QuerySet, FETCH_RAISE refuses., set with QuerySet.fetch_mode(), decides what happens when code reads a field the query did not load: a foreign key, a one-to-one field and its reverse accessor, a deferred field or a generic relation. The fourth and fifth ways of query_counts.py used the two new ones, and the first read of a poster goes three ways:

Interactive · message by message What the first notice.posted_by costs in each fetch mode Step through the first read of notice.posted_by in a loop over 30 notices, once for each fetch mode.
What the first notice.posted_by costs in each fetch mode, step 5 of 5. Messages so far: Your loop to Django: notice.posted_by; Django to SQLite: WHERE id = 1; Django to SQLite: WHERE id IN (1, …, 6); Django to Your loop: FieldFetchBlocked.Your loopthe first noticeDjangocopied to related rowsSQLitenotice.posted_byWHERE id = 1WHERE id IN (1, …, 6)FieldFetchBlocked

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A field that was not loaded. The 30 notices came from one query, which held posted_by_id but not the resident. Reading notice.posted_by is a field Django must fetch, and the fetch mode says how.
  2. FETCH_ONE, the default. FETCH_ONE fetches the poster of this one notice: SELECT … WHERE id = 1. Every other notice will do the same when the loop reaches it, 30 queries in all.
  3. FETCH_PEERS. FETCH_PEERS fetches the poster of every notice that came from the same QuerySet, its peers, in one query: WHERE id IN (1, 2, 3, 4, 5, 6). The other 29 reads cost nothing.
  4. FETCH_RAISE. FETCH_RAISE fetches nothing and raises FieldFetchBlocked: "Fetching of Notice.posted_by blocked." The loop stops at the first notice, after the 1 query that read the notices.
  5. The mode travels. Objects fetched from an instance get its mode: comments read with FETCH_PEERS fetched their notices and those notices' posters the same way, 3 queries instead of 87.

FETCH_PEERS fetched the poster of every notice from the same QuerySet, its peers, when the first one was read: the same IN query that prefetch_related() sent, without naming the relation in advance. FETCH_RAISE refused with FieldFetchBlocked: Fetching of Notice.posted_by blocked., and the deferred bodies too, “Fetching of Notice.body blocked.” Use it where an extra query must never happen unnoticed: in tests, and in code you have measured.

A mode can be a model’s default through its manager, and it travels: objects fetched from an instance get its mode.

A manager that starts every QuerySet with FETCH_PEERS, and the mode copied to related objects

nivas/fetch_mode_default.py

"""A fetch mode as the default of a manager, Comment.with_peers in notices/models.py, and how it travels: the comments
are read with FETCH_PEERS, and so are the notices and the residents reached from them. FETCH_RAISE travels the same
way.

Run it next to manage.py. seed.py makes db.sqlite3 there with every table and the lesson's rows.
"""

import seed  # noqa: F401  (starts Django, creates the tables, adds the rows)
from django.core.exceptions import FieldFetchBlocked
from django.db import connection, models
from django.test.utils import CaptureQueriesContext

from notices.models import Comment


for label, queryset in [('Comment.objects, FETCH_ONE', Comment.objects.order_by('pk')),
                        ('Comment.with_peers', Comment.with_peers.order_by('pk'))]:
    with CaptureQueriesContext(connection) as queries:
        posters = {comment.notice.posted_by.name for comment in queryset}
    print(f'{label:<28} {len(queries):>2} queries for comment -> notice -> poster ({len(posters)} posters)')

with CaptureQueriesContext(connection) as queries:
    try:
        for comment in Comment.objects.select_related('notice').fetch_mode(models.FETCH_RAISE):
            comment.notice.title  # loaded by select_related: no query
            comment.notice.posted_by  # one relation further, not loaded
    except FieldFetchBlocked as error:
        print(f'FETCH_RAISE, select_related(notice), then notice.posted_by: {len(queries)} query, {error}')

Output

Comment.objects, FETCH_ONE   87 queries for comment -> notice -> poster (6 posters)
Comment.with_peers            3 queries for comment -> notice -> poster (6 posters)
FETCH_RAISE, select_related(notice), then notice.posted_by: 1 query, Fetching of Notice.posted_by blocked.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fetch_mode_default.py

notices/models.py · nivas/notices/models.py

from django.db import models

from society.models import Resident


class Notice(models.Model):
    title = models.CharField(max_length=120)
    body = models.TextField()
    posted_by = models.ForeignKey(Resident, on_delete=models.PROTECT, related_name='notices')
    posted_at = models.DateTimeField()

    def __str__(self):
        return self.title


class PeersManager(models.Manager):
    """Every QuerySet of this manager starts with the FETCH_PEERS fetch mode of Django 6.1."""

    def get_queryset(self):
        return super().get_queryset().fetch_mode(models.FETCH_PEERS)


class Comment(models.Model):
    notice = models.ForeignKey(Notice, on_delete=models.CASCADE, related_name='comments')
    author = models.ForeignKey(Resident, on_delete=models.CASCADE, related_name='comments')
    text = models.CharField(max_length=300)
    posted_at = models.DateTimeField()

    objects = models.Manager()  # the default manager, as before
    with_peers = PeersManager()  # a model whose every query should batch would declare objects = PeersManager()

    def __str__(self):
        return f'comment {self.pk}'

Comment.with_peers is such a manager. Reading each comment’s notice and that notice’s poster took 87 queries with FETCH_ONE, 1 + 43 + 43, and 3 with FETCH_PEERS: the comments, the notices of all of them, the posters of those notices. FETCH_RAISE travelled the same way: select_related('notice') loaded the notices, and reading notice.posted_by, one relation further, raised.

Fetch modes are new in Django 6.1

Django 5.2 LTS has no fetch_mode(); there, select_related(), prefetch_related() and query-count tests do the same work. Django 6.1 also deprecates select_related() with no arguments, which followed every non-null foreign key: the last line of query_counts.py caught its RemovedInDjango2028Warning. Name the relations, or use FETCH_PEERS. Setting ModelAdmin.list_select_related = True is deprecated as well.

Checkpoint

Check: The 6.1 fetch modes

  1. Question 1 of 2 What does Notice.objects.fetch_mode(models.FETCH_PEERS) send for the loop over notice.posted_by.name?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2 queries, the notices, then the posters of all 30 notices with WHERE id IN

    FETCH_PEERS works like an on-demand prefetch_related(): nothing is planned in advance, and the first read of the field fetches it for the whole batch.

  2. Question 2 of 2 Where does FETCH_RAISE help most?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In tests and hot paths, so that a field read without being loaded fails at once instead of sending a query

    Load what the code needs with select_related(), prefetch_related() or FETCH_PEERS, and let FETCH_RAISE prove that nothing else is read on demand.

A query budget in a test keeps the N+1 from coming back

An N+1 fixed today comes back the day someone adds a field to a template. A test that counts the queries of a page catches that day. assertNumQueries(2) checks that exactly 2 queries run inside its with block; here the block wraps a request to the board, which shows the five newest notices with their posters and comment counts:

The board's query budget as a test: failing, then passing after the fix

The test, run twice · nivas/budget_session.py

"""The query budget of the notice board as a test, run twice in a terminal: against the board as it is, which reads
each notice's poster and comments one notice at a time, and again after the fix in fixed/views.py is copied over it.

unittest prints how long the tests took ("Ran 1 test in 0.021s"); that time changes on every run.
"""

from session import run

run(
    """
    python manage.py test notices
    cp fixed/views.py notices/views.py
    python manage.py test notices.tests
    """,
    fails=('python manage.py test notices',),
)

Output

$ python manage.py test notices
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_board_sends_two_queries (notices.tests.BoardQueryBudget.test_board_sends_two_queries)
The board reads its notices, posters and comments in 2 queries, however many notices it shows.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "notices/tests.py", line 24, in test_board_sends_two_queries
    with self.assertNumQueries(2):
         ~~~~~~~~~~~~~~~~~~~~~^^^
AssertionError: 11 != 2 : 11 queries executed, 2 expected
Captured queries were:
1. SELECT "notices_notice"."id", "notices_notice"."title", "notices_notice"."body", "notices_notice"."posted_by_id", "notices_notice"."posted_at" FROM "notices_notice" ORDER BY "notices_notice"."posted_at" DESC LIMIT 5
2. SELECT "society_resident"."id", "society_resident"."name", "society_resident"."flat" FROM "society_resident" WHERE "society_resident"."id" = 1 LIMIT 21
3. SELECT "notices_comment"."id", "notices_comment"."notice_id", "notices_comment"."author_id", "notices_comment"."text", "notices_comment"."posted_at" FROM "notices_comment" WHERE "notices_comment"."notice_id" = 5
4. SELECT "society_resident"."id", "society_resident"."name", "society_resident"."flat" FROM "society_resident" WHERE "society_resident"."id" = 2 LIMIT 21
5. SELECT "notices_comment"."id", "notices_comment"."notice_id", "notices_comment"."author_id", "notices_comment"."text", "notices_comment"."posted_at" FROM "notices_comment" WHERE "notices_comment"."notice_id" = 4
6. SELECT "society_resident"."id", "society_resident"."name", "society_resident"."flat" FROM "society_resident" WHERE "society_resident"."id" = 1 LIMIT 21
7. SELECT "notices_comment"."id", "notices_comment"."notice_id", "notices_comment"."author_id", "notices_comment"."text", "notices_comment"."posted_at" FROM "notices_comment" WHERE "notices_comment"."notice_id" = 3
8. SELECT "society_resident"."id", "society_resident"."name", "society_resident"."flat" FROM "society_resident" WHERE "society_resident"."id" = 2 LIMIT 21
9. SELECT "notices_comment"."id", "notices_comment"."notice_id", "notices_comment"."author_id", "notices_comment"."text", "notices_comment"."posted_at" FROM "notices_comment" WHERE "notices_comment"."notice_id" = 2
10. SELECT "society_resident"."id", "society_resident"."name", "society_resident"."flat" FROM "society_resident" WHERE "society_resident"."id" = 1 LIMIT 21
11. SELECT "notices_comment"."id", "notices_comment"."notice_id", "notices_comment"."author_id", "notices_comment"."text", "notices_comment"."posted_at" FROM "notices_comment" WHERE "notices_comment"."notice_id" = 1

----------------------------------------------------------------------
Ran 1 test in 0.079s

FAILED (failures=1)
Destroying test database for alias 'default'...
[exit status 1]
$ cp fixed/views.py notices/views.py
$ python manage.py test notices.tests
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.079s

OK
Destroying test database for alias 'default'...

This output changes from run to run: unittest prints how long the tests took, which changes from run to run; everything else is the same on every run

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 budget_session.py

notices/tests.py · nivas/notices/tests.py

from datetime import UTC, datetime, timedelta

from django.test import TestCase

from society.models import Resident

from .models import Comment, Notice


class BoardQueryBudget(TestCase):
    @classmethod
    def setUpTestData(cls):
        asha = Resident.objects.create(name='Asha K.', flat='A-101')
        imran = Resident.objects.create(name='Imran Q.', flat='B-101')
        start = datetime(2026, 9, 1, 9, 0, tzinfo=UTC)
        for i in range(5):
            notice = Notice.objects.create(title=f'Notice {i + 1}', body='…', posted_by=[asha, imran][i % 2],
                                           posted_at=start + timedelta(days=i))
            for j in range(2):
                Comment.objects.create(notice=notice, author=asha, text='Noted', posted_at=notice.posted_at)

    def test_board_sends_two_queries(self):
        """The board reads its notices, posters and comments in 2 queries, however many notices it shows."""
        with self.assertNumQueries(2):
            response = self.client.get('/notices/')
        self.assertContains(response, 'posted by', count=5)

notices/views.py · nivas/notices/views.py

from django.shortcuts import render

from .models import Notice


def board(request):
    """The five newest notices, each with who posted it and how many comments it has."""
    notices = Notice.objects.order_by('-posted_at')[:5]
    return render(request, 'notices/board.html', {'notices': notices})

fixed/views.py · nivas/fixed/views.py

from django.shortcuts import render

from .models import Notice


def board(request):
    """The five newest notices, each with who posted it and how many comments it has."""
    notices = Notice.objects.select_related('posted_by').prefetch_related('comments').order_by('-posted_at')[:5]
    return render(request, 'notices/board.html', {'notices': notices})

nivas/notices/templates/notices/board.html

<ul>
{% for notice in notices %}
  <li>{{ notice.title }}, posted by {{ notice.posted_by.name }}, {{ notice.comments.all|length }} comments</li>
{% endfor %}
</ul>

nivas/session.py

"""Replays a terminal session, so that the page can show exactly what each command printed.

Every line of a script is one command of a single bash session that starts in the folder of the script that calls
run(), so `cd` and `source .venv/bin/activate` work as they do in a terminal, and later lines see their effect. The
session prints "$ " and the command, then what the command printed (standard output and standard error together, as a
terminal shows them), and "[exit status N]" when it fails. A line that starts with "#" is printed as a comment.
Commands whose failure is the point are listed in `fails`; any other failure, or a listed command that succeeds,
makes the session exit with status 1, so a session that went wrong is never recorded as if it had gone right.

pip: when a folder named "wheelhouse" sits in the environment of the Python running this file, as it does where these
sessions are recorded, pip installs from that folder and never from the network (pip's own PIP_NO_INDEX and
PIP_FIND_LINKS settings). On your computer there is no such folder, and pip downloads the same files from PyPI. The
lines in which pip names where it found each file ("Collecting …", "Downloading …", "Processing …", "Looking in
links: …") depend on that, so they are left out; every other line is printed as pip wrote it.

serve() starts a development server the way you would in a second terminal, waits until it answers on its port, runs
the commands that talk to it, stops it as Ctrl+C would (SIGINT to its process group), then prints what the server's
own terminal showed. The date and time in that output are printed as "…", because they change on every run.
"""

import os
import re
import shlex
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path

WHEELHOUSE = Path(sys.prefix) / "wheelhouse"

# pip lines that only say where pip found a file: PyPI on your computer, the wheelhouse where this is recorded.
_PIP_SOURCE = re.compile(r"^\s*(?:Collecting |Downloading |Processing |Looking in links: |Using cached |Obtaining )")
# runserver prints the date and time when it starts, and before each request it logs.
_SERVER_TIME = [
    (re.compile(r"^[A-Z][a-z]+ \d{1,2}, \d{4} - \d{2}:\d{2}:\d{2}$"), "…"),
    (re.compile(r"^\[\d{2}/[A-Z][a-z]{2}/\d{4} \d{2}:\d{2}:\d{2}\]"), "[…]"),
]


def _environment():
    env = {k: v for k, v in os.environ.items() if not k.startswith(("PIP_", "PYTHON", "VIRTUAL_ENV", "DJANGO_"))}
    env["PYTHONDONTWRITEBYTECODE"] = "1"  # no __pycache__ folders in the listings
    env["PYTHONUNBUFFERED"] = "1"  # a server's lines reach its terminal at once
    env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
    if WHEELHOUSE.is_dir():
        env["PIP_NO_INDEX"] = "1"
        env["PIP_FIND_LINKS"] = str(WHEELHOUSE)
    return env


def _shell(commands, cwd, env):
    """Runs the commands in one bash; returns what the terminal showed and each command's exit status."""
    read_end, write_end = os.pipe()
    lines = ["exec 2>&1"]
    for i, command in enumerate(commands):
        if command.startswith("#"):
            lines.append(f"printf '%s\\n' {shlex.quote(command)}")
            continue
        lines.append(f"printf '$ %s\\n' {shlex.quote(command)}")
        lines.append(command)
        lines.append(f'__s=$?; [ "$__s" -ne 0 ] && printf "[exit status %d]\\n" "$__s"; echo "{i} $__s" >&{write_end}')
    with os.fdopen(read_end) as statuses:
        done = subprocess.run(
            ["/bin/bash", "--noprofile", "--norc", "-c", "\n".join(lines)],
            cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, pass_fds=(write_end,),
        )
        os.close(write_end)
        status = {int(i): int(s) for i, s in (line.split() for line in statuses.read().splitlines())}
    shown = [line for line in done.stdout.splitlines() if not _PIP_SOURCE.match(line)]
    return "\n".join(shown) + ("\n" if shown else ""), status


def _check(commands, status, fails):
    wrong = 0
    for i, command in enumerate(commands):
        if command.startswith("#"):
            continue
        failed = status.get(i, 1) != 0
        if failed != (command in fails):
            wrong += 1
    return wrong


def _lines(script):
    return [line.strip() for line in script.strip().splitlines() if line.strip()]


def run(script, fails=()):
    """Runs each line of `script` as one command of a single bash session and prints the session."""
    commands = _lines(script)
    out, status = _shell(commands, Path.cwd(), _environment())
    print(out, end="")
    if _check(commands, status, fails):
        sys.exit(1)


def _answers(port):
    with socket.socket() as s:
        s.settimeout(0.5)
        return s.connect_ex(("127.0.0.1", port)) == 0


def _stop(job):
    """Stops the server and everything it started, as Ctrl+C would; kills it if it has not ended after 15 seconds."""
    for sig in (signal.SIGINT, signal.SIGKILL):
        try:
            os.killpg(job.pid, sig)
        except ProcessLookupError:
            break  # nothing of the group is left
        try:
            job.wait(timeout=15)
            break
        except subprocess.TimeoutExpired:
            continue
    job.wait()


def serve(setup, server, client, port, folder):
    """Runs `setup`, starts `server` in `folder` as a second terminal would, runs `client` against it, stops it."""
    env = _environment()
    before = _lines(setup)
    out, status = _shell(before, Path.cwd(), env)
    print(out, end="")
    if _check(before, status, ()):
        sys.exit(1)
    if _answers(port):
        sys.exit(f"port {port} is already in use: stop what listens there and run the session again")
    where = Path.cwd() / folder
    print(f"$ {server}")
    print("# The server keeps that terminal. In a second terminal:")
    log = where.parent / f".{folder}-server-terminal"
    with open(log, "w") as terminal:
        job = subprocess.Popen(["/bin/bash", "--noprofile", "--norc", "-c", f"exec {server}"], cwd=where, env=env,
                               stdout=terminal, stderr=subprocess.STDOUT, start_new_session=True)
    try:
        deadline = time.monotonic() + 30
        while not _answers(port):
            if job.poll() is not None or time.monotonic() > deadline:
                print(log.read_text(), end="")
                sys.exit("the server did not start")
            time.sleep(0.1)
        after = _lines(client)
        out, status = _shell(after, where, env)
        print(out, end="")
        wrong = _check(after, status, ())
    finally:
        _stop(job)
    print("# Ctrl+C in the first terminal stops the server. That terminal showed:")
    for line in log.read_text().splitlines():
        for pattern, replacement in _SERVER_TIME:
            line = pattern.sub(replacement, line)
        print(line)
    log.unlink()
    if wrong:
        sys.exit(1)

The first run failed with 11 != 2 : 11 queries executed, 2 expected and listed them: the notices, then a resident and the comments of one notice, five times over, sent by the template’s loop. After fixed/views.py was copied over the view, with select_related('posted_by') and prefetch_related('comments'), the same test passed: 2 queries, and 2 for 50 or 500 notices as well, because nothing on the page sends a query per row any more.

Key idea

Fix an N+1 where the rows are fetched, then pin the page’s number of queries in a test, which fails the moment a query per row comes back.

SQL Formatter Paste a query from the failure message to read its columns and joins one clause at a time.

Checkpoint

Check: A query budget in a test

  1. Question 1 of 2 The board test failed with 11 != 2. What were the 11 queries?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 1 for the five notices, then for each notice 1 for its poster and 1 for its comments

    The failure message listed each captured query: the notices, then a resident and the comments of one notice, five times. The fixed view sends the notices with their posters joined, and one query for the comments.

  2. Question 2 of 2 The board is changed to show 50 notices instead of 5. How many queries does the fixed view send?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2

    A query budget that holds for 5 rows holds for 50 when nothing in the page sends a query per row, which is what assertNumQueries(2) proves on a small test database.

Practise: prefetch by hand and spot the repeats

What prefetch_related() does after its second query, and what a query log looks like when a page has an N+1, in plain Python. First match notices to their posters with one call to a stand-in database; then group a page’s queries by shape and find the one that repeats. Each step has tests you can run here and a hint:

Code lab · 2 steps

Prefetch by hand and spot the repeats

Join notices to their posters with a single bulk read, then find the query shape that a page sends again and again.

  1. Read every poster in one query, as prefetch_related does To do
  2. Find the query that repeats, as an N+1 detector does To do

Exercise · Medium · Python

Read every poster in one query, as prefetch_related does

prefetch_related('posted_by') sent two queries in this lesson: the notices, then SELECT … FROM resident WHERE id IN (1, 2, 3, 4, 5, 6), and it joined the two in Python. Do the joining part.

with_posters(notices, load_residents) gets notices as dictionaries, {'title': 'Power cut (3)', 'posted_by_id': 3}, and a function that stands for the database: load_residents(ids) returns the residents with those ids, as {'id': 3, 'name': 'Meera P.'}, in any order. Return (title, name) pairs in the order of the notices.

  • Call load_residents once, with each id once, in ascending order, as the IN (…) of the query lists them.
  • Do not call it at all when there are no notices.
  • A notice whose poster is not among the residents returned gets None for the name.

Starter code · posters.py

def with_posters(notices, load_residents):
    """(title, poster's name) for each notice, in order, reading the residents with as few calls as possible."""
    pairs = []
    for notice in notices:
        found = load_residents([notice['posted_by_id']])
        pairs.append((notice['title'], found[0]['name'] if found else None))
    return pairs
The sample tests · test_posters.py
from posters import with_posters

RESIDENTS = {1: 'Asha K.', 2: 'Imran Q.', 3: 'Meera P.', 4: 'Rohan M.', 5: 'Neha D.', 6: 'Vikram B.'}
NOTICES = [{'title': f'Notice {i + 1}', 'posted_by_id': i % 6 + 1} for i in range(30)]


class Database:
    """Stands for the residents table and remembers every query it was asked."""

    def __init__(self):
        self.calls = []

    def __call__(self, ids):
        self.calls.append(list(ids))
        return [{'id': i, 'name': RESIDENTS[i]} for i in reversed(list(ids)) if i in RESIDENTS]


def test_names_in_order():
    """every notice gets its poster, in the order of the notices"""
    pairs = with_posters(NOTICES, Database())
    assert pairs[:3] == [('Notice 1', 'Asha K.'), ('Notice 2', 'Imran Q.'), ('Notice 3', 'Meera P.')]
    assert len(pairs) == 30


def test_one_query_for_thirty_notices():
    """30 notices, 6 posters: one call with each id once, ascending, like WHERE id IN (1, 2, 3, 4, 5, 6)"""
    db = Database()
    with_posters(NOTICES, db)
    assert db.calls == [[1, 2, 3, 4, 5, 6]]


def test_no_notices_no_query():
    """an empty board sends no query at all"""
    db = Database()
    assert with_posters([], db) == []
    assert db.calls == []


def test_unknown_poster():
    """a poster the residents table no longer has gives None"""
    db = Database()
    assert with_posters([{'title': 'Old notice', 'posted_by_id': 99}], db) == [('Old notice', None)]
    assert db.calls == [[99]]
A hint

Collect the ids first, with a set, and sort them; that one list is the only call to load_residents. Then turn what it returns into a dictionary from id to name, and look each notice's id up in it with .get(), which gives None for an id it does not have.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Find the query that repeats, as an N+1 detector does

An N+1 shows in a log of queries as the same statement again and again with a different value each time: … WHERE resident.id = 1 LIMIT 21, then = 2, then = 1 again. Grouping the log by the shape of each statement makes the repeat stand out. Write that grouping.

repeated(queries, at_least=3) gets the SQL of every query of a page, in order, and returns (shape, count) pairs for each shape that appears at least at_least times, the most frequent first; shapes with the same count keep the order in which they first appeared.

  • A shape is the query with every number and every quoted string replaced by ?: 'water' and 42 both become ?, while names such as resident.id or notice_id stay as they are.
  • Spaces are left as they are; only values change.

Starter code · repeats.py

def repeated(queries, at_least=3):
    """(shape, count) for every query shape that appears at least at_least times, the most frequent first."""
    counts = {}
    for sql in queries:
        counts[sql] = counts.get(sql, 0) + 1
    return [(sql, n) for sql, n in counts.items() if n >= at_least]
The sample tests · test_repeats.py
from repeats import repeated

BOARD = ['SELECT notice.id, notice.title FROM notice ORDER BY notice.posted_at DESC LIMIT 5']
for notice_id, resident_id in [(5, 1), (4, 2), (3, 1), (2, 2), (1, 1)]:
    BOARD.append(f'SELECT resident.id, resident.name FROM resident WHERE resident.id = {resident_id} LIMIT 21')
    BOARD.append(f'SELECT comment.id, comment.text FROM comment WHERE comment.notice_id = {notice_id}')


def test_the_board_of_the_lesson():
    """the 11 queries of the slow board: two shapes, 5 times each"""
    assert repeated(BOARD) == [
        ('SELECT resident.id, resident.name FROM resident WHERE resident.id = ? LIMIT ?', 5),
        ('SELECT comment.id, comment.text FROM comment WHERE comment.notice_id = ?', 5),
    ]


def test_strings_are_values():
    """a quoted string is a value, so these three are one shape"""
    log = [f"SELECT id FROM notice WHERE category = '{c}'" for c in ('water', 'power', 'water')]
    assert repeated(log) == [("SELECT id FROM notice WHERE category = ?", 3)]


def test_names_keep_their_digits():
    """digits inside names are not values: notice_2 stays notice_2"""
    log = ['SELECT notice_2.id FROM notice_2 WHERE notice_2.id = 7'] * 3
    assert repeated(log) == [('SELECT notice_2.id FROM notice_2 WHERE notice_2.id = ?', 3)]


def test_below_the_threshold():
    """a shape seen twice is not reported when at_least is 3, and is when it is 2"""
    log = ['SELECT 1 FROM notice WHERE id = 1', 'SELECT 1 FROM notice WHERE id = 2']
    assert repeated(log) == []
    assert repeated(log, at_least=2) == [('SELECT ? FROM notice WHERE id = ?', 2)]


def test_most_frequent_first():
    """the shape seen 4 times comes before the one seen 3 times, though it appeared later"""
    log = ['SELECT a FROM t WHERE id = 1'] * 3 + ['SELECT b FROM t WHERE id = 2'] * 4
    assert repeated(log) == [('SELECT b FROM t WHERE id = ?', 4), ('SELECT a FROM t WHERE id = ?', 3)]
A hint

A regular expression can find the values: quoted strings first, '[^']*', then numbers that are not part of a name, \b\d+\b. re.sub with ? gives the shape. A dictionary keeps the order in which keys were first added, so counting into one and then sorting by count alone, with sorted(…, key=…, reverse=True), keeps ties in that order.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the quiz on the five ideas of this lesson, from the N+1 itself to the query budget; it starts with the idea where your answers have been weakest:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 What is the N+1 queries problem?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: One query for N rows, then one more query for each of them, to read something the first query left out

    The extra queries come from reading a related object or a deferred field on each row, and their number grows with the rows.

  2. Question 2 of 10 The view code has no loop, yet the board sent 11 queries. Where did the loop that sent them come from?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The template's for loop, through {{ notice.posted_by.name }} and notice.comments.all

    Django's documentation notes that templates call callables automatically, which hides the difference between an attribute and a query. Count the queries of the rendered page, not of the view's code.

  3. Question 3 of 10 What did prefetch_related('comments') add to the notices query?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: A second query, SELECT … FROM comment WHERE comment.notice_id IN (1, 2, …, 30)

    prefetch_related() does a separate lookup for each relationship and does the joining in Python, as Django's documentation puts it.

  4. Question 4 of 10 How many queries did prefetch_related('comments__author') send for the 30 notices, their comments and the comments' authors?

    Type a number.

    Show the answer: Question 4 of 10

    Answer: 3 queries

    One per level: the notices, the comments of all notices, the authors of all comments. The Prefetch with select_related('author') did the same in 2, by joining the authors into the comments query.

  5. Question 5 of 10 When does only() or defer() pay off?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: When a list shows a few fields and leaves out a large one, such as the notice bodies, and never reads it

    Django's documentation advises deferring only fields you know you won't need, such as large text, and profiling first; read a deferred field in a loop and the saving turns into an N+1.

  6. Question 6 of 10 A page lists titles with only('title') and, for some notices, shows the body. Which change keeps the bodies it shows to one more query?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: fetch_mode(models.FETCH_PEERS) on the QuerySet

    Fetch modes cover deferred fields as well as foreign keys: FETCH_PEERS read the 30 bodies with 2 queries in all, FETCH_RAISE refused with "Fetching of Notice.body blocked."

  7. Question 7 of 10 Which fetch mode does a QuerySet have when nothing sets one?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: FETCH_ONE, which fetches a missing field for the current instance only

    Fetch modes are new in Django 6.1 and opt-in: FETCH_ONE is what every earlier version did.

  8. Question 8 of 10 How do you make FETCH_PEERS the default for every query of a model?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: A manager whose get_queryset() returns super().get_queryset().fetch_mode(models.FETCH_PEERS), declared as objects

    Comment.with_peers read the comments, their notices and the notices' posters in 3 queries, because the mode is copied to the related objects fetched from an instance.

  9. Question 9 of 10 What does with self.assertNumQueries(2): check in the board test?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: That exactly 2 queries are sent on the default database inside the with block

    On failure it lists the captured queries, which is how the board's 11 queries could be read one by one.

  10. Question 10 of 10 The same app must also run on Django 5.2 LTS, which has no fetch modes. What guards it against new N+1 queries there?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: select_related() and prefetch_related() in the views, and tests that pin each page's queries with assertNumQueries

    The fix and its test of this lesson work the same on 5.2; FETCH_PEERS and FETCH_RAISE are 6.1 conveniences on top of them.

Interview questions

Warm-up (fresher): When do you use select_related() and when prefetch_related()?

select_related() follows a foreign key or a one-to-one field, a relation with one related row, in the same query, with a JOIN: each notice arrives with its poster, 1 query in all. prefetch_related() is for reverse foreign keys and many-to-many relations, where each row has many related rows: it sends one more query per relation, with WHERE … IN (…), and joins the results in Python, so 30 notices and their comments take 2 queries. It also works for foreign keys. A Prefetch object gives the second query its own filter, order or to_attr.

Two design questions of this module start from this lesson: a page that sends far more queries than it shows rows, and a proposal to make one fetch mode the default for every model.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    The notice board shows 30 notices, each with its poster's name and its number of comments, and the page sends 61 queries. Explain how you find where they come from, fix them, and make sure they do not come back after the next change to the template.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A teammate proposes making FETCH_PEERS the default fetch mode of every model, through a common manager, so that nobody has to write select_related() again. What does it fix, what can it make worse, and where would you use FETCH_RAISE instead?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An N+1 is one query for the rows and one more per row for a field the first query did not load; the default fetch mode, FETCH_ONE, fetches it for one instance at a time.
  • select_related() joins forward foreign keys into the first query; prefetch_related() reads reverse and many-to-many relations in one more query per relation, and a Prefetch gives that query its filter and order.
  • only() and defer() leave columns out, and reading a column left out is a query per row unless a fetch mode batches it.
  • FETCH_PEERS fetches a missing field for every peer of the same QuerySet at once, and FETCH_RAISE turns the fetch into FieldFetchBlocked; a manager can make either a model’s default, and related objects inherit it.
  • assertNumQueries pins a page’s query count in a test, so an N+1 that comes back fails the build instead of slowing the page.

Terms in this lesson

deferred field
A model field that a query left out with only() or defer(). Its value stays in the database until code reads it, and that read is a query of its own unless a fetch mode batches it.
fetch mode
Django 6.1's setting of a QuerySet, chosen with fetch_mode(), for reading a field the query did not load: FETCH_ONE fetches it for one instance, FETCH_PEERS for every instance of the same QuerySet, FETCH_RAISE refuses.
N+1 queries
One query that reads N rows, then one more query for each of them to read something the first one left out, such as each notice's poster. The number of queries grows with the rows a page reads.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.