Performance & Optimization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; everything shown also applies to Django 5.2 LTS except
fetch modes (6.1), which the text points out where they appear. The tools are django-debug-toolbar 8.0.0, pyinstrument
5.1.3, WhiteNoise 6.12.0, Gunicorn 26.2.0 and Uvicorn 0.54.0. Every listing was run in a scratch project on Python 3.13
and Django 6.1.1 with SQLite and 20,000 seeded books: manage.py check and a suite of 4 tests pass, the profilers were
run against real requests, and the HTTP examples were exercised with curl against Uvicorn and Gunicorn. No PostgreSQL
server was available, so the connection-pool settings were only checked with manage.py check, and the pool behaviour
is described from the documentation. All timings below are from one laptop run; absolute numbers move by tens of
percent between runs, so read them for their ratios, not their values.
Django’s own performance guide opens with a warning that is worth repeating: "It’s no good just guessing or assuming where the inefficiencies lie in your code." Most slow Django pages are slow for a handful of reasons — too many queries, a missing index, work repeated on every request that could be cached, or bytes sent over the network that the browser already has. This page shows how to find which one applies, then how Django and a few well-known tools fix each. Several topics have their own pages and are only summarised here: queries in Query Optimization & Fetch Modes, caching in Caching, and async in Async Support. In the Bookshelf application that grows across this section the running example is the "latest books" page, which starts out issuing 51 queries per request.
In a hurry: what goes where
| Symptom or goal | First thing to try | Section |
|---|---|---|
"The site feels slow" with no numbers |
Decide what you are optimising (latency, throughput, memory, database load), then measure one page. |
|
Which page, which query, which function? |
Django Debug Toolbar in development; cProfile or pyinstrument for Python time; a query budget in tests. |
|
Many similar queries on one page |
|
|
One query is slow |
Read |
|
Connection setup shows up in every request |
|
|
The same expensive result is computed again and again |
The caching framework (per-view, fragment, low-level), |
|
Template rendering dominates the profile |
Keep the cached template loader (the default), move logic into the view, prefer |
|
Large responses, repeat downloads |
|
|
CSS/JS/images |
Hashed file names, precompressed files, far-future caching, optionally a CDN. |
|
A view waits on several slow network calls |
An async view that awaits them concurrently. |
Measure first
"Performance" is not one number. A faster page can use more memory, a smaller database load can mean more Python work, and your own time is more expensive than CPU time. Before changing anything, write down what you are optimising and how you will know it improved: the median and 95th-percentile response time of one URL, the number of queries per page, requests per second under a load test, memory per worker. Then follow the same loop every time.
and one page"] --> B["Measure
(toolbar, profiler,
load test)"] B --> C{"Where is
the time?"} C -->|"many or slow queries"| D["Queries and indexes"] C -->|"repeated work"| E["Cache it"] C -->|"templates / Python"| F["Templates and code"] C -->|"network and bytes"| G["HTTP and static files"] C -->|"waiting on I/O"| H["Async or a task queue"] D --> I["Measure again
and lock it in a test"] E --> I F --> I G --> I H --> I I --> A
Django’s guide adds one rule that applies before any tool: work at the appropriate level. The database is better at counting and filtering than Python, and Python is faster than the template language. The three ways of counting the Bookshelf catalogue below give the same number, but at very different cost:
import os
import timeit
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from django.template import Context, Template # noqa: E402
from books.models import Book # noqa: E402
template = Template("{{ books|length }}")
def ms(fn):
return min(timeit.repeat(fn, number=5, repeat=5)) / 5 * 1000
print(f"Book.objects.count() {ms(lambda: Book.objects.count()):7.2f} ms")
print(f"len(Book.objects.all()) {ms(lambda: len(Book.objects.all())):7.2f} ms")
print(f"{{{{ books|length }}}} (template) {ms(lambda: template.render(Context({'books': Book.objects.all()}))):7.2f} ms")
$ python bench_count.py
Book.objects.count() 0.06 ms
len(Book.objects.all()) 53.16 ms
{{ books|length }} (template) 76.66 ms
count() sends one SELECT COUNT(*); len() loads 20,000 rows into model instances just to count them; the template
filter does the same plus template overhead. The guide notes the exception: if the page will loop over the rows anyway,
len() on the already-evaluated queryset costs nothing extra — which is why measuring the real page beats rules of
thumb.
The running example
The "latest books" page lists the 50 most recently published books with their authors. The naive view does not mention authors at all; the template does, one row at a time:
from django.db import models
from django.utils.functional import cached_property
class Author(models.Model):
name = models.CharField(max_length=200)
@cached_property
def top_rated_count(self):
return self.books.filter(rating=5).count()
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
published = models.DateField()
rating = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["-published"]
def __str__(self):
return self.title
from django.shortcuts import render
from .models import Book
def book_list_naive(request):
books = Book.objects.all()[:50] # template reads book.author.name -> one query per book
return render(request, "books/book_list.html", {"books": books})
def book_list(request):
books = Book.objects.select_related("author")[:50]
return render(request, "books/book_list.html", {"books": books})
{% extends "books/base.html" %}
{% block content %}
<h1>Latest books</h1>
<ul>
{% for book in books %}
<li>{{ book.title }} by {{ book.author.name }} ({{ book.published|date:"Y" }})</li>
{% endfor %}
</ul>
{% endblock %}
The URLconf routes /books/naive/ and /books/ to the two views; a seed_books management command fills the database
with 200 authors and 20,000 books using bulk_create().
Profiling tools
Different tools answer different questions. Use them from the outside in: first which page is slow, then which part of that page (database, Python, templates), then which function.
| Tool | Answers | Where to run it |
|---|---|---|
Django Debug Toolbar |
SQL queries per page (with duplicates and |
Development only, in the browser. |
|
"Does this view still run the number of queries I expect?" |
The test suite, on every commit. |
cProfile + pstats (standard library) |
Exact call counts and cumulative time per function. |
A script or a single request; any environment. |
pyinstrument |
A sampled call tree that hides library noise; shows where wall-clock time goes, including I/O waits. |
A script, or one request with |
django-silk |
Records requests, SQL and optional cProfile runs into the database and shows them in a web UI. |
Development or staging; it writes on every request. |
Load testers and APM services |
Throughput and latency under concurrency; production behaviour. |
Against a staging copy or production. |
Django Debug Toolbar
The guide’s first recommendation is django-debug-toolbar: a panel injected into every HTML page that shows each SQL
query the page issued, how long it took, which ones are similar or duplicated, and an EXPLAIN button per query. It
also has panels for templates, cache, signals, headers and timings. Version 8.0.0 supports Django 5.2, 6.0 and 6.1, and
works with ASGI and async views but, in its own words, "still lacks the capability for handling concurrent requests".
Install it only in development, keep it out of the test run, and put its middleware after anything that encodes the
response (such as GZipMiddleware):
$ python -m pip install django-debug-toolbar
import os
import sys
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"
TESTING = "test" in sys.argv or "PYTEST_VERSION" in os.environ
if DEBUG and not TESTING:
INSTALLED_APPS += ["debug_toolbar"]
# After GZipMiddleware (which encodes the response), as early as possible otherwise.
MIDDLEWARE.insert(3, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
from django.conf import settings
if settings.DEBUG and not settings.TESTING:
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns += debug_toolbar_urls()
The toolbar shows only when DEBUG is True and the client’s address is in INTERNAL_IPS (or when a custom
SHOW_TOOLBAR_CALLBACK says so). Loading both list pages with the toolbar on, the SQL panel’s summary line tells the
story of the running example in one glance:
$ DJANGO_DEBUG=1 python manage.py runserver
$ curl -s http://127.0.0.1:8000/books/naive/ | grep -o '[0-9]* quer[a-z]* in [0-9.]*ms'
51 queries in 1.29ms
$ curl -s http://127.0.0.1:8000/books/ | grep -o '[0-9]* quer[a-z]* in [0-9.]*ms'
1 query in 1.53ms
Fifty-one tiny queries are fast on a local SQLite file; against a database across a network each one pays a round trip, which is why the count matters more than the time shown here.
Query budgets in tests
The toolbar shows a problem once; a test keeps it fixed. assertNumQueries() fails when a block runs a different
number of queries, and CaptureQueriesContext records them so you can inspect the SQL:
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from .models import Author, Book
class BookListQueryBudgetTests(TestCase):
@classmethod
def setUpTestData(cls):
authors = Author.objects.bulk_create(Author(name=f"Author {i}") for i in range(10))
Book.objects.bulk_create(
Book(title=f"Book {i}", author=authors[i % 10], published="2024-01-01") for i in range(60)
)
def test_book_list_runs_one_query(self):
with self.assertNumQueries(1):
response = self.client.get("/books/")
self.assertContains(response, "<li>", count=50)
def test_naive_list_is_n_plus_one(self):
with CaptureQueriesContext(connection) as ctx:
self.client.get("/books/naive/")
self.assertEqual(len(ctx.captured_queries), 51)
$ python manage.py test books
Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.049s
OK
Destroying test database for alias 'default'...
cProfile and pstats
The standard library’s deterministic profiler counts every call. Profiling the request through Django’s test Client
keeps the server out of the picture; warm the page up once first so that imports, URL resolution and template
compilation do not dominate:
import cProfile
import os
import pstats
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from django.test import Client # noqa: E402
client = Client(HTTP_HOST="localhost")
assert client.get("/books/naive/").status_code == 200 # warm up: imports, URL resolver, template compilation
with cProfile.Profile() as profiler:
for _ in range(20):
client.get("/books/naive/")
stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumulative")
stats.print_stats(r"book_list_naive|related_descriptors|execute_sql")
$ python profile_books.py
873837 function calls (808197 primitive calls) in 0.218 seconds
Ordered by: cumulative time
List reduced from 604 to 8 due to restriction <'book_list_naive|related_descriptors|execute_sql'>
ncalls tottime percall cumtime percall filename:lineno(function)
20 0.000 0.000 0.213 0.011 views.py:11(book_list_naive)
1000 0.001 0.000 0.165 0.000 related_descriptors.py:234(__get__)
1000 0.001 0.000 0.161 0.000 related_descriptors.py:279(fetch_one)
1000 0.001 0.000 0.160 0.000 related_descriptors.py:229(get_object)
1020 0.001 0.000 0.075 0.000 compiler.py:1613(execute_sql)
1000 0.001 0.000 0.012 0.000 related_descriptors.py:169(get_queryset)
1000 0.000 0.000 0.001 0.000 related_descriptors.py:88(__set__)
2000 0.000 0.000 0.001 0.000 related_descriptors.py:120(_traverse_ancestors)
The numbers read directly: 20 requests made 1,000 calls to the foreign-key descriptor’s get (50 per page) and
1,020 SQL executions, and those lazy loads account for about three quarters of the view’s cumulative time. The
fetch_one frame is the 6.1 fetch-mode machinery: FETCH_ONE is the default mode, which loads each missing related
object on its own — exactly the N+1 behaviour. pstats can also sort_stats("tottime") to find functions that are
slow in themselves, and python -m cProfile -o out.prof script.py saves a file for viewers such as SnakeViz.
Source: The Python Profilers, Fetch modes
pyinstrument
pyinstrument is a sampling profiler: it records the call stack every millisecond (here every 0.1 ms) instead of hooking every call, so its overhead is low and it measures wall-clock time, including time spent waiting on the database or the network. It hides library frames by default, which makes the call tree readable; the flat view below lists where time was actually spent:
import os
import django
from pyinstrument import Profiler
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from django.test import Client # noqa: E402
client = Client(HTTP_HOST="localhost")
client.get("/books/naive/") # warm up
with Profiler(interval=0.0001) as profiler:
for _ in range(20):
client.get("/books/naive/")
print(profiler.output_text(flat=True, time="percent_of_total"))
$ python pyinstrument_books.py
_ ._ __/__ _ _ _ _ _/_ Recorded: 02:19:57 Samples: 2066
/_//_/// /_\ / //_// / //_'/ // Duration: 0.208 CPU time: 0.208
/ _/ v5.1.3
Profile at .../pyinstrument_books.py:14
9.6% [self] django/template/base.py
5.4% Query._add_q django/db/models/sql/query.py:1691
4.9% Query.clone django/db/models/sql/query.py:387
4.8% SQLCompiler.as_sql django/db/models/sql/compiler.py:775
4.8% Query.build_filter django/db/models/sql/query.py:1487
4.0% SQLiteCursorWrapper.execute <built-in>
4.0% ModelIterable.__iter__ django/db/models/query.py:124
2.7% ForeignKey.get_reverse_related_filter django/db/models/fields/related.py:432
2.4% copy copy.py:62
Almost every line is the ORM building, compiling and running the 50 extra queries; the database itself (execute) is a
small share because SQLite is in-process. To profile one real request instead, pyinstrument ships a Django middleware:
add it at the top of MIDDLEWARE and append ?profile to any URL to get an interactive HTML report instead of the
page. Out of the box any client can trigger it, so enable it only in development, or set PYINSTRUMENT_SHOW_CALLBACK
to a function that checks, for example, request.user.is_superuser:
if os.environ.get("PYINSTRUMENT") == "1":
MIDDLEWARE.insert(0, "pyinstrument.middleware.ProfilerMiddleware")
$ PYINSTRUMENT=1 python manage.py runserver
$ curl -s -o naive-profile.html -w "%{http_code} %{content_type} %{size_download}\n" \
"http://127.0.0.1:8000/books/naive/?profile"
200 text/html; charset=utf-8 146420
Setting PYINSTRUMENT_PROFILE_DIR instead writes a report file for every request, which is handy when the slow call is
an API endpoint rather than a page.
django-silk, load tests and production monitoring
django-silk (5.6.0, classified for Django 5.2-6.1) is a middleware plus an app that stores every request, its SQL queries and, optionally, a cProfile run in the database, and shows them in a web UI at a URL you choose. It is useful on a shared staging server where several people need to look at the same captured requests. Because it writes to the database on every request, it slows the site down and its UI must be protected; it is not a production APM. For behaviour under concurrency, run a load tester (Locust is a Python option) against a staging copy, and in production rely on an application performance monitoring service or your database’s slow-query log, which see real traffic and real data volumes that no development machine reproduces.
Query costs in brief
The profiles above point at the most common Django performance problem: the N+1 query pattern, where a loop touches a
relation that was not loaded and Django fetches it one row at a time. The fixes, all covered in depth in
Query Optimization & Fetch Modes, are to load the relation up
front with select_related() (a SQL join, for foreign keys and one-to-one fields) or prefetch_related() (a second
query, for many-to-many and reverse relations), or — new in Django 6.1 — to change the queryset’s fetch mode so that
the first lazy load fetches the relation for every instance that came from the same queryset. Measured on the running
example, reading book.author.name for 50 books:
import os
import timeit
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from django.db import connection, models # noqa: E402
from django.test.utils import CaptureQueriesContext # noqa: E402
from books.models import Book # noqa: E402
variants = {
"default (FETCH_ONE)": lambda: Book.objects.all()[:50],
"fetch_mode(FETCH_PEERS)": lambda: Book.objects.fetch_mode(models.FETCH_PEERS)[:50],
"select_related('author')": lambda: Book.objects.select_related("author")[:50],
}
def names(make_qs):
return [book.author.name for book in make_qs()]
for label, make_qs in variants.items():
with CaptureQueriesContext(connection) as ctx:
names(make_qs)
ms = min(timeit.repeat(lambda: names(make_qs), number=20, repeat=5)) / 20 * 1000
print(f"{label:26} {len(ctx.captured_queries):3} queries {ms:6.2f} ms")
$ python bench_queries.py
default (FETCH_ONE) 51 queries 3.21 ms
fetch_mode(FETCH_PEERS) 2 queries 0.47 ms
select_related('author') 1 queries 0.25 ms
Even against an in-process SQLite file the explicit join is about 13 times faster than the default; across a network
the gap grows with every round trip. FETCH_PEERS is a good safety net for code you do not control, and FETCH_RAISE
turns any accidental lazy load into an exception, which is a cheap way to find N+1 patterns in tests. On Django 5.2 LTS
only select_related() and prefetch_related() are available. The same page covers the other query-level tools:
only()/defer(), values(), iterator(), exists(), count(), update()/bulk_create() and doing work in the
database with expressions instead of Python loops.
Source: Database access optimization, Fetch modes
Database indexes
Once the number of queries is right, look at the slow ones. An index lets the database find rows without scanning the
whole table, at the price of extra disk space and slower writes, so add indexes for the filters and orderings your
pages actually use and check that the database uses them. QuerySet.explain() prints the plan for any queryset. The
Bookshelf archive page filters books by year and rating and sorts by date:
import os
import timeit
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from books.models import Book # noqa: E402
qs = Book.objects.filter(published__year=2001, rating__gte=4).order_by("-published")
print(qs.explain())
best = min(timeit.repeat(lambda: list(qs.all()), number=20, repeat=5)) / 20 * 1000
print(f"{len(qs)} rows, {best:.2f} ms per query")
$ python explain_books.py
3 0 216 SCAN books_book
18 0 0 USE TEMP B-TREE FOR ORDER BY
98 rows, 0.97 ms per query
SCAN books_book means a full-table scan of all 20,000 rows, followed by a temporary sort. Declaring an index in the
model’s Meta and generating a migration fixes both:
class Meta:
ordering = ["-published"]
indexes = [models.Index(fields=["published"], name="book_published_idx")]
$ python manage.py makemigrations books
Migrations for 'books':
books/migrations/0002_book_book_published_idx.py
+ Create index book_published_idx on field(s) published of model book
$ python manage.py sqlmigrate books 0002
BEGIN;
--
-- Create index book_published_idx on field(s) published of model book
--
CREATE INDEX "book_published_idx" ON "books_book" ("published");
COMMIT;
$ python manage.py migrate books
Operations to perform:
Apply all migrations: books
Running migrations:
Applying books.0002_book_book_published_idx... OK
$ python explain_books.py
4 0 162 SEARCH books_book USING INDEX book_published_idx (published>? AND published<?)
98 rows, 0.27 ms per query
The __year lookup became a range condition on the index, the sort disappeared (the index is already ordered), and
the query got 3.6 times faster on a small table. On PostgreSQL, explain(analyze=True) runs the query and reports real
row counts and timings. Foreign keys get an index automatically, and unique=True creates one; everything else — composite, partial (condition=), functional and covering (include=) indexes, and PostgreSQL’s GIN/GiST/BRIN types — is described in Constraints, Indexes & Composite Keys and
PostgreSQL & Database Backends. On a large production table,
create indexes concurrently (PostgreSQL’s AddIndexConcurrently operation) so the migration does not lock writes.
Persistent connections and connection pooling
By default Django opens a database connection the first time a request needs one and closes it when the request ends
(CONN_MAX_AGE = 0). Opening a connection — TCP, TLS, authentication, a backend process on PostgreSQL — can cost
more than the queries themselves, especially on virtualised hosts. Two mechanisms avoid paying it on every request.
Persistent connections (CONN_MAX_AGE set to a number of seconds, or None for no limit) keep each worker thread’s
connection open between requests. CONN_HEALTH_CHECKS (Django 4.1) makes Django check a reused connection once per
request before using it, so a database restart costs no failed request. To see the difference, the running example
logs every new connection with the connection_created signal and is served by one Gunicorn sync worker:
import logging
from django.apps import AppConfig
from django.db.backends.signals import connection_created
logger = logging.getLogger("books")
def log_new_connection(sender, connection, **kwargs):
logger.info("new database connection (%s)", connection.alias)
class BooksConfig(AppConfig):
name = "books"
def ready(self):
connection_created.connect(log_new_connection)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"CONN_MAX_AGE": int(os.environ.get("CONN_MAX_AGE", "0")),
"CONN_HEALTH_CHECKS": True,
}
}
$ for age in 0 60; do
> CONN_MAX_AGE=$age gunicorn config.wsgi:application -b 127.0.0.1:8000 -w 1 > gunicorn-$age.log 2>&1 &
> sleep 3
> for i in 1 2 3 4 5; do curl -s -o /dev/null http://127.0.0.1:8000/books/; done
> kill %1; sleep 1
> echo "CONN_MAX_AGE=$age: $(grep -c 'new database connection' gunicorn-$age.log) connections for 5 requests"
> done
CONN_MAX_AGE=0: 5 connections for 5 requests
CONN_MAX_AGE=60: 1 connections for 5 requests
Persistent connections have caveats spelled out in the databases reference: each thread holds its own connection, so the database must allow at least as many connections as you have worker threads in total; the development server starts a new thread per request, so the setting has no effect there; and under ASGI persistent connections should be disabled — the reference recommends the database backend’s own connection pooling instead.
Connection pooling (Django 5.1, PostgreSQL with psycopg 3 only) keeps a pool of open connections per database alias
per process and hands one to whichever thread needs it; closing a Django connection returns it to the pool. It is the
recommended option under ASGI and works under WSGI too. Install psycopg[pool] and set "pool" in OPTIONS to True
(the ConnectionPool defaults) or to a dict of ConnectionPool arguments. CONN_MAX_AGE must stay 0; connection
lifetime is set with the pool’s max_lifetime option instead:
import os
from .settings import * # noqa: F403
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "bookshelf",
"USER": "bookshelf",
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
"HOST": "localhost",
"PORT": "5432",
"CONN_MAX_AGE": 0, # must stay 0 when "pool" is set
"OPTIONS": {"pool": {"min_size": 2, "max_size": 10, "timeout": 10}},
}
}
$ python -m pip install "psycopg[binary,pool]"
$ DJANGO_SETTINGS_MODULE=config.settings_pg python manage.py check
System check identified no issues (0 silenced).
Size the pool so that max_size times the number of processes stays below the server’s max_connections. An external
pooler such as PgBouncer in transaction mode is the alternative when many application servers share one database; it
needs DISABLE_SERVER_SIDE_CURSORS = True in the database settings. The details are in
PostgreSQL & Database Backends.
A caching strategy
Caching saves the result of expensive work so the next request can reuse it. The guide is explicit that it is one of the final steps, not a substitute for fixing badly written code: a cached N+1 page is still an N+1 page on every cache miss and after every invalidation. Think of caching as layers, from closest to the user to closest to the data, and use the outermost layer that the content’s freshness rules allow:
| Layer | Django tool | Good for |
|---|---|---|
Browser and CDN |
|
Static assets; public pages that may be a little stale. |
Whole site |
|
Mostly anonymous, mostly static sites. |
One view |
|
Expensive pages whose output depends only on the URL (and headers named in |
A template fragment |
|
A costly block inside an otherwise personalised page. |
Any value |
|
Results of queries or API calls, with keys you control and invalidate. |
One object, one request |
|
An expensive method called several times on the same instance. |
Sessions |
|
Avoiding a database read for the session on every request. |
Every layer above "one object" needs a real shared backend in production — django.core.cache.backends.redis.RedisCache
(built in since Django 4.0) or PyMemcacheCache — because the default local-memory cache is per process. The
Bookshelf project uses LocMemCache here only because no Redis server was available for the examples. A per-view
cache turns the 51-query page into zero queries on a hit, and cached_property runs its query once per instance; both
are pinned down by the other two tests of the suite run earlier:
from django.views.decorators.cache import cache_page
@cache_page(60 * 5)
def book_list_cached(request):
books = Book.objects.all()[:50] # still N+1, but only on a cache miss
return render(request, "books/book_list.html", {"books": books})
from django.core.cache import cache
def test_cached_page_skips_the_database_on_a_hit(self):
cache.clear()
with self.assertNumQueries(51):
self.client.get("/books/cached/")
with self.assertNumQueries(0):
self.client.get("/books/cached/")
def test_cached_property_queries_once_per_instance(self):
author = Author.objects.get(name="Author 0")
with self.assertNumQueries(1):
author.top_rated_count
author.top_rated_count
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
The hard part of caching is not storing values but deciding when they are wrong: choose a timeout per kind of data,
invalidate on writes (signals or explicit cache.delete() in the service that changes the data), version keys when the
format changes, and never cache a response that contains one user’s data under a key shared by everyone. All of that,
plus Redis configuration, fragment caching and the async cache API, is in Caching; the
session engines are compared in Sessions, Cookies, Messages & Signing.
Template costs
Rendering a Django template has two costs: compiling the template file into a node tree, and rendering that tree
with a context. The cached template loader removes the first cost after the first use by keeping compiled templates in
memory. Older advice says to enable it in production; since Django 4.1 it is the default whenever OPTIONS["loaders"]
is not set — with DEBUG = False and with DEBUG = True, where the autoreloader clears it when a template changes.
The engine reports its loaders directly:
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from django.conf import settings # noqa: E402
from django.template import engines # noqa: E402
engine = engines["django"].engine
print(f"DEBUG={settings.DEBUG}")
for loader in engine.loaders:
print(loader)
$ python loaders.py
DEBUG=False
('django.template.loaders.cached.Loader', ['django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader'])
$ DJANGO_DEBUG=1 python loaders.py
DEBUG=True
('django.template.loaders.cached.Loader', ['django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader'])
So the only way to lose the cache is to set loaders explicitly without wrapping them in
django.template.loaders.cached.Loader. The second cost depends on how the template is written. The guide notes that
{% block %} is faster than {% include %} and that heavily fragmented templates cost more. A small benchmark of
the list page, once with the row inline and once with each row in an included books/book_row.html, shows both
effects:
{% extends "books/base.html" %}
{% block content %}
<h1>Latest books</h1>
<ul>
{% for book in books %}{% include "books/book_row.html" %}{% endfor %}
</ul>
{% endblock %}
import os
import timeit
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from django.template import Context, Engine # noqa: E402
from books.models import Book # noqa: E402
books = list(Book.objects.select_related("author")[:50])
context = {"books": books}
app_loaders = ["django.template.loaders.app_directories.Loader"]
uncached = Engine(loaders=app_loaders, libraries={"static": "django.templatetags.static"})
cached = Engine(
loaders=[("django.template.loaders.cached.Loader", app_loaders)],
libraries={"static": "django.templatetags.static"},
)
def per_call(stmt):
return min(timeit.repeat(stmt, number=500, repeat=7)) / 500 * 1e6
name = "books/book_list.html"
print(f"get_template, uncached loader: {per_call(lambda: uncached.get_template(name)):7.1f} us")
print(f"get_template, cached loader: {per_call(lambda: cached.get_template(name)):7.1f} us")
for name in ["books/book_list.html", "books/book_list_include.html"]:
template = cached.get_template(name)
print(f"render {name:30} {per_call(lambda: template.render(Context(context))):7.1f} us")
$ python bench_templates.py
get_template, uncached loader: 58.3 us
get_template, cached loader: 0.3 us
render books/book_list.html 363.2 us
render books/book_list_include.html 616.5 us
Loading the template drops from tens of microseconds to almost nothing with the cache, and including a template 50
times inside a loop made the render about 1.7 times slower than writing the row inline. Neither number matters next to
51 queries — which is the point: fix queries first, then look at templates only if the profiler says so. When it does,
the usual causes are logic in the template (method calls that hit the database, nested loops, heavy filters) that
belongs in the view, and deep include/inclusion-tag nesting. Template partials (Django 6.0, {% partialdef %}) keep
reusable fragments in the same file without a separate include lookup; see
Template Partials & htmx. Jinja2 renders faster than the Django
template language and Django supports it as a second backend, but the guide recommends it only after the other options
are exhausted, and it changes the template syntax. The engine and its options are covered in
Templates.
HTTP: compression and conditional GET
Two built-in middleware classes reduce what goes over the wire. Their place in MIDDLEWARE matters: on the way out,
responses pass through the list from bottom to top, so GZipMiddleware must sit above anything that reads or writes
the body (it compresses last), and ConditionalGetMiddleware goes right below it so its ETag is computed on the
uncompressed content:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.gzip.GZipMiddleware",
"django.middleware.http.ConditionalGetMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
GZipMiddleware compresses responses for clients that send Accept-Encoding: gzip, skipping bodies under 200 bytes
and responses that already have a Content-Encoding. Compressing HTML that mixes secrets (a CSRF token, personal data)
with text an attacker can influence exposes it to the BREACH family of attacks; since Django 4.2 the middleware applies
the "Heal The Breach" mitigation, adding up to max_random_bytes (default 100) random bytes to each compressed
response, and it makes any ETag weak, as RFC 9110 requires for a transformed body. ConditionalGetMiddleware adds an
ETag to responses that lack one and answers If-None-Match or If-Modified-Since with an empty 304 Not Modified.
The running example under Uvicorn shows all of it:
$ curl -s -D - -o /dev/null http://127.0.0.1:8000/books/ | grep -i -E "^HTTP|^content-(length|encoding)|^etag|^vary"
HTTP/1.1 200 OK
Content-Length: 2354
ETag: "4df2aac82b1c5d9f0dfa592320d95331"
Vary: Accept-Encoding
$ curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" http://127.0.0.1:8000/books/ | grep -i -E "^HTTP|^content-(length|encoding)|^etag"
HTTP/1.1 200 OK
Content-Length: 632
ETag: W/"4df2aac82b1c5d9f0dfa592320d95331"
Content-Encoding: gzip
$ curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" http://127.0.0.1:8000/books/ | grep -i -E "^HTTP|^content-(length|encoding)|^etag"
HTTP/1.1 200 OK
Content-Length: 565
ETag: W/"4df2aac82b1c5d9f0dfa592320d95331"
Content-Encoding: gzip
$ curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" -H 'If-None-Match: W/"4df2aac82b1c5d9f0dfa592320d95331"' http://127.0.0.1:8000/books/ | grep -i -E "^HTTP|^content-length|^etag"
HTTP/1.1 304 Not Modified
ETag: "4df2aac82b1c5d9f0dfa592320d95331"
The page shrank from 2,354 to roughly 600 bytes, and two identical requests produced different compressed lengths
(632 and 565) — that is the random padding at work. The revalidation returned no body at all. Note what it did not
save: ConditionalGetMiddleware computes the ETag from the finished response, so the view still ran its query and
rendered the template. To skip that work too, give the view a cheap way to decide freshness with the
last_modified(), etag() or condition() decorators; here one aggregate query replaces the whole page:
from django.db.models import Max
from django.views.decorators.http import last_modified
def latest_change(request):
return Book.objects.aggregate(latest=Max("updated_at"))["latest"]
@last_modified(latest_change)
def book_list_conditional(request):
books = Book.objects.select_related("author")[:50]
return render(request, "books/book_list.html", {"books": books})
$ curl -s -D - -o /dev/null http://127.0.0.1:8000/books/conditional/ | grep -i -E "^HTTP|^last-modified|^content-length"
HTTP/1.1 200 OK
Last-Modified: Sat, 03 Oct 2026 07:23:47 GMT
Content-Length: 2354
$ curl -s -D - -o /dev/null -H "If-Modified-Since: Sat, 03 Oct 2026 07:23:47 GMT" http://127.0.0.1:8000/books/conditional/ | grep -i -E "^HTTP|^last-modified|^content-length"
HTTP/1.1 304 Not Modified
Last-Modified: Sat, 03 Oct 2026 07:23:47 GMT
Content-Length: 0
(Book gained an updated_at = models.DateTimeField(auto_now=True) field for this.) In production the reverse proxy or
CDN usually compresses responses, often with Brotli as well as gzip, which removes the need for GZipMiddleware and
keeps the CPU work off the Python workers; enable one or the other, not both. Middleware ordering in general is
explained in Middleware, and BREACH together with the CSRF token’s own masking in
Security.
Static assets: hashed names, compression and a CDN
Static files never change between deployments, so the fastest request for one is the request the browser never makes.
Django’s ManifestStaticFilesStorage makes that safe: collectstatic copies each file under a name that contains a
hash of its content (site.c9ede0bebe98.css) and the {% static %} tag emits that name, so a changed file gets a new
URL and old copies can be cached "forever". WhiteNoise builds on it with CompressedManifestStaticFilesStorage, which
also writes gzip and (with the Brotli package installed) Brotli versions of each file at build time, and with a
middleware that serves them from the Python process with far-future cache headers for hashed names:
$ python -m pip install "whitenoise[brotli]"
STATIC_HOST = os.environ.get("DJANGO_STATIC_HOST", "") # e.g. a CDN origin in production
STATIC_URL = STATIC_HOST + "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
WhiteNoiseMiddleware goes directly after SecurityMiddleware, as in the middleware list of the previous section.
$ python manage.py collectstatic --noinput --clear
787 static files deleted, 131 static files copied to '.../staticfiles', 655 post-processed.
$ ls -l staticfiles/books/ | awk '{print $5, $9}'
23163 site.c9ede0bebe98.css
733 site.c9ede0bebe98.css.br
1383 site.c9ede0bebe98.css.gz
23163 site.css
733 site.css.br
1383 site.css.gz
$ curl -s http://127.0.0.1:8000/books/ | grep stylesheet
<link rel="stylesheet" href="/static/books/site.c9ede0bebe98.css"></head>
$ curl -s -D - -o /dev/null -H "Accept-Encoding: br, gzip" http://127.0.0.1:8000/static/books/site.c9ede0bebe98.css | grep -i -E "^HTTP|^content-(length|encoding)|^cache-control|^vary"
HTTP/1.1 200 OK
Cache-Control: max-age=315360000, public, immutable
Vary: Accept-Encoding
Content-Length: 733
Content-Encoding: br
$ curl -s -D - -o /dev/null http://127.0.0.1:8000/static/books/site.css | grep -i -E "^HTTP|^content-(length|encoding)|^cache-control"
HTTP/1.1 200 OK
Cache-Control: max-age=60, public
Content-Length: 23163
The 23 KB stylesheet goes out as 733 bytes of Brotli, compressed once at deploy time instead of on every request, and
the hashed URL may be cached for ten years; the unhashed name gets only 60 seconds, which is why templates must always
use {% static %} rather than hard-coded paths. Because these headers are correct, putting a CDN in front is a
settings change: point the CDN at the site as its origin and set DJANGO_STATIC_HOST, and every {% static %} URL
moves to the CDN while the CDN fetches each file from WhiteNoise once:
$ DJANGO_STATIC_HOST=https://cdn.example.com python -c "
import os, django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings'); django.setup()
from django.templatetags.static import static; print(static('books/site.css'))"
https://cdn.example.com/static/books/site.c9ede0bebe98.css
The alternative is to upload collectstatic output to object storage (S3 and similar, through django-storages) that
the CDN serves directly. Minifying CSS and JavaScript is better done by the front-end build tool (esbuild, Vite and the
like) before collectstatic than by a Django package at request time. User-uploaded media need the same thinking but
cannot be served by WhiteNoise. All of this is covered in
Static Files & Front-End Assets and
File Uploads & Storage.
Async where it helps
Async views do not make Python code or database queries faster. They help when a request spends most of its time
waiting — on other HTTP services, on a slow API, on a long-poll or a stream — because an ASGI server can serve other
requests while one waits, and because one view can wait for several things at the same time. The Bookshelf price
comparison asks three partner shops for a price; each call takes about half a second (simulated here with
asyncio.sleep):
import asyncio
import time
from django.http import JsonResponse
async def fetch_price(shop: str) -> dict:
await asyncio.sleep(0.5) # stands in for a 500 ms call to a partner API
return {"shop": shop, "price": "12.90"}
async def prices_sequential(request):
start = time.perf_counter()
results = [await fetch_price("shop-a"), await fetch_price("shop-b"), await fetch_price("shop-c")]
return JsonResponse({"results": results, "seconds": round(time.perf_counter() - start, 2)})
async def prices_concurrent(request):
start = time.perf_counter()
results = await asyncio.gather(fetch_price("shop-a"), fetch_price("shop-b"), fetch_price("shop-c"))
return JsonResponse({"results": results, "seconds": round(time.perf_counter() - start, 2)})
$ uvicorn config.asgi:application --port 8000
$ curl -s -w " (curl: %{time_total}s)\n" http://127.0.0.1:8000/prices/sequential/
{"results": [{"shop": "shop-a", "price": "12.90"}, {"shop": "shop-b", "price": "12.90"}, {"shop": "shop-c", "price": "12.90"}], "seconds": 1.51} (curl: 1.518752s)
$ curl -s -w " (curl: %{time_total}s)\n" http://127.0.0.1:8000/prices/concurrent/
{"results": [{"shop": "shop-a", "price": "12.90"}, {"shop": "shop-b", "price": "12.90"}, {"shop": "shop-c", "price": "12.90"}], "seconds": 0.5} (curl: 0.504598s)
Writing async def alone changed nothing — the sequential version still takes the sum of the three waits; the gain
comes from asyncio.gather(). A real implementation would use an async HTTP client (HTTPX, aiohttp) with timeouts. The
rules that keep async from making things slower:
-
Under WSGI an async view runs in its own one-off event loop:
asyncio.gather()still overlaps the waits inside the view, but the worker is blocked for the whole request. Serving many slow or long-lived connections without a thread each needs an ASGI server. -
Each switch between sync and async code (
sync_to_async, synchronous middleware in an async stack, a sync view under ASGI) has a cost — the 6.1 documentation puts it at tens of microseconds per call on the ASGI request path and a few hundred in scripts and management commands. That is rarely visible once, but a crossing inside a tight loop adds up: put the loop inside onesync_to_async()call instead. To see which middleware Django adapts, enable debug logging for thedjango.requestlogger and look for "Asynchronous handler adapted for middleware" messages. -
The async ORM methods (
aget(),acount(),async for) keep the event loop free while a query runs, but the database does exactly the same work: an N+1 page is just as slow in an async view. Transactions do not work in async mode yet, and persistent connections must be disabled there (use the pool instead). -
Work that does not need to finish before the response — sending email, resizing images — belongs in a task queue, not in an async view: see Tasks Framework and Celery Integration.
The async model, sync_to_async/async_to_sync, async-safe middleware and the async ORM are covered in
Async Support.
Where each technique acts in the request cycle
Every optimisation on this page removes work from one stage of the path a request travels (the full path is in Request/Response Cycle). The earlier a stage answers, the more work it saves:
(hashed static files,
Cache-Control)"] --> C["CDN / reverse proxy
(static files, compression,
cached pages)"] C --> M["Middleware
(WhiteNoise, GZip,
ConditionalGet, site cache)"] M --> V["View
(cache_page, last_modified,
async gather)"] V --> Q["ORM and database
(select_related, fetch modes,
indexes, connection reuse)"] V --> T["Template
(cached loader,
fragment cache)"]
| Stage | What can answer or shrink the work there |
|---|---|
Browser |
Hashed static URLs with |
CDN or reverse proxy |
Static files from the edge; Brotli/gzip compression; full-page caches for anonymous traffic. |
Middleware |
WhiteNoise answers static requests before Django’s URL resolver; |
View |
|
ORM and database |
Fewer queries ( |
Template |
The cached loader skips compilation; |
Pitfalls
-
Optimising without a measurement. Change one thing, measure the same metric again, and keep the change only if the number moved. Benchmarks on a laptop with SQLite exaggerate Python costs and hide network round trips.
-
Measuring with
DEBUG = True. Debug mode records every SQL query inconnection.queries, keeps the toolbar’s instrumentation on, and serves static files differently; time pages with production-like settings. -
Profiling the first request. Imports, URL resolution and template compilation happen once per process; warm up before measuring, as the profiling scripts above do.
-
Caching instead of fixing. A cache hides an N+1 page only until the next miss; cache stampedes after invalidation then hit the database with the slow version all at once.
-
Leaving profilers on. The pyinstrument middleware answers
?profilefor anyone unlessPYINSTRUMENT_SHOW_CALLBACKrestricts it; django-silk writes to the database on every request; the debug toolbar must never be installed in production settings. -
Persistent connections in the wrong place.
CONN_MAX_AGEunder ASGI, with the development server, or combined with"pool"(which requires it to be0); and pools or persistent connections sized beyond the database’smax_connections. -
Indexes everywhere. Every index slows inserts and updates and takes space; add them for real query patterns, check them with
explain(), and create them concurrently on large PostgreSQL tables. -
Compressing twice or compressing secrets. Use either the proxy’s compression or
GZipMiddleware, and remember that compressed pages mixing secrets with attacker-controlled text are a BREACH target. -
Hard-coded static paths.
/static/site.cssbypasses the manifest, gets a short cache lifetime and breaks when the file is served from a CDN; always use{% static %}. -
Async for CPU-bound work. An async view that computes for 200 ms blocks the event loop for every other request on that worker; use a sync view or a task queue.
Where the books are outdated
Two Scoops of Django 1.11 (2017), chapter 24 "Finding and Reducing Bottlenecks", is still a sound checklist — measure
with the debug toolbar, cut query counts with select_related()/prefetch_related(), assert query counts in tests,
index what you filter and sort on, read query plans, keep logs and ephemeral data out of the relational database, cache
with Memcached or Redis, use a CDN. Its advice that ATOMIC_REQUESTS rarely needs to be turned off for speed also
still holds (see Transactions, Raw SQL & Multiple Databases).
The tools it names have moved on:
| The book (Django 1.11) | Today (checked 2026-10-03) |
|---|---|
Add django-cache-panel to see cache activity. |
Its only release is 0.1 from 2012. The debug toolbar’s built-in Cache panel shows the same information. |
django-extensions' |
|
silk at |
Now |
django-braces' |
Unnecessary: set |
Third-party QuerySet caches: django-cache-machine, johnny-cache, django-cachalot. |
johnny-cache’s last release is from 2012 and django-cache-machine’s from 2022 (no Django classifiers); only django-cachalot (2.9.1, 2026-08-10, classified for 6.1) is maintained. The book’s warning about "magic" caching still applies; prefer explicit caching (A caching strategy). |
Memcached through |
|
|
Proxy or CDN compression is still preferable. |
Varnish as an upstream cache; Fastly, Akamai or CloudFront as CDN. |
Still valid; with correct |
PostgreSQL 9.0 and MySQL 5.7 tuning references. |
Django 6.1 requires PostgreSQL 15+ and MySQL 8.4+; use the current server documentation. |
Not covered. |
Persistent-connection health checks (4.1), PostgreSQL connection pools (5.1), the cached template loader in development (4.1), async views for I/O-bound work (3.1 onwards) and fetch modes (6.1). |
The book’s "ephemeral data" advice needs one nuance: Django’s default message storage keeps messages in a cookie, with
the session as fallback, not in the database, and the cached_db session engine gives cache-speed reads while keeping
sessions durable.
What changed recently
| Version | Change |
|---|---|
Django 3.1 (2020) |
Async views and middleware, so I/O-bound views can await concurrent calls. |
Django 3.2 (2021) |
|
Django 4.0 (2021) |
Built-in Redis cache backend. |
Django 4.1 (2022) |
Cached template loader enabled in development too; |
Django 4.2 (2023) |
|
Django 5.1 (2024) |
PostgreSQL connection pools ( |
Django 6.1 (2026-09-02) |
Fetch modes ( |
django-debug-toolbar 8.0.0 (2026-09-01) |
Classified for Django 5.2, 6.0 and 6.1; the installation guide wires the URLconf with the |
pyinstrument 5.1.3 (2026-07-29), django-silk 5.6.0 (2026-09-12), WhiteNoise 6.12.0 (2026-02-27) |
Current releases of the profiling and static-file tools used on this page. |
The Django rows come from the release notes of each version; the package rows from their PyPI pages and documentation.