An API endpoint that should fetch one user takes 500 milliseconds. When you look at your distributed traces, you see a single database query at the top level — but then dozens, hundreds, or even thousands of subsequent queries nested inside it. You've just encountered the n+1 query problem, a silent performance killer that turns fast queries into bottlenecks without ever breaking your code.
The n+1 query problem happens so quietly that many teams don't notice until they're already at scale. A request that felt instant with 100 users in your beta starts timing out at 10,000 active users. The fix is straightforward once you see it, but spotting the pattern requires understanding your traces and knowing exactly where to look in your code.
What is the n+1 query problem?
The n+1 query problem describes a scenario where fetching N items results in N+1 database queries: one query to fetch the initial list of N items, then one additional query for each item to fetch related data. Instead of 2 queries (one for the items, one for all related data in bulk), you end up with 1+N queries.
Here's a concrete example. You're building a blog API. A request comes in for the recent posts:
// Fetches all recent posts (1 query)
const posts = await db.query("SELECT * FROM posts ORDER BY created_at DESC LIMIT 10");
// Then for each post, fetch the author (10 more queries!)
const enriched = posts.map(post => {
const author = await db.query("SELECT * FROM users WHERE id = ?", post.author_id);
return { ...post, author };
});
That's 1 + 10 = 11 queries for what should have been 1 or 2. At scale, if each query takes 10ms and you have 100 concurrent requests, that's 110 queries per request × 100 requests = 11,000 queries per second — your database can't keep up, connection pools exhaust, and everything grinds to a halt.
The problem is rarely this obvious. It usually hides inside an ORM loop or an API that layers lookups, and you don't see it until you read your trace waterfalls in production.
How the n+1 query problem creeps into your code
The n+1 problem is most common in ORMs like SQLAlchemy, TypeORM, and Hibernate, where lazy loading is the default. The ORM fetches the parent objects, then loads related objects one at a time as you access them.
# SQLAlchemy: lazy loading by default
posts = db.query(Post).order_by(Post.created_at.desc()).limit(10).all()
for post in posts:
print(post.author.name) # Triggers a query for each post!
The code looks innocent. You're just accessing .author.name, but under the hood, SQLAlchemy executes one query per loop iteration. A pagination endpoint with 50 items just became 51 database calls.
The same pattern appears in REST APIs that chain lookups:
// Endpoint 1: fetch users
const users = await fetch("/api/users?page=1");
const userIds = users.map(u => u.id);
// Endpoint 2: fetch metadata for each user
const metadata = await Promise.all(
userIds.map(id => fetch(`/api/users/${id}/metadata`))
);
Each /api/users/:id/metadata call might hit your database. Five users means five API calls to a downstream service, each hitting a database query.
Lazy loading in ORMs is a convenience feature, not a free lunch. Turn it off by default and use explicit eager loading instead. Many teams accidentally turn on lazy loading in production without realizing the cost.
Detecting n+1 queries with distributed traces
The easiest way to spot the n+1 problem is to look at your distributed tracing data. A trace that shows one logical operation but contains dozens of database queries is your smoking gun. When you're looking for slow database queries, this pattern jumps out immediately.
When you instrument your database client with tracing spans, each query becomes visible as a span in the waterfall. An n+1 problem looks like a fan of database queries all firing sequentially or in parallel, all triggered from the same endpoint.
GET /api/posts
├─ SELECT * FROM posts (10ms)
├─ SELECT * FROM users WHERE id = 1 (8ms)
├─ SELECT * FROM users WHERE id = 2 (7ms)
├─ SELECT * FROM users WHERE id = 3 (9ms)
├─ SELECT * FROM users WHERE id = 4 (8ms)
... (repeat 6 more times)
└─ Total: 95ms (should be 15ms!)
The pattern is unmistakable: one or two initial queries at the top, then a parade of nearly-identical queries afterward. When you read your waterfall, you're looking for unexpected repetition.
Setting up tracing is straightforward. Instrument your database client, set up span propagation in your HTTP middleware, and every slow endpoint will show you exactly where the time goes.
The performance and reliability cost
An n+1 query problem doesn't just slow your app — it makes it unpredictable. A request that hits 5 items completes in 100ms. A request that hits 50 items takes 1 second. The same endpoint has wildly different latency depending on what data it fetches, and your database becomes a bottleneck just when load increases.
Beyond latency, there's a cascading failure risk. If your database connection pool is exhausted by n+1 queries, every request starves. The slow endpoint takes all your connections, and fast endpoints time out waiting for a connection that never comes. This is where understanding throughput vs latency becomes critical — your throughput collapses when queries multiply.
The p95 and p99 latencies balloon fastest. A 90th percentile request might finish in 200ms, but the 99th percentile hits a second or more because it landed on the API call that fetches the most related data. Meanwhile, your mean API latency creeps up week by week as data grows.
Fixing the n+1 query problem: eager loading
The fix is to fetch all the related data you need in a single query, not one query per item. ORMs call this "eager loading" or "joining."
In SQLAlchemy:
# Instead of lazy loading
posts = db.query(Post).limit(10).all()
for post in posts:
print(post.author.name) # n queries!
# Use eager loading
from sqlalchemy.orm import joinedload
posts = (
db.query(Post)
.options(joinedload(Post.author))
.limit(10)
.all()
)
for post in posts:
print(post.author.name) # 1 query!
In TypeORM:
// Lazy loading: n+1
const posts = await postRepo.find();
// Eager loading: single query with join
const posts = await postRepo.find({
relations: ["author"],
});
In Sequelize:
// Eager loading with include
const posts = await Post.findAll({
include: [{ association: "author" }],
limit: 10,
});
The key principle: decide before you query what related data you need, and fetch it all in one go. Don't loop after fetching; join before.
Batching when joins aren't enough
Not every scenario allows a join. Cross-service calls can't be joined — if you call a downstream microservice to fetch user metadata, you need a different strategy.
Use batching instead. Collect all the IDs you need, make one request for all of them at once, then match results back to your objects:
// Inefficient: n requests
const users = await usersPromise;
const metadata = await Promise.all(
users.map(u => fetch(`/api/users/${u.id}/metadata`))
);
// Efficient: 1 request (or a few batched requests)
const users = await usersPromise;
const userIds = users.map(u => u.id);
const metadataMap = await fetch(
`/api/users/metadata?ids=${userIds.join(",")}`
).then(res => res.json()).then(list => new Map(list.map(m => [m.userId, m])));
Some teams use a data loader pattern (popularized by DataLoader for GraphQL) that collects requests across a batch window and deduplicates them automatically.
Cache aggressively. If you're fetching the same related data repeatedly, a fast in-memory cache (or Redis for distributed caches) can eliminate queries entirely. Just watch your cache invalidation strategy.
Monitoring and testing for regressions
The n+1 problem often sneaks back in when you refactor. A developer adds a new field to a response, forgets to add it to the join, and suddenly your queries double.
Catch regressions by instrumenting your tests:
// Pseudo-code for tracking query count
test("GET /api/posts should execute <= 2 queries", async () => {
const queryCount = captureQueryCount();
await request(app).get("/api/posts");
expect(queryCount).toBeLessThanOrEqual(2);
});
In production, monitor your database query volume relative to API requests. A ratio that creeps up is a sign that queries are multiplying. Use your tracing data to alert when an endpoint's query count spikes, or when a specific query appears in a trace more times than expected.
An alert rule like "Alert if any endpoint executes > 10 database queries per request" or "Alert if query count for this endpoint increases 50% week-over-week" catches n+1 problems before users do.
Framework-specific patterns
Most popular ORMs have built-in eager-loading syntax that becomes natural with practice. The pattern is always the same: declare your relationships upfront, let the ORM build the query, execute once.
The hardest cases are layered APIs where you don't control all the code. A backend calls a CMS API, which calls your database, and by the time you see the trace, it's already too late. This is where end-to-end distributed tracing pays off — you see the entire chain of calls and can push responsibility back to the right team.
Start tracking errors in minutes
Watch your traces and catch n+1 queries in seconds with LightTrace — distributed tracing that surfaces query patterns before they tank your database.
The n+1 query problem is a performance tax you pay for convenience if you're not careful. But it's also predictable, easy to test for, and fast to fix once you see it. Add tracing to your database, look for the fan patterns in your waterfalls, and you'll catch it before scale becomes a crisis.