The N+1 query problem is one of the most common performance issues in database-backed applications. It is subtle because the code looks completely reasonable and the problem only becomes visible under load or with large datasets.

The pattern

Suppose you are building a blog. You want to display a list of posts with the author’s name next to each one. In an ORM like ActiveRecord, Prisma, or SQLAlchemy, the code might look like this:

posts = Post.objects.all()

for post in posts:
    print(post.title, post.author.name)

This looks like a single loop over posts. What actually happens is one query to fetch all posts, then one additional query per post to fetch that post’s author. If there are 100 posts, this runs 101 queries: 1 + 100. Hence N+1.

The ORM hides the database calls behind Python attribute access. post.author triggers a lazy load - a database round trip happens at that . operator. The developer never wrote a SQL query explicitly, so there is no obvious moment where they could have said “this seems like a lot of queries.”

Detecting it

The fastest way to detect N+1 is to log all queries during development. Most frameworks have a way to do this.

In Django:

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

With this enabled, a list page that shows 50 items with 51 queries in the log is an immediate signal.

Tools like Django Debug Toolbar, Rails Bullet gem, or the explain() wrapper in Prisma will surface these automatically and flag them as warnings. Adding one of these to development environments catches N+1 issues before they ship.

The fix: eager loading

The solution is to load related data in the same query or in a second bulk query, rather than one query per row.

In Django, select_related and prefetch_related do this:

# select_related: JOIN for single-value foreign keys
posts = Post.objects.select_related('author').all()

# prefetch_related: separate IN query for many-to-many or reverse FK
posts = Post.objects.prefetch_related('tags').all()

With select_related('author'), Django emits a single SQL JOIN:

SELECT post.*, author.*
FROM post
INNER JOIN author ON post.author_id = author.id;

One query returns everything. The loop over posts accesses post.author.name without triggering any additional database calls because the data is already in memory.

In Prisma:

const posts = await prisma.post.findMany({
  include: {
    author: true,
    tags: true,
  },
});

In SQLAlchemy:

from sqlalchemy.orm import joinedload

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

The pattern is the same across ORMs: declare what related data you need at the point of the initial query, and the ORM figures out whether to JOIN or run a batched secondary query.

Nested N+1

The problem compounds when there are multiple levels of relations. Fetching posts with authors and each author’s organization creates a 3-level N+1 if not handled:

# Bad: N+1+N
for post in Post.objects.all():
    print(post.author.organization.name)

# Good: 1 query with two joins
for post in Post.objects.select_related('author__organization').all():
    print(post.author.organization.name)

Django’s double-underscore syntax traverses the relation chain. The generated SQL performs both joins in one query.

When lazy loading is acceptable

Not every lazy load is a problem. If you are loading a single record and accessing one related field, the cost is one extra query. That is often fine.

The problem only emerges when the lazy load is inside a loop iterating over a collection. The rule of thumb: if you are iterating over a queryset and accessing a related field, use eager loading. If you are fetching one object and accessing one relation, lazy loading is acceptable.

GraphQL and the same problem

GraphQL resolvers expose the exact same pattern. A resolver for post.author that runs a database query will run once per post in a list. The standard fix is the DataLoader pattern: batch all author lookups from a single request cycle into a single WHERE id IN (...) query.

const authorLoader = new DataLoader(async (authorIds) => {
  const authors = await db.author.findMany({
    where: { id: { in: authorIds } },
  });
  return authorIds.map((id) => authors.find((a) => a.id === id));
});

DataLoader collects all IDs requested within a single event loop tick and issues one batched query.

The N+1 problem is solved at the query construction layer. The fix is always the same: specify what you need upfront so the database can return it in bulk.