Speed up Django read‑heavy views with per‑view caching – a hands‑on guide
Learn how to enable Django's @cache_page for read‑heavy views, measure speedups, and handle cache invalidation. A step‑by‑step guide with example code, trade‑offs, and testing tips.
09 Nov 2025, 15:51 UTC

Concrete problem
Imagine a blog that receives thousands of visits per hour. Every request to the main list page triggers a full SELECT * FROM posts query, the ORM builds a complex queryset, and the template renders dozens of rows. The database becomes the bottleneck, response times climb, and users start to abandon the site. A quick way to relieve the pressure is to cache the rendered page so that the view logic is executed only once per time‑to‑live (TTL).
What is per‑view caching?
Per‑view caching is a feature built into Django that stores the entire HTTP response for a view. The decorator @cache_page tells Django to look up the response in the cache before running the view. If a cached response exists, Django returns it immediately, skipping the database, template rendering, and any middleware that would normally run for that request.
The cache key is built from the request path and query string by default. You can override it with key_prefix or a custom key function if you need finer control.
Step 1 – Pick a backend
For local development Django ships with a simple in‑memory cache, but it is not shared across processes or hosts. In production you should use a shared backend such as Redis or Memcached. The following example uses Redis:
# settings.py
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://localhost:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
Run the Redis server locally with docker run -p 6379:6379 redis or ensure the host is reachable. No additional Django settings are required for per‑view caching to work.
Step 2 – Apply @cache_page
Decorate the view you want to cache. The argument is the TTL in seconds. The following view lists blog posts and caches the result for one minute.
from django.views.decorators.cache import cache_page
from django.shortcuts import render
from .models import Post
@cache_page(60) # 60‑second cache
def post_list(request):
posts = Post.objects.all()
return render(request, 'post_list.html', {'posts': posts})
Now every GET request to /posts/ will hit the cache after the first hit.
Custom cache keys
By default the key is django_cache:GET:/posts/. If you paginate the list, you want separate keys per page. Use key_prefix to include the page number:
from django.core.cache import cache
@cache_page(60, key_prefix='posts-page')
def post_list(request):
page = request.GET.get('page', '1')
posts = Post.objects.all()
return render(request, 'post_list.html', {'posts': posts, 'page': page})
Now the cache key becomes django_cache:GET:/posts/?page=2 for the second page. If you need even more control, generate the key manually and store the response with cache.set(), but the decorator covers most use‑cases.
Step 3 – Invalidate the cache
Cached pages can become stale when the underlying data changes. Django signals let you clear the relevant keys automatically:
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.cache import cache
@receiver(post_save, sender=Post)
def clear_post_cache(sender, instance, **kwargs):
# Remove all cached pages for the list view
cache.delete_pattern('posts-page*')
Note that delete_pattern is a helper provided by django-redis; if you are using a different backend, you may need to iterate over keys manually or use a cache key naming convention that makes manual deletion straightforward.
Measure the performance gain
- Run the development server:
python manage.py runserver. - Open a terminal and use
curlto fetch the page twice, measuring the total time:
# First hit – view executed
curl -w "%{time_total}\n" -o /dev/null http://127.0.0.1:8000/posts/
# Subsequent hit – should be < 0.1s if cached
curl -w "%{time_total}\n" -o /dev/null http://127.0.0.1:8000/posts/
On a typical machine you’ll see the first request take several hundred milliseconds, while the cached hit is under 50 ms. Verify that the database connection count stays low by looking at your database logs or using a tool like pg_stat_statements for PostgreSQL.
Trade‑offs and limitations
- Stale data – The cache does not know when the data changes unless you invalidate it. Set a reasonable TTL or use signals.
- Memory usage – In‑memory backends consume RAM per worker. For large pages or many cached views, switch to Redis or Memcached.
- Non‑GET methods –
@cache_pageonly caches GET requests by default. If you need to cache POST or other verbs, you must explicitly allow them. - Dynamic content – If your page contains user‑specific data (e.g., logged‑in user info), caching the whole page will expose data across users. Use per‑user prefixes or cache fragments instead.
Actionable next steps
- Configure a shared cache backend in
settings.py(Redis is recommended). - Identify the most read‑heavy views and decorate them with
@cache_page, choosing an appropriate TTL. - Implement signal‑based invalidation for models that affect the cached pages.
- Measure response times before and after to confirm the expected speedup.
- Monitor cache hit/miss ratios with your cache provider’s dashboard or by inspecting the
cache.get_info()output.
By following these steps you can reduce database load, improve response times, and deliver a smoother experience for your users without changing the core logic of your Django application.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.