Django Performance in Practice: Kill N+1, Then Cache
When a Django site feels sluggish, teams often reach for more workers,
bigger Postgres instances, or a CDN. Those help—but the highest ROI fixes
almost always sit in the ORM layer. Production guides from 2025–2026 keep
repeating the same order of operations for a reason.
1. See the queries before you “optimize”
Install django-debug-toolbar locally and watch the SQL panel on list
pages. In staging, log slow queries or use connection.queries in a
throwaway management command. You cannot fix what you do not measure.
A useful heuristic: a simple page should stay in roughly single-digit
queries. Spikes usually mean N+1 or accidental queries inside loops or
template tags.
2. Fix N+1 with the right prefetch tool
select_related— foreign keys / one-to-one (SQLJOIN).prefetch_related— reverse FK and many-to-many (separate query,
then join in Python). For M2M, prefer prefetch over inventing giant
joins that duplicate rows.
posts = (
Post.objects.filter(status="published")
.select_related("category")
.prefetch_related("tags")
)
Never call related managers inside a template loop without prefetching
first. That single habit removes most “mysterious” latency.
3. Fetch less data
Use only() / defer() when serializers or cards need a handful of
columns. Prefer exists() over count() when you only care about
presence. Batch writes with bulk_create / bulk_update instead of
saving in a loop.
4. Index what you filter and order by
If every list view filters on status and orders by -published_at,
that composite path belongs in an index. Use EXPLAIN (ANALYZE, BUFFERS)
on Postgres before and after. Partial indexes (for example only
status='published') keep hot paths small.
Avoid indexing everything “just in case”—write amplification is real.
5. Cache after the query plan is clean
Caching a bad queryset only freezes waste. Once queries are tight:
- Cache expensive fragments or whole pages with Django’s cache framework.
- Put Redis in front for shared cache across workers.
- Invalidate deliberately (signal on save, short TTLs, or queryset-aware
helpers). Blindcache.clear()is not a strategy.
Connection pooling (PgBouncer or Django’s newer pooling options)
matters when you open many short-lived connections under load—after you
stop issuing fifty queries per request.
A realistic checklist
| Step | Action | Done when |
|---|---|---|
| Profile | Debug toolbar / slow query log | Hot endpoints identified |
| ORM | select_related / prefetch_related |
N+1 gone on list/detail |
| Shape | only / pagination |
Payloads shrink |
| Indexes | Match filters & ordering | EXPLAIN looks healthy |
| Cache | Redis + targeted keys | Repeat traffic is cheap |
| Infra | Pooling / scale-out | Only if still needed |
Django performance in 2026 is rarely mysterious. Eliminate unnecessary
queries, index the paths you already query, cache the expensive leftovers,
and only then buy bigger boxes. That sequence still beats premature
microservices.