Introduction
Django ORM lets you work with the database without writing SQL, but misuse can generate hundreds of unnecessary queries per request. QuerySets are lazy: no SQL runs until evaluation. That delay is powerful but accessing related objects in a loop causes the N+1 problem.
This guide covers QuerySet optimization techniques, profiling with Django Debug Toolbar and connection.queries, and safe pagination strategies on large tables.
QuerySet Basics and Lazy Evaluation
Post.objects.filter(status='published') does not produce SQL yet. list(), len(), iteration, or slicing triggers the query. queryset.count() runs a separate COUNT query; prefer count() over len(queryset).
- all(), filter(), exclude(), order_by() return new QuerySets
- values() and values_list() return dicts/tuples
- exists() and count() are optimized queries
qs = Post.objects.filter(status='published') # no SQL
for post in qs:
print(post.title) # single SELECT
# Bad: separate query per post
for post in Post.objects.all():
print(post.author.username) # N+1!The N+1 Problem and Detection
N+1 means one query fetches main rows and N queries fetch related data per row. Listing 100 posts and showing author names can cause 101 queries. Monitor connection.queries length with django-debug-toolbar or logging.
# Bad
posts = Post.objects.all()
for p in posts:
print(p.author.email) # SELECT each iteration
# Good
posts = Post.objects.select_related('author')
for p in posts:
print(p.author.email) # single JOINIf your template uses {{ post.author.name }}, plan select_related in the view.
select_related: ForeignKey and OneToOne
select_related uses SQL JOIN; it fetches ForeignKey and OneToOne relations in one query. Chain relations with double underscores: select_related('author__profile').
Post.objects.select_related(
'author',
'category',
).filter(status='published')
# In CBV
class PostDetailView(DetailView):
queryset = Post.objects.select_related('author', 'category')prefetch_related: ManyToMany and Reverse FK
prefetch_related runs separate queries and joins in Python memory. Use for ManyToMany and reverse ForeignKey. Prefetch() lets you filter the sub-QuerySet.
- select_related: JOIN, single query
- prefetch_related: separate queries + in-memory join
- Both can be used together
from django.db.models import Prefetch
Post.objects.prefetch_related(
'tags',
Prefetch(
'comments',
queryset=Comment.objects.filter(is_approved=True),
),
)only, defer, and annotate
only() and defer() load or exclude specific fields; useful for large TEXT/BLOB columns. annotate() and aggregate() compute at SQL level, avoiding Python loops.
from django.db.models import Count, Avg
Post.objects.annotate(
comment_count=Count('comments'),
avg_rating=Avg('comments__rating'),
).filter(comment_count__gte=5)
Post.objects.only('id', 'title', 'slug').defer('body')Profiling and Production Tips
Use django-debug-toolbar SQL panel in development. Monitor slow endpoints in production with Sentry or APM. iterator(chunk_size=2000) reduces memory for large exports.
- Preload required relations in the view
- Use pagination with LIMIT/OFFSET
- Define indexes in migrations
- Use raw SQL only on measured bottlenecks
for row in Post.objects.iterator(chunk_size=500):
export_row(row)Conclusion
ORM convenience does not require a performance penalty; the right QuerySet chain keeps code readable and fast. Do not optimize without measuring; sometimes cache beats an extra JOIN.
- Choose select_related/prefetch_related based on template needs
- exists() is faster than count() for empty checks
- Use bulk_create and bulk_update for batch operations