The page takes two seconds. Every individual query is fast (1–2 ms each), and the database looks idle. Then you turn on query logging and count: one page view, 301 queries.
That's the N+1 query problem, and it's probably the most common performance bug in ORM-based applications.
What it looks like
# Django
posts = Post.objects.order_by("-published_at")[:100] # 1 query
for post in posts:
print(post.author.name) # +1 query per post
print(post.tags.count()) # +1 more per post
The ORM loads related objects lazily: post.author isn't fetched until you touch it, and then it's fetched for that one post. 100 posts mean 1 + 100 + 100 queries. Each is cheap, but every one pays a network round trip, parsing, planning and ORM hydration. With 1 ms of latency that's 200 ms of waiting before any real work, and the cost grows with the page size.
The same pattern shows up everywhere:
@posts.each { |p| p.author.name } # Rails
posts.forEach(p -> p.getAuthor().getName()); // Hibernate/JPA with LAZY
It also hides inside templates, serializers and GraphQL resolvers, where nobody reading the controller sees a loop.
Detect it
-
Count queries per request. Most frameworks have a tool for this: Django Debug Toolbar or
nplusone, Rails'bulletgem orstrict_loading, Hibernate statistics (hibernate.generate_statistics), Laravel Debugbar, or Prisma's query event logging. -
Look for the same statement repeated with different parameters in your logs or APM traces.
-
On the database side, the pattern is a query with a huge
callscount and a tiny mean time. In PostgreSQL:SELECT calls, round(mean_exec_time::numeric, 2) AS ms, left(query, 80) FROM pg_stat_statements ORDER BY calls DESC LIMIT 10;
Fix it: load related data up front
Every ORM can load associations eagerly. There are two strategies.
JOIN the relation in the same query. This works best for to-one relations (post → author):
Post.objects.select_related("author") # Django
Post.includes(:author).references(:author) # Rails (forces JOIN)
@Query("select p from Post p join fetch p.author") // JPA
Run a second query with IN (...). This works best for to-many relations (post → tags):
Post.objects.prefetch_related("tags") # Django
select(Post).options(selectinload(Post.tags)) # SQLAlchemy
prisma.post.findMany({ include: { tags: true } }) // Prisma
This produces exactly two queries whether there are 10 posts or 10,000:
SELECT * FROM post ORDER BY published_at DESC LIMIT 100;
SELECT * FROM post_tag JOIN tag ... WHERE post_id IN (1, 2, 3, ..., 100);
Don't overcorrect: the cartesian explosion
JOIN-fetching several to-many collections at once multiplies rows. 100 posts × 10 tags × 50 comments is 50,000 rows to return 100 posts. Hibernate refuses to do this with some collection types (MultipleBagFetchException) for exactly this reason. Use one JOIN for to-one relations and separate IN queries for each collection, or Hibernate's @BatchSize / default_batch_fetch_size.
Aggregates: let the database count
post.tags.count() in a loop is another N+1. Ask for the counts in the main query:
Post.objects.annotate(tag_count=Count("tags"))
SELECT p.id, p.title, count(pt.tag_id) AS tag_count
FROM post p
LEFT JOIN post_tag pt ON pt.post_id = p.id
GROUP BY p.id, p.title;
Make it hard to reintroduce
- Turn on strict loading in development and CI so lazy loads raise an error (Rails
strict_loading_by_default, DjangonplusonewithNPLUSONE_RAISE, Hibernate with lazy loading outside a session disabled). - Add a test that asserts a query count for hot endpoints (
assertNumQueriesin Django). - In GraphQL, batch resolvers with DataLoader. N+1 is the default behavior there.
Checklist
- Count queries per request. Dozens of near-identical queries means N+1.
- Use JOIN-fetching for to-one relations and
IN-prefetching for to-many. - Push counts and sums into the main query.
- Enforce it with strict loading and query-count tests.
Get the weekly commit
New database deep dives every week.
