The N+1 Query Problem and How to Fix It
A page that feels sluggish for no obvious reason is, more often than not, quietly running a few hundred database queries instead of two. The N+1 problem is the single most common cause, and it hides well because each individual query is fast.
Published July 6, 2026The pattern shows up anywhere an ORM's convenience for accessing related objects meets a loop. Say you're rendering a list of blog posts along with each post's author name:
# Django models
class Author(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
posts = Post.objects.all() # 1 query: fetch all posts
for post in posts:
print(post.title, post.author.name) # +1 query per post, on first access
The first line runs one query and gets 50 posts. But post.author is a lazy relationship — the ORM doesn't fetch the author when it loads the post, it fetches the author the first time your code actually accesses post.author. So the loop above runs one query to get the posts, then one more query per post to get that post's author: 1 + 50 = 51 queries to render a page that could have been answered by a single JOIN. That's the "N+1" in the name — N related-object lookups on top of the 1 original query.
Why it's easy to miss in development
With 5 test posts in a local database, the difference between 1 query and 6 queries is invisible — both take a few milliseconds. The problem only becomes obvious in production with a realistic amount of data, where 51 queries at 2ms of network round-trip each is over 100ms of pure query overhead added to a page that should have taken 5ms. It scales linearly with list size, so a page showing 500 items instead of 50 doesn't get 10x slower gracefully — it can tip a request from "fine" to "timing out" once a table grows past whatever size it was originally tested with.
Spotting it
The clearest signal is a query log (or an APM tool's per-request query count) showing dozens or hundreds of near-identical queries that differ only by an ID, right after a query that returned a list. Most ORMs also have a "strict mode" or logging flag for exactly this — Django's django-debug-toolbar shows a query count per request in development, and a sudden jump from a handful of queries to hundreds after a seemingly small template change is almost always this pattern.
The fix: eager loading
Instead of letting each related object load lazily on first access, you tell the ORM up front which relationships you'll need, so it fetches them in one additional query (or one JOIN) rather than one query per row. In Django, that's select_related for foreign-key/one-to-one relationships (implemented as a SQL JOIN) and prefetch_related for many-to-many or reverse foreign-key relationships (implemented as a second, separate query with an IN clause):
posts = Post.objects.select_related('author').all() # 1 query, JOINed
for post in posts:
print(post.title, post.author.name) # no extra query, already loaded
That drops the page from 51 queries to 1. The exact semantics and when to use each method are covered in Django's QuerySet API reference. Every major ORM has an equivalent: SQLAlchemy has joinedload and selectinload, Rails' ActiveRecord has includes, Laravel's Eloquent has with. The concept transfers directly even though the method name changes.
Eager loading isn't free either
Blanket eager-loading every relationship on every query trades one problem for another: fetching data you don't actually use on a given page wastes bandwidth and memory, and a JOIN across a one-to-many relationship can multiply row counts if you're not careful (fetching a post with 200 comments via a JOIN returns 200 rows for that one post, not 1). The fix isn't "always eager load," it's "know what a given code path actually needs and load exactly that" — which is really just applying the same discipline as any other performance work: measure with a realistic query count, then load precisely what the page renders, no more and no less.