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 14 – Performance and caching

Django performance: profiling and optimising

Find the slow queries of a Django page with query counts and django-debug-toolbar, then fix them with annotations, indexes, bulk writes and keysets.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: Measure first: a page's queries tell you where its time goes · Five fixes, each for a different kind of slow. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

What you will learn

  • Find a page's slow and repeated queries with CaptureQueriesContext and django-debug-toolbar
  • Read a query plan with explain() and add the index a filter needs
  • Replace loops of saves with bulk_create() and update(), and read large tables with iterator()
  • Use keysets instead of OFFSET to paginate deep lists
  • Measure connection settings and fixes with a repeatable load test

Before you start

What you’ll learn · Before you start

What you will learn

  • Find a page's slow and repeated queries with CaptureQueriesContext and django-debug-toolbar
  • Read a query plan with explain() and add the index a filter needs
  • Replace loops of saves with bulk_create() and update(), and read large tables with iterator()
  • Use keysets instead of OFFSET to paginate deep lists
  • Measure connection settings and fixes with a repeatable load test

Before you start

On this page

The dues report of a tower with 200 flats sent 201 queries. Written as one query, it returned the same rows and, under the same load, answered many times faster, although nothing about the first version looked slow when it was read.

You will measure a page before changing it, with query counts and django-debug-toolbar, then fix the costs you find: an N+1 with an annotation, a scan with an index, a deep page with a keyset, a loop of writes with bulk calls, and a connection per request with persistent connections, each measured again with a load test you can repeat.

Measure first: a page’s queries tell you where its time goes

Most slow Django pages are slow in the database, and the first number to read is how many queries a page sends. CaptureQueriesContext records every query of a block of code. Grouping those queries by their shape, the SQL with its values taken out, shows the loop that sent them. Here is the dues report of tower B, measured as it was and as it became:

The dues report, measured as it was and as it became

nivas/profile_report.py

"""The dues report of tower B measured two ways: every query it sends, captured and grouped by its shape (the SQL with
its values taken out), and the time it takes. The slow report reads the bills one flat at a time; the other asks the
database to count and add them up.

The times change from run to run and from computer to computer; the queries do not.
"""

import re
import time
from collections import Counter

import seed

seed.fresh()

from django.db import connection  # noqa: E402
from django.test.utils import CaptureQueriesContext  # noqa: E402

from society.reports import dues_report, dues_report_slow  # noqa: E402

VALUES = re.compile(r"'[^']*'|\b\d+\b")


def measure(label, report):
    with CaptureQueriesContext(connection) as ctx:
        start = time.perf_counter()
        rows = report('B')
        took = time.perf_counter() - start
    shapes = Counter(VALUES.sub('?', q['sql']) for q in ctx.captured_queries)
    shape, repeats = shapes.most_common(1)[0]
    n = len(ctx.captured_queries)
    print(f'{label}: {n} {"query" if n == 1 else "queries"} in {took * 1000:.0f} ms, {len(rows)} flats')
    if repeats > 1:
        print(f'   {repeats} of them have the same shape: SELECT … FROM "society_bill"{shape[shape.find(" WHERE"):]}')
    return rows


slow = measure('dues_report_slow', dues_report_slow)
fast = measure('dues_report', dues_report)
print('the two reports agree:', slow == fast, '| first row:', fast[0])

Output

dues_report_slow: 201 queries in 216 ms, 200 flats
   200 of them have the same shape: SELECT … FROM "society_bill" WHERE ("society_bill"."flat_id" = ? AND "society_bill"."paid_on" IS NULL)
dues_report: 1 query in 3 ms, 200 flats
the two reports agree: True | first row: ('B-1001', 3, 735000)

This output changes from run to run: the time of each report depends on the computer and how busy it is; the queries and the rows do not

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

society/reports.py · nivas/society/reports.py

from django.db.models import Count, Q, Sum

from .models import Flat

UNPAID = Q(bills__paid_on__isnull=True)


def dues_report_slow(tower):
    """Each flat of the tower with its unpaid bills and what it owes, read one flat at a time."""
    rows = []
    for flat in Flat.objects.filter(tower=tower).order_by('code'):
        unpaid = list(flat.bills.filter(paid_on__isnull=True))
        rows.append((flat.code, len(unpaid), sum(bill.amount_paise for bill in unpaid)))
    return rows


def dues_report(tower):
    """The same report in one query: the database counts and adds up the unpaid bills of every flat."""
    flats = (Flat.objects.filter(tower=tower).order_by('code')
             .annotate(unpaid=Count('bills', filter=UNPAID), owed=Sum('bills__amount_paise', filter=UNPAID, default=0)))
    return [(flat.code, flat.unpaid, flat.owed) for flat in flats]

nivas/seed.py

"""The data the examples of this lesson start from: tower B with 200 flats and a year of monthly bills each, 2,400
bills of which the last three months are unpaid. fresh() builds the tables from nothing first.
"""

import os
from datetime import date

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

import django  # noqa: E402

django.setup()

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


def fresh(flats=200):
    from django.conf import settings

    db = settings.DATABASES['default']['NAME']
    if settings.DATABASES['default']['ENGINE'].endswith('sqlite3') and os.path.exists(db):
        os.remove(db)
    call_command('migrate', verbosity=0)
    from society.models import Bill, Flat

    homes = Flat.objects.bulk_create(Flat(tower='B', code=f'B-{1001 + i}') for i in range(flats))
    Bill.objects.bulk_create(
        Bill(flat=flat, month=date(2026, month, 1), amount_paise=245000 + 100 * (i % 7),
             paid_on=None if month > 9 else date(2026, month, 5))
        for i, flat in enumerate(homes) for month in range(1, 13)
    )

201 queries, 200 of one shape: the unpaid bills of one flat at a time, added up in Python. annotate() with Count and Sum sent 1 query and returned the same rows. Step through the method once more:

Interactive · stage by stage The dues report, measured, fixed and measured again Step through the report from its first measurement to its second.
The dues report, measured, fixed and measured again, step 6 of 6. Showing Count the queries, Group them by shape, Find where rows are read, Let the database add up, Measure again, Look for the next cost.still too slowCount the queries201 for 200 flatsGroup them by shape200 of one shapeFind where rows are readone flat at a timeLet the database add upCount and SumMeasure again1 query, same rowsLook for the next coststill too slow

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

Every step as text
  1. Count first. CaptureQueriesContext around the report counted 201 queries for the 200 flats of tower B, before any guess about indexes or caches.
  2. The same shape, 200 times. With the values taken out, 200 of the queries were the same SQL: the unpaid bills of one flat, WHERE flat_id = ? AND paid_on IS NULL.
  3. Where they come from. The report loops over the flats and asks for each one's bills, then adds them up in Python: an N+1, with the sum done on the wrong side.
  4. One query. annotate() with Count and Sum, both filtered to unpaid bills, asks the database for every flat's count and total in a single GROUP BY query.
  5. Measured again. The new report sent 1 query and returned the same 200 rows, checked row by row against the old one. The time fell with the queries.
  6. Then the next cost. A page that is still too slow has a cost of another kind: a scan without an index, a deep OFFSET, a write per row or a connection per request. Measure for it the same way.

django-debug-toolbar: A development tool that adds a panel to every HTML page of a Django project, for addresses in INTERNAL_IPS, listing the page's SQL queries, templates, cache calls and timings; it is installed as an app with a middleware and never used in production. shows the same numbers on every page during development: a panel per concern, with SQL listing each query and marking similar and duplicate ones. It reads the app’s settings and queries, so it belongs in development settings only. Its own system check catches a half-done install:

The toolbar half installed, then installed

manage.py check · nivas/toolbar_check_session.py

"""django-debug-toolbar half installed, then installed: its own system check names what is missing."""

from session import run

run(
    """
    python manage.py check --settings config.settings_toolbar_half
    python manage.py check --settings config.settings_toolbar
    """
)

Output

$ python manage.py check --settings config.settings_toolbar_half
System check identified some issues:

WARNINGS:
?: (debug_toolbar.W001) debug_toolbar.middleware.DebugToolbarMiddleware is missing from MIDDLEWARE.
	HINT: Add debug_toolbar.middleware.DebugToolbarMiddleware to MIDDLEWARE.

System check identified 1 issue (0 silenced).
$ python manage.py check --settings config.settings_toolbar
System check identified no issues (0 silenced).

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

config/settings_toolbar.py · nivas/config/settings_toolbar.py

"""Development settings with django-debug-toolbar: the app, its middleware as early as possible, and the addresses it
shows itself to. Never in production: the toolbar shows every query and setting of a page to whoever sees it."""

from .settings import *  # noqa: F403

INSTALLED_APPS = [*INSTALLED_APPS, 'debug_toolbar']  # noqa: F405
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware', *MIDDLEWARE]  # noqa: F405
INTERNAL_IPS = ['127.0.0.1']
What the toolbar's SQL panel says about the two reports

nivas/toolbar_report.py

"""What django-debug-toolbar shows for the two reports, read through the test client instead of a browser: the toolbar
is added to every HTML page for an address in INTERNAL_IPS, and its SQL panel, which a browser loads when the panel is
opened, counts the page's queries and how many of them are similar (the same SQL with other values) or duplicates
(the same SQL with the same values).

The toolbar also shows how long each query took; those times change on every run, so they are left out here.
"""

import json
import os
import re

os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings_toolbar'

import seed  # noqa: E402

seed.fresh()

from django.test import Client  # noqa: E402

client = Client()  # its requests come from 127.0.0.1, which INTERNAL_IPS lists
for url in ['/reports/slow/B/', '/reports/B/']:
    page = client.get(url).content.decode()
    request_id = re.search(r'data-request-id="([^"]+)"', page).group(1)
    panels = [re.sub(r'\s+in [\d.]+ms', '', s) for s in re.findall(r'<small>([^<]*)</small>', page)]
    sql = client.get(f'/__debug__/render_panel/?request_id={request_id}&panel_id=SQLPanel')
    text = re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', ' ', json.loads(sql.content)['content']))
    summary = re.search(r'\((\d+ quer[^)]*)\)', text).group(1).strip()
    print(f'{url}: the toolbar says "{panels[4]}"; SQL panel: {summary}')

Output

/reports/slow/B/: the toolbar says "201 queries"; SQL panel: 201 queries including 200 similar
/reports/B/: the toolbar says "1 query"; SQL panel: 1 query

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

config/urls.py · nivas/config/urls.py

from django.conf import settings
from django.urls import include, path

urlpatterns = [
    path('', include('society.urls')),
]

if 'debug_toolbar' in settings.INSTALLED_APPS:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns += debug_toolbar_urls()

debug_toolbar.W001 named the missing middleware; with it added, the check passed. On the two reports, the panel said 201 queries including 200 similar and 1 query, the counts of the program above, without writing any code.

Checkpoint

Check: Measuring a page's queries

  1. Question 1 of 2 The dues report is slow. What does this lesson measure first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: How many queries the report sends, captured with CaptureQueriesContext, and their shapes

    Count the queries, group them by shape, find where the rows are read, fix that, and count again.

  2. Question 2 of 2 The toolbar's SQL panel said 201 queries including 200 similar. What do the 200 similar queries have in common?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The same SQL with different values, here one flat's bills each

    A large count of similar queries is the mark of an N+1; duplicates point at the same data read twice.

Five fixes, each for a different kind of slow

A measurement names the cost, and each cost has its own fix. Django’s documentation on database optimization comes down to a few of them:

What the measurement shows The fix
a query per row, one shape repeated select_related(), prefetch_related() or an annotation, as above
a scan of a whole table for a few rows an index on the columns of the WHERE and ORDER BY
a deep page that gets slower with its number keyset pagination, id > last_seen, instead of OFFSET
a write per row in an import or a batch bulk_create(), bulk_update() or one update()
a connection opened for every request CONN_MAX_AGE or a connection pool

Each fix in one sentence:

  • explain() prints the query plan: The steps a database chooses for running a query, printed by EXPLAIN as a tree of nodes: the scans, the indexes they use, and estimated costs and rows; with ANALYZE, also what the run found and how long it took. More in “Custom managers, raw SQL and EXPLAIN” the database chose for a QuerySet.
  • An index lets the database find rows by a column without reading the table.
  • A keyset pagination: Paginating by the values of the last row seen, such as WHERE id > 900000 ORDER BY id LIMIT 20, instead of skipping rows with OFFSET, so a deep page costs as little as the first one; the order must be unique. page starts after the last row seen, in an order with no ties, so every page costs the same.
  • Bulk calls write many rows in a few statements.
  • Persistent connections let later requests of a worker reuse its connection.

Fix one cost at a time and measure after each, because the next cost is often of another kind.

Checkpoint

Check: Matching a fix to a cost

  1. One question The list of complaints answers page 3 at once and page 4,000 in seconds; every page sends 2 queries. Which fix fits?

    Choose one answer.

    Show the answer: One question

    Answer: A keyset page that starts after the last row seen, instead of OFFSET

    Match the symptom to the cost: a slowdown that grows with the page number is the rows OFFSET skips.

Plans and indexes on a million bills, measured on PostgreSQL 18 Pro: opens with a Pro, Premium or Ultimate pass

An index is worth adding when a plan shows the scan it would remove. This part reads the plans of a million bills before and after a partial index, reproduces the cost of a deep OFFSET, and replaces it with a keyset.

In this part:

  • A million bills: the plan before and after an index
  • OFFSET reads every row it skips; a keyset reads 20
  • A keyset needs a unique order
  • The next page of a list, by keyset
    • Results of the sample tests

1 diagram · 1 runnable example (index_plan.py, pgserver.py) · 1 exercise with sample tests · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Bulk writes and big reads: statements counted, times and memory measured Pro: opens with a Pro, Premium or Ultimate pass

An import or a batch that writes one row at a time pays for every statement. This part writes 10,000 meter readings two ways, changes them three ways and reads them back two ways, and counts the statements, the seconds and the memory of each.

In this part:

  • 10,000 inserts, then 10
  • Updates: save(), bulk_update() and one UPDATE
  • Reading big tables with iterator()

1 runnable example (bulk_writes.py, society/models.py) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Connections, pools and a load test you can repeat Pro: opens with a Pro, Premium or Ultimate pass

A fix is only real when the same load, run again, shows it. This part runs one load test against two reports and two connection settings, counts the database connections each server opened, and works out how many connections a deployment needs.

In this part:

  • The same 60 requests, three times
  • One connection per request, or one per worker
  • Sizing connections across servers, workers and threads

1 runnable example (config/settings.py, load_session.py, load_test.js) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise: measuring and fixing slow pages

The quiz covers the free part, measuring a page and choosing a fix, and starts with the idea where your answers have been weakest:

Practice that adapts to you

Practise this lesson

6 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 6 What does CaptureQueriesContext record?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: Every query sent on a connection inside its with block, with its SQL and time

    It is the tool behind assertNumQueries, so a measurement can become a test.

  2. Question 2 of 6 Why group captured queries by shape, the SQL with its values taken out?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: A loop sends the same SQL with other values, so one shape repeated many times points at the loop

    The toolbar does the same and calls such queries similar.

  3. Question 3 of 6 A teammate wants django-debug-toolbar on the production site to investigate a slow page. What do you answer?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Keep it in development settings; reproduce the page there with production-sized data, or use production logs and metrics

    Measure in development with realistic data, and in production with logs and metrics that expose nothing.

  4. Question 4 of 6 A query reads a whole table to return a few rows. Which fix does the table of this lesson give?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: An index on the columns of the WHERE and ORDER BY

    Read the plan with explain() first, to see the scan the index would remove.

  5. Question 5 of 6 A nightly import inserts 50,000 rows with save() and takes most of the night. Which fix fits first?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: bulk_create(), which writes many rows in a few statements

    Each kind of slow has its fix: the table maps the measurement to one.

  6. Question 6 of 6 Why fix one cost at a time and measure after each?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: Each fix changes what the next cost is, and only a measurement says whether a fix helped

    Count, fix, count again: the same loop for each cost the measurement finds.

Interview questions

Warm-up (fresher): What does QuerySet.explain() show, and what do you look for first?

The plan the database chose for the QuerySet’s SQL: which tables it scans, which indexes it uses and how it joins and sorts. Look first for a sequential scan of a large table where only a few rows are wanted, and with analyze=True compare the rows each step read with the rows the query returns.

Three design questions of this module start from this lesson:

  1. Mid to senior

    The committee's dues report takes 4 seconds for a tower of 1,200 flats and times out for the largest society. Walk through how you find the cause and fix it, and how you show the fix worked.

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

  2. Mid to senior

    A nightly CSV import of 200,000 water-meter readings takes 40 minutes and sometimes runs out of memory. It reads the file into a list, then calls save() on each reading. What do you change, and what do you check afterwards?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    After moving to 3 servers with 8 gunicorn workers of 4 threads each, PostgreSQL starts refusing connections at peak hours with "too many clients already". Explain why, size the connections, and choose a fix.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Measure before changing anything: count a page’s queries and group them by shape, with CaptureQueriesContext or django-debug-toolbar in development.
  • Each kind of slow has its own fix: an annotation or a prefetch for a query per row, an index for a scan, a keyset for a deep page, bulk calls for writes per row, kept connections for a connection per request.
  • Fix one cost at a time and measure again after each fix, because the next cost is often of another kind.

Terms in this lesson

django-debug-toolbar
A development tool that adds a panel to every HTML page of a Django project, for addresses in INTERNAL_IPS, listing the page's SQL queries, templates, cache calls and timings; it is installed as an app with a middleware and never used in production.
keyset pagination
Paginating by the values of the last row seen, such as WHERE id > 900000 ORDER BY id LIMIT 20, instead of skipping rows with OFFSET, so a deep page costs as little as the first one; the order must be unique.

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.