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.
