Backend · Frameworks
Django ORM patterns I keep coming back to
QuerySets, prefetches, and the joins that decide whether your app dies under load.
Django's ORM is one of the most ergonomic in any web framework, and one of the most foot-gunny under load. The patterns that scale aren't clever — they're disciplined.
After years of Django at varying scales (10 RPS to 8000 RPS), here are the patterns I reach for first.
select_related vs prefetch_related: the load-bearing distinction#
This is the one most teams get wrong, and it's the one that matters most for read latency.
select_relatedperforms a SQL JOIN. Use for forward foreign keys and one-to-one relations. One query.prefetch_relatedruns a second query plus an in-Python join. Use for reverse foreign keys (a parent's children) and many-to-many. Two queries, but each is a clean indexed lookup.
If you find yourself reaching for prefetch_related on a forward FK, you've made a mistake — select_related is the right call and is cheaper.
# Forward FK — JOIN, one query
Order.objects.select_related('customer').filter(status='paid')
# Reverse FK — two queries, indexed
Customer.objects.prefetch_related('orders').filter(active=True)
# m2m — two queries
Article.objects.prefetch_related('tags').all()The mental model: forward = JOIN; reverse/m2m = second query.
Annotate over loop, every time#
The single biggest win in 90% of Django performance work: replace a Python loop with an SQL annotation.
Bad:
authors = Author.objects.all()
for author in authors:
author.book_count = author.books.count() # N+1!Good:
from django.db.models import Count
authors = Author.objects.annotate(book_count=Count('books'))This isn't just about query count — it's about moving aggregation to the database where it belongs. Postgres is dramatically better at COUNT, SUM, AVG than your Python process is, and you free your worker to handle the next request.
The pattern extends to almost any per-row computation: Sum, Avg, Max, Min, `Subquery\