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.
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:
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Count first. CaptureQueriesContext around the report counted 201 queries for the 200 flats of tower B, before any guess about indexes or caches.
- 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.
- 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.
- 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.
- 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.
- 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:
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'] 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
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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
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:
-
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.
-
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?
-
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.
Key takeaways
- Measure before changing anything: count a page’s queries and group them by shape, with
CaptureQueriesContextor 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
- Django documentation: Performance and optimization (Django Software Foundation)
- Django documentation: Database access optimization (Django Software Foundation)
- Django documentation: QuerySet.explain(), bulk_create(), bulk_update(), iterator() (Django Software Foundation)
- Django documentation: Databases, persistent connections and connection pools (Django Software Foundation)
- Django Debug Toolbar documentation (Django Debug Toolbar project)
- PostgreSQL 18 documentation: Using EXPLAIN (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: LIMIT and OFFSET (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: Partial indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: max_connections (The PostgreSQL Global Development Group)
- Grafana k6 documentation (Grafana Labs)
- Gunicorn documentation: Settings, workers (Gunicorn project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress