The Caching Framework
Reviewed & published by Brayan K
Caching is the technique of storing the result of an expensive operation so future requests can reuse it instead of recomputing it, trading a little staleness for a lot of speed.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll use the low-level cache API (set, get, add, delete), see how a timeout makes entries expire, cache an entire view with @cache_page, cache a template fragment, and understand the backend choices from LocMem to Redis.
What You'll Learn in This Lesson
- Use cache.set, get, add, and delete
- Make entries expire with a timeout (TTL)
- Cache a whole view with @cache_page
- Cache part of a page with {% cache %}
- Choose backends: LocMem, Redis, Memcached
- Design cache keys that stay unique
1 The Low-Level Cache API
The low-level API treats the cache like a dictionary you can talk to from anywhere. You import the cache object and call set, get, add (write only if the key is new), and delete. The backend behind it is configured in CACHES, but your code stays the same.
# settings.py — local memory is the zero-config default
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
}
# anywhere in your code
from django.core.cache import cache
cache.set("homepage", rendered_html, timeout=300) # store for 5 minutes
value = cache.get("homepage") # None if missing/expired
cache.add("user:1", "Ada") # writes only if new
cache.delete("homepage") # remove it# The low-level cache API is a dict you can talk to: set a value (optionally
# with a timeout), get it back (or a default if it's missing), add only if the
# key is new, and delete it. This TTLCache mirrors Django's cache object.
class TTLCache:
def __init__(self):
self.store = {} # key -> value
def set(self, key, value, timeout=300):
self.store[key] = value
def get(self, key, default=None):
return self.store.get(key, default)
def add(self, key, value, timeout=300):
# add() only writes if the key isn't already present
if key in self.store:
return False
self.store[key] = value
return True
def delete(self, key):
self.store.pop(key, None)
cache = TTLCache()
cache.set("homepage", "<html>...</html>", timeout=300)
print("get homepage:", cache.get("homepage"))
print("get missing:", cache.get("missing", "DEFAULT"))
print("add new key:", cache.add("user:1", "Ada")) # True, key was new
print("add same key:", cache.add("user:1", "Bob")) # False, already there
print("user:1 is still:", cache.get("user:1"))
cache.delete("homepage")
print("after delete:", cache.get("homepage", "GONE"))
# EXPECTED:
# get homepage: <html>...</html>
# get missing: DEFAULT
# add new key: True
# add same key: False
# user:1 is still: Ada
# after delete: GONE
# ✅ Expected output:
# get homepage: <html>...</html>
# get missing: DEFAULT
# add new key: True
# add same key: False
# user:1 is still: Ada
# after delete: GONE2 Timeouts: How Entries Expire
The timeout argument is the key to caching. A value lives for that many seconds, then expires — a later get returns None and you recompute. This time-to-live (TTL) is what keeps cached data from going permanently stale.
from django.core.cache import cache
def get_report():
report = cache.get("report")
if report is None: # miss or expired
report = build_expensive_report() # slow work
cache.set("report", report, timeout=60) # cache for 60s
return report
# timeout=None means "cache forever"; timeout=0 means "don't cache"# A real cache entry EXPIRES after its timeout. We store (value, expires_at)
# and treat the entry as missing once "now" passes expires_at. This is the
# heart of @cache_page and cache.set(key, value, timeout).
class TTLCache:
def __init__(self):
self.store = {} # key -> (value, expires_at)
def set(self, key, value, timeout, now):
self.store[key] = (value, now + timeout)
def get(self, key, now, default=None):
if key not in self.store:
return default
value, expires_at = self.store[key]
if now >= expires_at:
del self.store[key] # expired -> evict and miss
return default
return value
cache = TTLCache()
# Cache an expensive result for 60 seconds, "now" = t=100
cache.set("report", "EXPENSIVE_RESULT", timeout=60, now=100)
print("at t=120 (fresh):", cache.get("report", now=120, default="MISS"))
print("at t=170 (expired):", cache.get("report", now=170, default="MISS"))
print("after expiry, recompute and re-cache...")
cache.set("report", "EXPENSIVE_RESULT", timeout=60, now=170)
print("at t=180 (fresh again):", cache.get("report", now=180, default="MISS"))
# EXPECTED:
# at t=120 (fresh): EXPENSIVE_RESULT
# at t=170 (expired): MISS
# after expiry, recompute and re-cache...
# at t=180 (fresh again): EXPENSIVE_RESULT
# ✅ Expected output:
# at t=120 (fresh): EXPENSIVE_RESULT
# at t=170 (expired): MISS
# after expiry, recompute and re-cache...
# at t=180 (fresh again): EXPENSIVE_RESULT3 Per-View and Template-Fragment Caching
You rarely write the cache-aside loop by hand. The @cache_page decorator caches a view's entire response for a given timeout, and the {% cache %} template tag caches just one fragment of a page while the rest stays dynamic.
# Per-view caching: cache the whole response for 15 minutes
from django.views.decorators.cache import cache_page
@cache_page(60 * 15)
def article_list(request):
articles = Article.objects.all()
return render(request, "blog/articles.html", {"articles": articles})<!-- Template-fragment caching: cache only the sidebar for 600 seconds -->
{% load cache %}
{% cache 600 sidebar request.user.username %}
<aside>... expensive sidebar ...</aside>
{% endcache %}Under the hood both decorators run the same cache-aside pattern you just simulated: check the cache by key, and only do the slow work on a miss.
# The cache-aside pattern (what @cache_page automates): try the cache first;
# on a miss, compute the value, store it, and return it. We count how often
# the expensive function actually runs to prove the cache is working.
class Cache:
def __init__(self):
self.store = {}
def get(self, key, default=None):
return self.store.get(key, default)
def set(self, key, value, timeout=300):
self.store[key] = value
cache = Cache()
calls = {"count": 0}
def expensive_query(user_id):
calls["count"] += 1
return f"profile-data-for-{user_id}"
def get_profile(user_id):
key = f"profile:{user_id}" # a descriptive cache key
cached = cache.get(key)
if cached is not None:
return cached # cache HIT
value = expensive_query(user_id) # cache MISS -> compute
cache.set(key, value, timeout=300)
return value
print(get_profile(7)) # miss -> runs query
print(get_profile(7)) # hit -> no query
print(get_profile(7)) # hit -> no query
print("expensive_query ran:", calls["count"], "time(s)")
# EXPECTED:
# profile-data-for-7
# profile-data-for-7
# profile-data-for-7
# expensive_query ran: 1 time(s)
# ✅ Expected output:
# profile-data-for-7
# profile-data-for-7
# profile-data-for-7
# expensive_query ran: 1 time(s)🎯 Your Turn: Build a Unique Cache Key
A cache key must uniquely name what you stored. Fill in the blank so the key for user 42 becomes profile:42.
# 🎯 YOUR TURN: build a descriptive cache key for a user's profile.
# Cache keys are strings that uniquely name what you stored.
# Replace ___ so the key becomes "profile:42".
user_id = 42
key = f"profile:___"
print(key)
# EXPECTED OUTPUT:
# profile:42Common Errors (And How to Fix Them)
❌ Cached page shows stale data forever
You set a value with timeout=None (cache forever) and never invalidate it.
✅ Fix: give a sensible timeout, or call cache.delete(key) when the underlying data changes.
❌ One user sees another user's cached data
Your cache key was too generic, e.g. just "profile" for everyone.
✅ Fix: include the distinguishing input in the key, like "profile:" + str(user.id).
❌ Cache "works" locally but not across servers
LocMemCache is per-process, so each worker has its own separate cache.
✅ Fix: use a shared backend like Redis or Memcached in production via the CACHES setting.
📋 Quick Reference
| Call / Tool | What It Does |
|---|---|
| cache.set(k, v, timeout) | Store a value for some seconds |
| cache.get(k, default) | Read a value (default on miss) |
| cache.add(k, v) | Write only if the key is new |
| @cache_page(secs) | Cache a view's whole response |
| {% cache %} | Cache one fragment of a template |
🏆 Mini Challenge: A Memoizing Cache Decorator
Write a @cached decorator that stores each result by its argument, so repeated calls with the same value skip the work — the same idea as @cache_page applied to a function.
# 🏆 Mini Challenge: a memoizing cache decorator, the idea behind cache_page.
# Wrap a slow function so repeated calls with the same argument are served
# from a cache instead of recomputed.
def cached(func):
store = {}
def wrapper(n):
if n in store:
return store[n] # HIT
result = func(n) # MISS -> compute
store[n] = result
return result
return wrapper
run_log = []
@cached
def square(n):
run_log.append(n) # record each real computation
return n * n
print(square(4))
print(square(4))
print(square(5))
print(square(4))
print("actually computed:", run_log)
# EXPECTED:
# 16
# 16
# 25
# 16
# actually computed: [4, 5]❓ Frequently Asked Questions
Lesson complete — your pages are fast!
You can use the low-level API (set, get, add, delete), make entries expire with a timeout, cache a view with @cache_page, cache a fragment with {% cache %}, choose a backend, and design unique cache keys.
Practice quiz
Which low-level call stores a value in the cache?
- cache.put(key, value)
- cache.store(key, value)
- cache.add_value(key, value)
- cache.set(key, value)
Answer: cache.set(key, value). cache.set(key, value, timeout) stores a value, overwriting any existing one.
How does cache.add() differ from cache.set()?
- add() deletes the key
- add() writes only if the key is new
- add() never expires
- add() reads the value
Answer: add() writes only if the key is new. add() only writes when the key is absent and returns False if it already exists.
What does cache.get() return for a missing or expired key?
- None (or the provided default)
- An empty string
- A KeyError
- 0
Answer: None (or the provided default). get() returns None on a miss, or the default you pass as the second argument.
Which decorator caches an entire view's response?
- @cache_view
- @cache
- @cache_page(seconds)
- @view_cache
Answer: @cache_page(seconds). @cache_page(timeout) caches a view's whole rendered response for the given seconds.
What does the timeout argument control?
- The cache size
- The backend type
- The key length
- How many seconds a value stays valid before expiring
Answer: How many seconds a value stays valid before expiring. timeout is the TTL: after it passes, a get returns None and you recompute.
Which template tag caches just one fragment of a page?
- {% cache %}
- {% fragment %}
- {% store %}
- {% memo %}
Answer: {% cache %}. The {% cache %} template tag caches a single fragment while the rest stays dynamic.
Why might caching work locally but not across servers?
- Redis is too fast
- The timeout is too short
- LocMemCache is per-process, not shared
- The key is too long
Answer: LocMemCache is per-process, not shared. LocMemCache is per-process; use a shared backend like Redis or Memcached in production.
What causes one user to see another user's cached data?
- timeout=None
- A cache key that is too generic
- Using cache.add()
- Using Redis
Answer: A cache key that is too generic. Include the distinguishing input (like a user id) in the key so each value is unique.
Which backend needs no setup and works out of the box for development?
- Memcached
- RabbitMQ
- Redis
- LocMemCache
Answer: LocMemCache. LocMemCache is the zero-config local-memory backend, fine for development.
How do you invalidate a cached value when its data changes?
- cache.delete(key)
- cache.expire_all()
- cache.reset()
- cache.flush_key()
Answer: cache.delete(key). cache.delete(key) removes the stale entry so it is recomputed next time.
Continue this course
- Previous: Sending Email
- Next: Custom Management Commands — Automate tasks with your own manage.py commands and arguments
- Quick reference: Django cheat sheet
Frequently asked questions
What is the difference between low-level, per-view, and template-fragment caching?
Low-level caching uses the cache API directly — cache.set and cache.get — to store any value you choose, like the result of an expensive query. Per-view caching wraps an entire view with @cache_page so its whole rendered response is cached. Template-fragment caching uses the {% cache %} template tag to cache just one part of a page, such as a sidebar, while the rest stays dynamic. You pick the level that matches what is actually slow.
Which cache backend should I use?
For development the local-memory backend (LocMemCache) needs no setup and works out of the box, but it is per-process and not shared. In production you typically use Redis or Memcached because they are fast, shared across all your server processes, and survive a worker restart. You configure the choice in the CACHES setting; the cache API code you write stays exactly the same regardless of backend.
What is a cache key and why does it matter?
A cache key is the string under which a value is stored, like profile:42 or homepage. It must uniquely identify the thing you cached, because get and set both look up by that key. If two different pieces of data share a key they will overwrite each other, and if your key is too generic you may serve one user's data to another. Build keys from the inputs that make the value unique, such as a user id or a page number.
What does the timeout argument control?
The timeout is how many seconds a cached value stays valid before it expires. cache.set('key', value, timeout=300) keeps the value for five minutes; after that a get returns None and you recompute. A shorter timeout means fresher data but more recomputation, a longer timeout means faster pages but staler data. Choosing it is a trade-off between freshness and speed for each piece of content.