Django caching
last modified November 13, 2026 Python 3.15
A cache keeps the result of expensive work, a database query, a rendered template, a whole response, so that the next request can use it without repeating the work. Django has a cache framework with a uniform API and several backends, from a dictionary in the memory of the process to Redis and Memcached. The cache can be used at three levels: for single values, for a view or a template fragment, and for the entire site. In this article we configure the backends, use the low-level API, cache views and fragments, switch on the per-site cache, deal with invalidation, and test the code. The examples use Django 6.1 and Python 3.15.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django and redis, pymemcache, generate the project with the configuration package
config, start the app catalog, and create the folder demos for demo scripts.
$ mkdir caching $ cd caching $ uv init --bare $ uv add django redis pymemcache $ uv run django-admin startproject config . $ uv run python manage.py startapp catalog $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'catalog',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The packages redis and pymemcache are the client libraries for the two network backends; Django ships the backend classes but not the clients. The app catalog
has one model, Product. The functions at the end of the module are signal receivers: after a product is saved or deleted they remove the cached data that depends on products. We come back to them in the
section on invalidation.
from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import models
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
class Product(models.Model):
name = models.CharField(max_length=60)
price = models.DecimalField(max_digits=8, decimal_places=2)
class Meta:
ordering = ['-price']
def __str__(self):
return self.name
@receiver([post_save, post_delete], sender=Product)
def invalidate_product_caches(sender, **kwargs):
cache.delete('product_count')
cache.delete(make_template_fragment_key('top_products'))
The model has a name and a price, and it is ordered by price. We create and apply the migration, and the script seed.py adds five products.
$ uv run python manage.py makemigrations catalog No changes detected in app 'catalog' $ uv run python manage.py migrate | tail -n 2 Running migrations: No migrations to apply.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from catalog.models import Product
Product.objects.all().delete()
for name, price in [('Pen', '1.20'), ('Notebook', '4.50'), ('Lamp', '19.90'),
('Chair', '89.00'), ('Desk', '249.00')]:
Product.objects.create(name=name, price=price)
print('products:', Product.objects.count())
The script deletes the existing products first, so it can be run again.
$ uv run python -m demos.seed products: 5
Cache backends
The setting CACHES is a dictionary of named caches; default is the one used by the functions that do not name a cache. Without the setting Django uses a local-memory cache.
Each entry names a backend class and a LOCATION, whose meaning depends on the backend. We define one cache of each kind: LocMemCache keeps values in the memory of the process, which is fast and
needs no setup but is not shared between processes and is lost on restart; FileBasedCache stores one file per key in a directory; DatabaseCache uses a table; RedisCache and
PyMemcacheCache talk to servers that are shared by all processes and machines of the application. In production the last two are the usual choices.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'default',
},
'file': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': BASE_DIR / 'cache_files',
},
'db': {
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
'LOCATION': 'django_cache',
},
'redis': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6380',
},
'memcached': {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
'LOCATION': '127.0.0.1:11212',
},
}
The Redis and Memcached servers run in Docker containers here; the ports are shifted so that they do not collide with servers that may run on the machine. The database cache needs its table, which the command
createcachetable creates.
$ docker run --rm -d --name zc-redis -p 6380:6379 redis:8 $ docker run --rm -d --name zc-memcached -p 11212:11211 memcached:1.6
The first two commands start the servers; the containers are removed when they stop. The third command creates the table django_cache.
$ uv run python manage.py createcachetable Cache table 'django_cache' already exists.
The same code works with every backend. The script walks through the aliases, stores a dictionary and a counter in each cache, and reads them back; then it looks at where the data landed: the files, the rows of the table, and the keys in Redis.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import time
from pathlib import Path
import redis
from django.conf import settings
from django.core.cache import caches
from django.db import connection
for alias in settings.CACHES:
cache = caches[alias]
cache.clear()
start = time.perf_counter()
cache.set('greeting', {'text': 'hello'}, 300)
value = cache.get('greeting')
cache.set('counter', 0)
cache.incr('counter')
elapsed = (time.perf_counter() - start) * 1000
backend = settings.CACHES[alias]['BACKEND'].rsplit('.', 2)
print(f'{alias:<10} {backend[-2] + "." + backend[-1]:<28} get -> {value}, counter -> {cache.get("counter")}')
print()
files = list(Path(settings.CACHES['file']['LOCATION']).iterdir())
print('file :', len(files), 'files', sorted({path.suffix for path in files}))
with connection.cursor() as cursor:
cursor.execute('SELECT cache_key, substr(value, 1, 12) FROM django_cache')
print('db :', cursor.fetchall())
print('redis:', sorted(key.decode() for key in redis.Redis(port=6380).keys('*')))
print('timeout=300 -> ttl in Redis:', redis.Redis(port=6380).ttl(':1:greeting'), 'seconds')
All five caches give the same answers. Django builds the key from the key prefix, the version, and the name, so the key greeting is stored as :1:greeting (the prefix is empty
by default). The file cache has one file per key, the database cache one row per key with the pickled value in a column, and Redis has the keys with the time to live that we passed, 300 seconds.
$ uv run python -m demos.backends
default locmem.LocMemCache get -> {'text': 'hello'}, counter -> 1
file filebased.FileBasedCache get -> {'text': 'hello'}, counter -> 1
db db.DatabaseCache get -> {'text': 'hello'}, counter -> 1
redis redis.RedisCache get -> {'text': 'hello'}, counter -> 1
memcached memcached.PyMemcacheCache get -> {'text': 'hello'}, counter -> 1
file : 2 files ['.djcache']
db : [(':1:greeting', 'gAWVEwAAAAAA'), (':1:counter', 'gAVLAS4=')]
redis: [':1:counter', ':1:greeting']
timeout=300 -> ttl in Redis: 300 seconds
The low-level cache API
The object django.core.cache.cache is a proxy to the default cache; caches['name'] returns another one. The main methods are set(), get() with an optional default,
add(), which stores a value only if the key does not exist yet, get_or_set(), which takes a value or a callable, delete(), incr() and decr(), the bulk methods
get_many(), set_many(), and delete_many(), touch() for renewing the lifetime, and clear(). The argument timeout is in seconds: None means forever
and 0 means do not store. The default is 300 seconds, set by TIMEOUT in the entry of CACHES. The argument version makes it possible to keep several generations of a key.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import time
from django.core.cache import cache
cache.clear()
cache.set('greeting', 'hello', timeout=30)
print('get:', cache.get('greeting'))
print('missing key:', cache.get('nothing'), '| with default:', cache.get('nothing', 'n/a'))
print('add existing key:', cache.add('greeting', 'changed'), '->', cache.get('greeting'))
print('add new key:', cache.add('fresh', 1), '->', cache.get('fresh'))
print('in:', 'greeting' in cache)
print()
cache.set('visits', 0)
print('incr:', cache.incr('visits'), cache.incr('visits', 5), '| decr:', cache.decr('visits'))
print('get_many:', cache.get_many(['greeting', 'visits', 'nothing']))
cache.set_many({'a': 1, 'b': 2, 'c': 3})
cache.delete_many(['a', 'b'])
print('after delete_many:', cache.get_many(['a', 'b', 'c']))
print()
calls = []
def expensive():
calls.append(1)
return 42
print('get_or_set:', cache.get_or_set('answer', expensive, 60), cache.get_or_set('answer', expensive, 60),
'| function called', len(calls), 'time(s)')
print()
cache.set('short', 'x', timeout=1)
cache.set('forever', 'x', timeout=None)
cache.set('now', 'x', timeout=0)
print('timeout=0 stores nothing:', cache.get('now'))
time.sleep(1.2)
print('after 1.2 s: short =', cache.get('short'), '| forever =', cache.get('forever'))
cache.set('touch', 'x', timeout=1)
cache.touch('touch', timeout=60)
time.sleep(1.2)
print('touch() extended the key:', cache.get('touch'))
print()
original = ['a', 'b']
cache.set('list', original)
original.append('c')
fetched = cache.get('list')
fetched.append('d')
print('values are pickled copies:', original, fetched, cache.get('list'))
cache.set('page', 'version one', version=1)
cache.set('page', 'version two', version=2)
print('versions:', cache.get('page', version=1), '|', cache.get('page', version=2), '| default version:', cache.get('page'))
print('make_key:', cache.make_key('page'), '| version 2:', cache.make_key('page', version=2))
cache.clear()
print('after clear():', cache.get('greeting'))
add() returns False and leaves the old value when the key exists. get_or_set() calls the function only the first time. Expiration is lazy: after 1.2 seconds the key
with the timeout of one second is gone, while touch() saved the other one. Note that the cache stores a pickled copy: changing the list that we put there, or the list that we got back, does not change the cached
value. Versions are separate keys, :1:page and :2:page. Do not rely on the cache for data that must not be lost; it may drop any key at any time, so the code must always be able to compute the value again.
$ uv run python -m demos.lowlevel
get: hello
missing key: None | with default: n/a
add existing key: False -> hello
add new key: True -> 1
in: True
incr: 1 6 | decr: 5
get_many: {'greeting': 'hello', 'visits': 5}
after delete_many: {'c': 3}
get_or_set: 42 42 | function called 1 time(s)
timeout=0 stores nothing: None
after 1.2 s: short = None | forever = x
touch() extended the key: x
values are pickled copies: ['a', 'b', 'c'] ['a', 'b', 'd'] ['a', 'b']
versions: version one | version two | default version: version one
make_key: :1:page | version 2: :2:page
after clear(): None
Caching views
The decorator cache_page(seconds) caches the whole response of a view, for GET and HEAD requests with the status 200. The key is made of the URL, including the query string, and of the headers named
in the Vary header of the response. The decorator also sets Cache-Control: max-age, so browsers and proxies may cache the page too. If the page differs for different visitors, it has to say so: vary_on_cookie (or
vary_on_headers) adds the header Vary, and the cache then keeps one copy per cookie value. A page that shows personal data without it would be shown to the wrong person. The decorator never_cache does the opposite and
tells the clients not to keep the response. The views of the example count how many times they were built, so that the cache hits are visible. The remaining views are used in the later sections: product_count caches a number with
get_or_set() and product_list renders a template with a cached fragment.
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.decorators.cache import cache_page, never_cache
from django.views.decorators.vary import vary_on_cookie
from .models import Product
CALLS = {'report': 0, 'greeting': 0, 'plain': 0}
@cache_page(60)
def report(request):
CALLS['report'] += 1
return HttpResponse(f'The report was built {CALLS["report"]} time(s)')
@cache_page(60)
@vary_on_cookie
def greeting(request):
CALLS['greeting'] += 1
name = request.COOKIES.get('name', 'stranger')
return HttpResponse(f'Hello, {name} (built {CALLS["greeting"]} time(s))')
def plain(request):
CALLS['plain'] += 1
return HttpResponse(f'The page was built {CALLS["plain"]} time(s)')
@never_cache
def clock(request):
return HttpResponse('never cached')
def product_count(request):
count = cache.get_or_set('product_count', lambda: Product.objects.count(), 300)
return JsonResponse({'count': count})
def product_list(request):
return render(request, 'catalog/product_list.html', {
'products': Product.objects.all(),
'top_products': Product.objects.all()[:3],
})
The URL patterns map the paths to the views, and the project includes them at the root.
from django.urls import path
from . import views
urlpatterns = [
path('report/', views.report),
path('greeting/', views.greeting),
path('plain/', views.plain),
path('clock/', views.clock),
path('count/', views.product_count),
path('products/', views.product_list),
]
The root URL configuration includes the URLs of the app.
from django.urls import include, path
urlpatterns = [
path('', include('catalog.urls')),
]
The script visits the views with the test client and prints what the views built, the headers, and the number of queries.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from django.core.cache import cache
from django.db import connection
from django.test.utils import CaptureQueriesContext
from catalog import views
logging.getLogger('django.request').setLevel(logging.ERROR)
cache.clear()
client = Client()
def headers(response):
return {name: response[name] for name in ('Cache-Control', 'Vary') if name in response}
print('cache_page(60)')
for number in (1, 2):
response = client.get('/report/')
print(f' request {number}:', response.content.decode(), headers(response) if number == 1 else '')
print(' with a query string:', client.get('/report/?page=2').content.decode())
print()
print('cache_page + vary_on_cookie')
for cookie in ('anna', 'ben', 'anna', None):
other = Client()
if cookie:
other.cookies['name'] = cookie
response = other.get('/greeting/')
print(f' cookie {cookie!s:<5}:', response.content.decode(), '| Vary:', response['Vary'])
print()
response = client.get('/clock/')
print('never_cache:', headers(response))
print()
print('get_or_set in a view')
for number in (1, 2):
with CaptureQueriesContext(connection) as queries:
data = client.get('/count/').json()
print(f' request {number}:', data, '| queries:', len(queries))
The second request for /report/ is served from the cache: the counter did not change. A different query string is a different page. With vary_on_cookie the pages for
anna and ben are separate, the repeated request for anna is a hit, and a visitor without the cookie gets a third copy. The response of never_cache carries the
directives that forbid caching. The view /count/ runs the query once; the second request needs no query at all.
$ uv run python -m demos.views
cache_page(60)
request 1: The report was built 1 time(s) {'Cache-Control': 'max-age=60'}
request 2: The report was built 1 time(s)
with a query string: The report was built 2 time(s)
cache_page + vary_on_cookie
cookie anna : Hello, anna (built 1 time(s)) | Vary: Cookie
cookie ben : Hello, ben (built 2 time(s)) | Vary: Cookie
cookie anna : Hello, anna (built 1 time(s)) | Vary: Cookie
cookie None : Hello, stranger (built 3 time(s)) | Vary: Cookie
never_cache: {'Cache-Control': 'max-age=0, no-cache, no-store, must-revalidate, private'}
get_or_set in a view
request 1: {'count': 5} | queries: 1
request 2: {'count': 5} | queries: 0
Caching template fragments and invalidation
Sometimes only a part of the page is expensive. The tag {% cache seconds name %} from the library cache caches the rendered text of its block. Additional arguments after the name,
such as a user id, make separate fragments. The template below shows a list of top products. The list is a lazy QuerySet in the context, so the database is queried only when the block has to be rendered, which makes the saving visible.
{% load cache %}
<h1>Products</h1>
{% cache 300 top_products %}
<h2>Top products</h2>
<ul>
{% for product in top_products %}
<li>{{ product.name }}: {{ product.price }}</li>
{% endfor %}
</ul>
{% endcache %}
<p>All products: {{ products|length }}</p>
The hardest part of caching is knowing when to throw the data away. The simplest strategy is expiration, accepting that the data may be up to 300 seconds old. When that is too long, the code that changes the data deletes
the cache entries that depend on it. The receivers in catalog/models.py do this after each save and delete of a product: they delete the counter and the fragment, whose key is rebuilt with
make_template_fragment_key(). We use signals rather than overriding save() and delete(), because the QuerySet method delete() does not call the
delete() of the model, but it sends the signals. The script below shows the cycle.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import connection
from django.test.utils import CaptureQueriesContext
from catalog.models import Product
cache.clear()
client = Client()
print('fragment key:', make_template_fragment_key('top_products'))
print()
def visit(label):
with CaptureQueriesContext(connection) as queries:
html = client.get('/products/').content.decode()
names = [line.strip() for line in html.splitlines() if line.strip().startswith('<li>')]
print(f'{label:<24} queries: {len(queries)} | {names[0]} ... ({len(names)} items)')
visit('first request')
visit('second request')
Product.objects.create(name='Monitor', price='199.00')
visit('after a new product')
visit('next request')
Product.objects.filter(name='Monitor').delete()
visit('after a queryset delete')
visit('next request')
Product.objects.filter(name='Pen').update(price='1.30')
print('the key is still there after update():', cache.get(make_template_fragment_key('top_products')) is not None)
The first request needs two queries, one for the fragment and one for the count of all products. The next ones need only the second. Creating a product or deleting products with a QuerySet
invalidates the fragment, so the next request builds it again. The last line shows the limit of the approach: update(), like bulk_create() and bulk_update(), does not send any signal, so a code that uses them has to invalidate the cache itself.
$ uv run python -m demos.fragment fragment key: template.cache.top_products.d41d8cd98f00b204e9800998ecf8427e first request queries: 2 | <li>Desk: 249.00</li> ... (3 items) second request queries: 1 | <li>Desk: 249.00</li> ... (3 items) after a new product queries: 2 | <li>Desk: 249.00</li> ... (3 items) next request queries: 1 | <li>Desk: 249.00</li> ... (3 items) after a queryset delete queries: 2 | <li>Desk: 249.00</li> ... (3 items) next request queries: 1 | <li>Desk: 249.00</li> ... (3 items) the key is still there after update(): True
The per-site cache
The per-site cache caches every page of the site with two middleware classes. UpdateCacheMiddleware must be first in MIDDLEWARE and stores the response on the way out; FetchFromCacheMiddleware must be last and answers from the
cache on the way in. The settings CACHE_MIDDLEWARE_SECONDS, CACHE_MIDDLEWARE_KEY_PREFIX, and CACHE_MIDDLEWARE_ALIAS configure it. Only GET and HEAD responses with the status 200 are stored, and the
middleware respects Vary and Cache-Control: private. (The order of middleware is described in Django middleware.) The script switches the middleware on for a moment; the
middleware reads its settings when it is created, so the script uses a new test client.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.core.cache import cache
from django.test import override_settings
from catalog import views
MIDDLEWARE = [
'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.cache.FetchFromCacheMiddleware',
]
cache.clear()
views.CALLS['plain'] = 0
with override_settings(MIDDLEWARE=MIDDLEWARE, CACHE_MIDDLEWARE_SECONDS=120,
CACHE_MIDDLEWARE_KEY_PREFIX='site'):
client = Client()
for path in ('/clock/', '/clock/', '/plain/', '/plain/', '/plain/?x=1'):
response = client.get(path)
print(f'GET {path:<12}', response.content.decode(), '|', response.get('Cache-Control'))
for number in (1, 2):
print('POST /plain/ ->', client.post('/plain/').content.decode())
The page /plain/ has no caching code of its own, yet the second request does not build it again. The query string makes a new page. The view with never_cache is not stored, and POST requests always reach the view.
$ uv run python -m demos.sitecache GET /clock/ never cached | max-age=0, no-cache, no-store, must-revalidate, private GET /clock/ never cached | max-age=0, no-cache, no-store, must-revalidate, private GET /plain/ The page was built 1 time(s) | max-age=120 GET /plain/ The page was built 1 time(s) | max-age=120 GET /plain/?x=1 The page was built 2 time(s) | max-age=120 POST /plain/ -> The page was built 3 time(s) POST /plain/ -> The page was built 4 time(s)
Testing caching
Tests should not depend on the state of a real cache, so the test class replaces CACHES with a local-memory cache and clears it before each test. The tests count the calls of the views, check the headers, and verify that creating
a product invalidates the cached count and the fragment.
from django.core.cache import cache
from django.test import TestCase, override_settings
from . import views
from .models import Product
@override_settings(CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}})
class CacheTests(TestCase):
def setUp(self):
cache.clear()
views.CALLS.update(report=0, greeting=0, plain=0)
def test_cache_page(self):
first = self.client.get('/report/')
second = self.client.get('/report/')
self.assertEqual(first.content, second.content)
self.assertEqual(views.CALLS['report'], 1)
self.assertIn('max-age=60', first['Cache-Control'])
def test_vary_on_cookie(self):
self.client.cookies['name'] = 'anna'
self.assertContains(self.client.get('/greeting/'), 'Hello, anna')
self.client.cookies['name'] = 'ben'
self.assertContains(self.client.get('/greeting/'), 'Hello, ben')
self.assertEqual(views.CALLS['greeting'], 2)
def test_never_cache(self):
self.assertIn('no-cache', self.client.get('/clock/')['Cache-Control'])
def test_count_is_cached_and_invalidated(self):
Product.objects.create(name='Pen', price='1.20')
self.assertEqual(self.client.get('/count/').json(), {'count': 1})
with self.assertNumQueries(0):
self.assertEqual(self.client.get('/count/').json(), {'count': 1})
Product.objects.create(name='Pad', price='2.00')
self.assertEqual(self.client.get('/count/').json(), {'count': 2})
def test_fragment_is_invalidated(self):
Product.objects.create(name='Pen', price='1.20')
self.assertContains(self.client.get('/products/'), 'Pen')
Product.objects.create(name='Lamp', price='19.90')
self.assertContains(self.client.get('/products/'), 'Lamp')
All five tests pass. The test of the count uses assertNumQueries(0) to prove that the second request is served from the cache. The test does not touch the servers, which can be stopped
with docker stop zc-redis zc-memcached.
$ uv run python manage.py test Found 5 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ..... ---------------------------------------------------------------------- Ran 5 tests in 0.011s OK Destroying test database for alias 'default'...
Source
Django documentation: Django's cache framework
Django documentation: CACHES setting
Django documentation: Cache middleware
In this article we have configured five cache backends, used the low-level API, cached views and template fragments, enabled the per-site cache, invalidated entries with signals, and tested the result. Related articles are Django query performance, Django sessions and cookies, and Django middleware.
Author
List all Django tutorials.