Deep dive into Django ORM performance. Learn how to identify and fix N+1 queries, use select_related, prefetch_related, only(), defer(), and Django Debug Toolbar to measure the real impact.
Django's ORM is powerful, but its convenience can hide serious performance bombs. One of the biggest is the N+1 query problem, and it's likely lurking in your app right now.
Given this simple code:
posts = Post.objects.all()
for post in posts:
print(post.author.name)
If you have 100 posts, Django runs 101 queries — one to fetch posts, then one per post to fetch its author. With 10,000 posts, this pattern grinds your database.
Install Django Debug Toolbar:
pip install django-debug-toolbar
Add to settings:
INSTALLED_APPS = [..., 'debug_toolbar']
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware', ...]
INTERNAL_IPS = ['127.0.0.1']
Now every page shows the SQL panel with exact query counts. If you see "similar queries: 50", you have N+1.
Use select_related for forward ForeignKey and OneToOne relationships. It adds a SQL JOIN:
# Bad: 1 + N queries
posts = Post.objects.all()
# Good: 1 query with JOIN
posts = Post.objects.select_related('author', 'category')
You can chain: select_related('author__profile', 'category').
For reverse ForeignKey or ManyToMany, use prefetch_related. It runs a second query and joins in Python:
# Each post has many tags
posts = Post.objects.prefetch_related('tags', 'comments__author')
When you need filtered prefetches:
from django.db.models import Prefetch
active_comments = Comment.objects.filter(is_active=True)
posts = Post.objects.prefetch_related(
Prefetch('comments', queryset=active_comments, to_attr='active_comments')
)
for post in posts:
for comment in post.active_comments: # no extra query
print(comment.body)
Load only the columns you need:
# Load only title and slug
Post.objects.only('title', 'slug')
# Load everything EXCEPT large text fields
Post.objects.defer('content', 'html_body')
Be careful — accessing a deferred field triggers an extra query.
When you don't need model instances:
# Returns dicts — no Python model instance overhead
Post.objects.filter(published=True).values('id', 'title', 'author__name')
# Returns a flat list
Post.objects.values_list('id', flat=True)
Push work to the database:
from django.db.models import Count, Avg, Sum
# Bad: fetches all comments then counts in Python
authors = Author.objects.all()
for a in authors:
print(a.name, a.comment_set.count()) # N+1!
# Good: single query with COUNT
authors = Author.objects.annotate(comment_count=Count('comment'))
for a in authors:
print(a.name, a.comment_count)
QuerySets are lazy and cached — but subtle rules apply:
qs = Post.objects.filter(published=True)
# Evaluates + caches
list(qs)
# Slicing before evaluation returns a new uncached QuerySet
first_five = qs[:5]
Use exists() instead of if queryset: — it runs a more efficient COUNT query.
Avoid the loop-and-save anti-pattern:
# Bad: N queries, N transactions
for data in source:
Product.objects.create(**data)
# Good: 1 query
Product.objects.bulk_create([Product(**d) for d in source], batch_size=1000)
# Bulk update
Product.objects.bulk_update(products, ['price', 'stock'])
For complex analytics, raw SQL is sometimes clearer:
from django.db import connection
with connection.cursor() as cursor:
cursor.execute('''
SELECT author_id, COUNT(*), AVG(word_count)
FROM blog_post
WHERE published_at > %s
GROUP BY author_id
''', [last_month])
results = cursor.fetchall()
Use connection.queries to count queries in tests:
from django.db import connection, reset_queries
from django.test.utils import override_settings
@override_settings(DEBUG=True)
def test_post_list_queries():
reset_queries()
list(Post.objects.select_related('author').all())
assert len(connection.queries) == 1
Or use assertNumQueries:
with self.assertNumQueries(2):
response = self.client.get('/posts/')
Log slow queries with a middleware or use tools like:
select_related for ForeignKey/OneToOneprefetch_related for reverse/M2Monly()/defer() to reduce column loadsvalues() when you don't need model instancesannotate() with aggregates to avoid N+1bulk_create/bulk_update for batch opsassertNumQueriesThe ORM is fast when used correctly. Know your tools, measure your queries, and keep your request times under 100ms.