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.

Measure first

Which page, which query, which function?

Django Debug Toolbar in development; cProfile or pyinstrument for Python time; a query budget in tests.

Profiling tools

Many similar queries on one page

select_related() / prefetch_related(), or the 6.1 fetch modes; lock it with assertNumQueries.

Query costs in brief

One query is slow

Read QuerySet.explain() and add an index through Meta.indexes.

Database indexes

Connection setup shows up in every request

CONN_MAX_AGE under WSGI; the psycopg connection pool (OPTIONS["pool"]) under ASGI or for PostgreSQL in general.

Persistent connections and connection pooling

The same expensive result is computed again and again

The caching framework (per-view, fragment, low-level), cached_property, cached sessions.

A caching strategy

Template rendering dominates the profile

Keep the cached template loader (the default), move logic into the view, prefer {% block %} to {% include %} in loops.

Template costs

Large responses, repeat downloads

GZipMiddleware (mind BREACH) and ConditionalGetMiddleware for ETags and 304 responses.

HTTP: compression and conditional GET

CSS/JS/images

Hashed file names, precompressed files, far-future caching, optionally a CDN.

Static assets: hashed names, compression and a CDN

A view waits on several slow network calls

An async view that awaits them concurrently.

Async where it helps

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.

flowchart LR A["Pick one metric
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
Figure 1. The optimisation loop

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:

bench_count.py — the same count at three levels
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:

books/models.py
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
books/views.py (the two list views)
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})
books/templates/books/book_list.html
{% 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 EXPLAIN), time, templates, cache calls, signals, headers.

Development only, in the browser.

assertNumQueries / CaptureQueriesContext

"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 ?profile through its Django middleware.

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
config/settings.py (excerpt)
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"]
config/urls.py (excerpt)
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:

books/tests.py (excerpt)
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:

profile_books.py
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.

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:

pyinstrument_books.py
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:

config/settings.py (excerpt)
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:

bench_queries.py
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.

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:

explain_books.py
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:

books/models.py (Book.Meta)
    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:

books/apps.py
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)
config/settings.py (excerpt)
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:

config/settings_pg.py
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

Cache-Control/ETag headers (cache_control(), ConditionalGetMiddleware, hashed static files)

Static assets; public pages that may be a little stale.

Whole site

UpdateCacheMiddleware + FetchFromCacheMiddleware

Mostly anonymous, mostly static sites.

One view

@cache_page(seconds)

Expensive pages whose output depends only on the URL (and headers named in Vary).

A template fragment

{% cache 600 sidebar %}

A costly block inside an otherwise personalised page.

Any value

cache.get()/cache.set()/cache.get_or_set() and their a-prefixed async versions

Results of queries or API calls, with keys you control and invalidate.

One object, one request

django.utils.functional.cached_property

An expensive method called several times on the same instance.

Sessions

SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" (or cache)

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:

books/views.py (cached view)
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})
books/tests.py (excerpt)
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
config/settings.py (excerpt)
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:

loaders.py
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:

books/templates/books/book_list_include.html
{% extends "books/base.html" %}
{% block content %}
<h1>Latest books</h1>
<ul>
{% for book in books %}{% include "books/book_row.html" %}{% endfor %}
</ul>
{% endblock %}
bench_templates.py
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:

config/settings.py (excerpt)
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:

books/views.py (conditional view)
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]"
config/settings.py (excerpt)
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):

books/views.py (async views)
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 one sync_to_async() call instead. To see which middleware Django adapts, enable debug logging for the django.request logger 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:

flowchart LR B["Browser cache
(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)"]
Figure 2. Where each optimisation short-circuits a request
Stage What can answer or shrink the work there

Browser

Hashed static URLs with immutable caching (no request at all); ETag/Last-Modified revalidation (304, no body).

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; FetchFromCacheMiddleware answers whole pages; ConditionalGetMiddleware replaces bodies with 304; GZipMiddleware shrinks what is left.

View

cache_page skips the view body; last_modified()/etag()/condition() skip it when the client’s copy is fresh; async views overlap I/O waits.

ORM and database

Fewer queries (select_related(), prefetch_related(), fetch modes), cheaper queries (indexes, only(), values()), cheaper connections (CONN_MAX_AGE, pooling).

Template

The cached loader skips compilation; {% cache %} skips a fragment; less logic and fewer includes shorten rendering.

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 in connection.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 ?profile for anyone unless PYINSTRUMENT_SHOW_CALLBACK restricts 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_AGE under ASGI, with the development server, or combined with "pool" (which requires it to be 0); and pools or persistent connections sized beyond the database’s max_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.css bypasses 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' runprofileserver "with hotshot/profiling tools enabled".

hotshot was a Python 2 module and does not exist in Python 3; the command’s documentation still names it, so pass --use-cprofile. pyinstrument’s middleware or a cProfile script (Profiling tools) are simpler.

silk at github.com/mtford90/silk.

Now jazzband/django-silk, version 5.6.0 (2026-09-12), classified for Django 5.2-6.1.

django-braces' SelectRelatedMixin to add select_related() to class-based views.

Unnecessary: set queryset = Book.objects.select_related("author") or override get_queryset(). django-braces 1.17.0 declares Django only up to 4.2.

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 python-memcached; Redis through django-redis.

MemcachedCache was removed in Django 4.1 (use PyMemcacheCache, added in 3.2); Redis has a built-in backend, django.core.cache.backends.redis.RedisCache, since Django 4.0. django-redis (7.0.0) remains an option for extra features.

GZipMiddleware and {% spaceless %}, but prefer Apache/Nginx compression; django-pipeline, django-compressor or django-htmlmin for assets.

Proxy or CDN compression is still preferable. GZipMiddleware gained the BREACH mitigation in 4.2. Precompressed, hashed static files from WhiteNoise’s CompressedManifestStaticFilesStorage replace most asset-pipeline work, with a front-end build tool for minification; django-htmlmin’s last release was in 2019, and django-pipeline (4.1.0) and django-compressor (4.6.0) declare Django only up to 5.2 and 6.0 respectively.

Varnish as an upstream cache; Fastly, Akamai or CloudFront as CDN.

Still valid; with correct Cache-Control headers from Django and WhiteNoise a CDN needs only the STATIC_URL change in Static assets: hashed names, compression and a CDN.

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)

PyMemcacheCache backend.

Django 4.0 (2021)

Built-in Redis cache backend.

Django 4.1 (2022)

Cached template loader enabled in development too; CONN_HEALTH_CHECKS; async ORM interface; MemcachedCache removed.

Django 4.2 (2023)

GZipMiddleware BREACH mitigation ("Heal The Breach", max_random_bytes); psycopg 3 support; STORAGES setting.

Django 5.1 (2024)

PostgreSQL connection pools (OPTIONS["pool"]).

Django 6.1 (2026-09-02)

Fetch modes (FETCH_ONE, FETCH_PEERS, FETCH_RAISE) — a one-line fix for many N+1 patterns.

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 debug_toolbar_urls() helper; async views and ASGI are supported, concurrent requests are not.

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.