The N+1 Problem: How It Happens and How to Fix It


The N+1 problem is one of those bugs that’s invisible until it isn’t. An endpoint that loads a list of posts with their authors works fine in development with 10 posts. In production with 10,000 posts, the same endpoint makes 10,001 database queries and takes 30 seconds.

The extra 10,000 queries aren’t a bug in the traditional sense - the code is doing exactly what it was written to do. The problem is that it was written naively, without thinking about what happens at scale.

How It Happens

Start with this Django model:

class Author(models.Model):
    name = models.CharField(max_length=200)
    email = models.CharField(max_length=200)

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    body = models.TextField()

And this view:

def post_list(request):
    posts = Post.objects.all()  # 1 query: SELECT * FROM posts
    result = []
    for post in posts:
        result.append({
            'title': post.title,
            'author': post.author.name  # 1 query per post: SELECT * FROM authors WHERE id=?
        })
    return JsonResponse(result, safe=False)

This is the N+1 pattern. One query fetches all posts. Then for each of N posts, another query fetches the author. Total: N+1 queries.

The ORM makes this easy to write accidentally because post.author looks like a simple attribute access. Underneath, it’s a database query executed lazily - on first access. There’s no visible query() call to warn you.

Detecting It

The simplest detection method: log all database queries in development. Every major ORM has this built in.

Django:

# settings.py
LOGGING = {
    'loggers': {
        'django.db.backends': {
            'handlers': ['console'],
            'level': 'DEBUG',
        }
    }
}

SQLAlchemy:

import logging
logging.basicConfig()
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)

When you see the same query executed 200 times with different IDs in your log output, you’ve found an N+1.

Tools like Django Debug Toolbar (for web requests), nplusone (Python), or Hibernate’s statistics (Java) can detect N+1 automatically and surface them during development.

Fix 1: Eager Loading

The standard fix is to tell the ORM to load related objects upfront, in the same query or with a single additional query.

Django - select_related (for ForeignKey/OneToOne - uses SQL JOIN):

# Before: N+1
posts = Post.objects.all()

# After: 1 query with JOIN
posts = Post.objects.select_related('author').all()

# Now post.author.name does not trigger a query

Django - prefetch_related (for ManyToMany and reverse ForeignKey - uses a separate IN query):

# Load posts with all their tags
posts = Post.objects.prefetch_related('tags').all()
# 2 queries total: 1 for posts, 1 for all tags

SQLAlchemy:

from sqlalchemy.orm import selectinload

posts = session.query(Post).options(selectinload(Post.author)).all()

ActiveRecord (Rails):

# Before: N+1
posts = Post.all

# After: eager load
posts = Post.includes(:author).all

The select_related / JOIN approach is best for single relationships where you want one combined query. The prefetch_related / selectinload approach is better for collections (tags, comments) where a JOIN would produce duplicate rows.

Fix 2: Batch Loading (DataLoader Pattern)

For GraphQL APIs and other cases where you can’t control the query structure upfront, eager loading doesn’t work cleanly. The DataLoader pattern collects all IDs requested during a single operation and fetches them in one batch.

from promise import Promise
from promise.dataloader import DataLoader

class AuthorLoader(DataLoader):
    def batch_load_fn(self, author_ids):
        authors = Author.objects.filter(id__in=author_ids)
        author_map = {a.id: a for a in authors}
        return Promise.resolve([author_map.get(id) for id in author_ids])

author_loader = AuthorLoader()

# Each of these defers to the batch
def resolve_author(post, info):
    return author_loader.load(post.author_id)

# At the end of the request, one query:
# SELECT * FROM authors WHERE id IN (1, 2, 3, ...)

DataLoader batches requests that arrive in the same tick of the event loop, then resolves all of them with a single query. The graphql-dataloader library (JavaScript) and strawberry-graphql’s DataLoader support (Python) implement this.

Fix 3: Write the Query Yourself

Sometimes the ORM’s eager loading doesn’t produce the query you need. Writing SQL (or a more explicit ORM query) gives you full control:

# Fetch posts with author name using a JOIN - no additional queries
from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("""
        SELECT p.id, p.title, a.name as author_name
        FROM posts p
        INNER JOIN authors a ON a.id = p.author_id
        ORDER BY p.created_at DESC
        LIMIT 100
    """)
    rows = cursor.fetchall()

Or using Django’s values() to avoid loading full model instances when you only need specific fields:

posts = Post.objects.select_related('author').values(
    'id', 'title', 'author__name'
)
# One query, returns dicts instead of model instances

The Deeper Pattern

N+1 is a symptom of a mismatch between how you’re loading data and how you’re using it. Loading a list, then loading related objects one at a time, is the wrong order of operations.

The fix is always to collect what you need, fetch it all at once, then present it. Whether that’s select_related, prefetch_related, a DataLoader, or a raw JOIN, the principle is the same: minimize the number of round trips to the database.

A useful habit: after writing any code that loads a list and iterates over it, ask “does this loop trigger any database calls?” If yes, you’ve written an N+1.



Read more