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.py and 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") raises ImproperlyConfigured at startup, which is far better than running with a placeholder key that signs every session identically.
  • DEBUG must be a parsed boolean, not a truthy string. "False" is a non-empty string, so bool("False") is True — the classic accidental debug mode in production.
  • Never branch a security decision on DEBUG inside 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 %}
BackendShared acrossUse it for
LocMemCacheOne processDevelopment only — each worker has its own copy
RedisCacheWorkers and hostsThe production default; TTL expiry and prefixed keys
MemcachedCacheWorkers and hostsPure key/value, slightly faster, no pattern delete
DatabaseCacheWorkers and hostsWhen adding Redis is not an option; adds DB load
DummyCacheNobodyTests, 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=True on a foreign key you sort on, and composite indexes matching the real WHERE ... ORDER BY pair. An index costs write time and disk.
  • A foreign key column is indexed automatically; a status column is not, and neither is the pair you filter together. Check with EXPLAIN ANALYZE before adding one.
  • Set DEBUG=True plus 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.

Migrations in depth Middleware, signals and management commands

Last refreshed 2026-09-18.