Settings, caching and query performance
Split settings per environment, cache the expensive parts with a shared backend, and fix the database queries that make a Django app slow.
One base, small deltas per environment
# config/settings/base.py # everything shared
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env(DJANGO_DEBUG=(bool, False), ALLOWED_HOSTS=(list, ["localhost"]))
environ.Env.read_env(BASE_DIR / ".env") # local convenience, git-ignored
INSTALLED_APPS = [...]
# config/settings/production.py
from .base import * # noqa: F403
DEBUG = False
SECRET_KEY = env("DJANGO_SECRET_KEY") # no default: fail loudly if unset
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
DATABASES = {"default": env.db("DATABASE_URL")}
CACHES = {"default": env.cache("REDIS_URL")}
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"export DJANGO_SETTINGS_MODULE=config.settings.production
python manage.py check --deploy
python manage.py diffsettings # show what differs from the defaults- Keep one
base.pyand override only the keys that differ. Copying the whole file per environment guarantees that a security setting fixed in one copy stays broken in the other. - Read every secret from the environment and give it no default in production:
env("DJANGO_SECRET_KEY")raisesImproperlyConfiguredat startup, which is far better than running with a placeholder key that signs every session identically. DEBUGmust be a parsed boolean, not a truthy string."False"is a non-empty string, sobool("False")isTrue— the classic accidental debug mode in production.- Never branch a security decision on
DEBUGinside application code. Read a dedicated setting so the intent is explicit and testable.
Caching
# config/settings/production.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_URL"),
"KEY_PREFIX": "blog-prod", # shared Redis, isolated namespace
"TIMEOUT": 300,
}
}from django.core.cache import cache
# low level: you own the key, so you own invalidation
cache.get("home:featured")
cache.set("home:featured", payload, timeout=120)
cache.get_or_set("stats:counts", compute_counts, timeout=60) # callable, lazy
cache.delete("home:featured")
cache.delete_pattern("home:*") # not on all backends
# per-view
from django.views.decorators.cache import cache_page, vary_on_cookie
@cache_page(60 * 15)
@vary_on_cookie
def post_detail(request, slug):
...
# template fragment
{% load cache %}
{% cache 300 sidebar request.user.pk %} ... expensive markup ... {% endcache %}| Backend | Shared across | Use it for |
|---|---|---|
LocMemCache | One process | Development only — each worker has its own copy |
RedisCache | Workers and hosts | The production default; TTL expiry and prefixed keys |
MemcachedCache | Workers and hosts | Pure key/value, slightly faster, no pattern delete |
DatabaseCache | Workers and hosts | When adding Redis is not an option; adds DB load |
DummyCache | Nobody | Tests, and a hard-off switch in staging |
- Every key needs a version or a prefix you can bump, so a deploy can invalidate everything by changing one string instead of hunting keys.
- Invalidate on write rather than on a timer when staleness is unacceptable: delete the key in
save()or in the service function that performs the change. - Cache a plain serialisable structure (dict, list, id) rather than a model instance or a queryset. Pickled model instances go stale silently and break on any deployed model change.
Query performance
# fetch only what you use
Post.objects.only("id", "title")
Post.objects.defer("body")
Post.objects.values_list("id", "title") # tuples: no model instances at all
# exists / count without loading rows
Post.objects.filter(status="published").exists()
Post.objects.filter(status="published").count() # len(qs) fetches every row
# eager loading: select_related joins, prefetch_related issues one extra query
Post.objects.select_related("author", "category").prefetch_related("tags")
Prefetch("comments", queryset=Comment.objects.filter(approved=True), to_attr="approved")
# aggregate in the database
Category.objects.annotate(n=Count("posts", filter=Q(posts__status="published")))
Post.objects.aggregate(avg=Avg("views"))
# writes
Post.objects.filter(status="draft", created_at__lt=cutoff).update(status="archived")
Post.objects.bulk_create(posts, batch_size=500)
# what will the planner do?
print(Post.objects.filter(status="published").explain())# count queries in a test — an N+1 regression cannot survive CI
from django.test import TestCase
from django.db import connection, reset_queries
class ListPerformanceTests(TestCase):
def test_list_uses_two_queries(self):
with self.assertNumQueries(2):
list(Post.objects.select_related("author"))- Index what you filter and order by:
Meta.indexes,db_index=Trueon a foreign key you sort on, and composite indexes matching the realWHERE ... ORDER BYpair. An index costs write time and disk. - A foreign key column is indexed automatically; a
statuscolumn is not, and neither is the pair you filter together. Check withEXPLAIN ANALYZEbefore adding one. - Set
DEBUG=Trueplus the Django Debug Toolbar in development and look at the query list on the slow page. The fix is nearly always an eager load or an aggregate, not a faster server.
⚠️
Never cache a page that depends on the logged-in user without varying on the cookie.
cache_page keys on the URL by default, so the first authenticated visitor's name and data are served to everyone who requests that URL next — a data leak that looks like a performance win. Add @vary_on_cookie, or cache only the fragments that are genuinely shared.FAQ
How long should a cache entry live?
As short as the requirement allows. Start with 60-300 seconds for page-level and longer for data that changes rarely, then make the write path delete the key when correctness matters. A TTL is a fallback for a missed invalidation, not the invalidation strategy itself.
Where do I put expensive computation?
Out of the request. Cache the result if it is cheap to recompute and read often; move it to a background task if it is slow or must happen on a schedule. A view should do bounded work regardless of how much data the site holds.
Related
Migrations in depth Middleware, signals and management commands
Last refreshed 2026-09-18.