Django ORM Performance

Django ORM Performance:
5 N+1 Queries Hiding in Your Codebase

The N+1 query problem doesn't always look like a loop. It hides in list comprehensions, templates, aggregations, and serializers — code that reads clean, passes review, and hammers your database once per row. Here are the five patterns that slip through every Django team.

Pattern #01

select_related vs prefetch_related Confusion

This is the foundational Django ORM performance mistake. Both fix N+1 queries — but they generate completely different SQL, and using the wrong one can actually make things worse.

select_related uses a SQL JOIN and works for single-valued relationships (ForeignKey, OneToOne). One query total. prefetch_related runs a separate query per relationship and does the joining in Python — use it for ManyToMany or reverse ForeignKey.

✗ N+1 — no prefetch at all
def get_posts(request):
      # 1 query for posts
      posts = Post.objects.all()[:50]
      return render(request, 'posts.html', {
          'posts': posts
      })

  # In the template:
  # {% for post in posts %}
  #   {{ post.author.name }}  <-- query per post
  # {% endfor %}
✓ 1 query with JOIN
def get_posts(request):
      # 1 query, author data joined
      posts = Post.objects.select_related(
          'author'
      )[:50]
      return render(request, 'posts.html', {
          'posts': posts
      })

  # Template accesses post.author.name
  # from the already-fetched JOIN result
When to use which

Use select_related for ForeignKey/OneToOne (single object per row). Use prefetch_related for ManyToMany or reverse FK (multiple objects per row). Using select_related on a ManyToMany raises an error — using prefetch_related on a ForeignKey works but generates 2 queries instead of 1. Get the choice wrong and you're either broken or slower than you need to be.

✗ Wrong tool for ManyToMany
def get_articles(request):
      # select_related on M2M = ValueError
      articles = Article.objects.select_related(
          'tags'  # raises exception
      )
      return render(request, 'articles.html', {
          'articles': articles
      })
✓ prefetch_related for M2M
def get_articles(request):
      # 2 queries: articles + tags
      # Python-side join
      articles = Article.objects.prefetch_related(
          'tags'
      )
      return render(request, 'articles.html', {
          'articles': articles
      })
Why reviewers miss it

The difference isn't obvious from the queryset call alone. You need to know the model's relationship type. In a code review, reviewers see .prefetch_related('author') on a ForeignKey and think "that's fine, it prefetches" — not "that generates an extra query when select_related would be free." The SQL impact is invisible without running django-debug-toolbar or logging queries.


Pattern #02

List Comprehension N+1

This is the N+1 in disguise. A list comprehension looks like a single data transformation — but if it accesses a related field on each object, it fires a query per iteration. The comprehension syntax hides the loop.

✗ N+1 — hidden in comprehension
def get_team_stats(org_id):
      repos = Repository.objects.filter(
          org_id=org_id
      )
      # This fires 1 query per repo
      # to fetch repo.owner
      return [
          {
              'name': repo.name,
              'owner': repo.owner.username,
              'stars': repo.star_count,
          }
          for repo in repos
      ]
✓ 1 query with select_related
def get_team_stats(org_id):
      repos = Repository.objects.filter(
          org_id=org_id
      ).select_related('owner')
      # owner data already loaded via JOIN
      return [
          {
              'name': repo.name,
              'owner': repo.owner.username,
              'stars': repo.star_count,
          }
          for repo in repos
      ]

The SQL tells the story clearly. For 100 repos without select_related:

-- What Django actually executes (101 queries):
  SELECT * FROM repositories WHERE org_id = 42;
  SELECT * FROM users WHERE id = 1;   -- repo 1's owner
  SELECT * FROM users WHERE id = 7;   -- repo 2's owner
  SELECT * FROM users WHERE id = 3;   -- repo 3's owner
  -- ... 97 more identical queries ...
Why reviewers miss it

The list comprehension reads as a pure transformation — it doesn't look like a database loop. Reviewers who know to look for for obj in queryset: obj.related often don't apply the same scrutiny to comprehensions. Django's lazy loading is the real culprit: related fields resolve on first access, silently, wherever that access happens.


Pattern #03

Template-Level N+1 Queries

The most invisible Django N+1. The view looks clean — no loops, no related access. The query storm is in the template, where Django's lazy loading fires silently on every {{ post.author.name }} in a for loop.

✗ View looks fine — template kills you
# views.py — looks clean
  def post_list(request):
      posts = Post.objects.filter(
          published=True
      ).order_by('-created_at')[:20]
      return render(request, 'posts.html', {
          'posts': posts
      })

  # posts.html — the real problem:
  # {% for post in posts %}
  #   <h2>{{ post.title }}</h2>
  #   <p>by {{ post.author.name }}</p>
  #   <p>in {{ post.category.name }}</p>
  # {% endfor %}
✓ Pre-fetch in the view
# views.py — fix at the queryset level
  def post_list(request):
      posts = Post.objects.filter(
          published=True
      ).select_related(
          'author',
          'category'
      ).order_by('-created_at')[:20]
      return render(request, 'posts.html', {
          'posts': posts
      })

  # Template unchanged — but now
  # author + category data is already loaded
  # No queries fire in the template loop
Why reviewers miss it

The view file and template file are reviewed separately. A reviewer looking at views.py sees a queryset returning posts — nothing suspicious. The N+1 is in posts.html, which may not even be in the same PR. Template loops accessing related fields are standard Django idiom — they don't look like bugs. You need to cross-reference view + template to spot it, which reviewers rarely do under time pressure.

This also applies to custom template tags and inclusion_tag functions. If a template tag accesses related fields on objects passed in from the parent context, it triggers the same lazy-load chain — and it's even harder to spot because the access is buried inside a Python function called from template syntax.

Get the Python Code Review Cheatsheet (PDF)

12 patterns that break in production — with the exact fixes. Free.


Pattern #04

Aggregation N+1: Python sum() Instead of .aggregate()

When you need a count or sum across related objects, the instinct is to fetch the related objects and aggregate in Python. This loads N rows from the database to compute a number that the database could return in a single query.

✗ Python aggregation = N+1
def get_org_summary(org_id): teams = Team.objects.filter(org_id=org_id) summary = [] for team in teams: # Fetches ALL members just to count them member_count = len(team.members.all()) # Fetches ALL reviews to sum scores total_score = sum( r.score for r in team.reviews.all() ) summary.append({ 'team': team.name, 'members': member_count, 'score': total_score, }) return summary
✓ DB aggregation — 1 query
from django.db.models import Count, Sum

  def get_org_summary(org_id):
      # Single annotated query
      teams = Team.objects.filter(
          org_id=org_id
      ).annotate(
          member_count=Count('members'),
          total_score=Sum('reviews__score')
      ).values(
          'name', 'member_count', 'total_score'
      )
      return [
          {
              'team': t['name'],
              'members': t['member_count'],
              'score': t['total_score'] or 0,
          }
          for t in teams
      ]
Why reviewers miss it

The bad version is natural Python. len(queryset.all()) and a generator sum are idiomatic Python code — they just happen to be catastrophically wrong for database access. Reviewers see Python, not SQL. For a team of 50 members with 200 reviews, the bad version fires 101 queries; the good version fires 1. The performance difference is invisible in tests with fixtures that have 3 members.

The same pattern applies to .count() vs len(). Post.objects.filter(...).count() runs a SELECT COUNT(*). len(Post.objects.filter(...)) fetches every row and counts in Python. One number, wildly different costs at scale.


Pattern #05

DRF Serializer N+1 on Related Fields

Django REST Framework serializers are N+1 machines by default. Every SerializerMethodField or nested serializer that accesses a related object triggers a lazy load per row — and because it's inside a serializer class, it doesn't look like a loop at all.

✗ Serializer fires N queries
class PostSerializer(serializers.ModelSerializer):
      author_name = serializers.SerializerMethodField()
      comment_count = serializers.SerializerMethodField()

      def get_author_name(self, obj):
          # Lazy loads author per post
          return obj.author.full_name

      def get_comment_count(self, obj):
          # Fetches ALL comments just to count
          return obj.comments.count()

      class Meta:
          model = Post
          fields = ['id', 'title',
                    'author_name', 'comment_count']

  # View — N queries hidden in serializer:
  class PostListView(generics.ListAPIView):
      queryset = Post.objects.all()
      serializer_class = PostSerializer
✓ Pre-fetch + annotate in view
from django.db.models import Count

  class PostSerializer(serializers.ModelSerializer):
      author_name = serializers.SerializerMethodField()
      comment_count = serializers.IntegerField(
          source='comment_count'  # from annotation
      )

      def get_author_name(self, obj):
          # Already loaded via select_related
          return obj.author.full_name

      class Meta:
          model = Post
          fields = ['id', 'title',
                    'author_name', 'comment_count']

  # View does the heavy lifting:
  class PostListView(generics.ListAPIView):
      serializer_class = PostSerializer

      def get_queryset(self):
          return Post.objects.select_related(
              'author'
          ).annotate(
              comment_count=Count('comments')
          )
Why reviewers miss it

The serializer is reviewed as a data-shaping class, not as a query executor. get_author_name looks like it's just formatting data — the database call is two hops away (obj → lazy load → .full_name). DRF's design lets you write what looks like clean object access that's secretly firing a query per row. The fix lives in the view's get_queryset, not the serializer — so the bad pattern can survive even a thorough serializer review.

The DRF docs recommend the select_related/prefetch_related fix, but it requires knowing the serializer's field access pattern when writing the view. Third-party packages like django-debug-toolbar and nplusone can catch these at runtime in development — but they don't stop the pattern from shipping to production if tests never exercise the real database.


CodeSight catches these automatically — on every PR.

ORM traversal detection, aggregation shortcuts, DRF serializer analysis. Every Django PR reviewed in 30 seconds, before it merges.

Install Free on GitHub 5 PRs/month free  ·  No credit card  ·  Uninstall in one click

Enjoyed this? Get weekly Python code review tips.

No spam. Unsubscribe any time.