N+1 is the canonical ORM performance bug. Fetch a list of N parents. Then for each parent, fetch their children one query at a time. N+1 total queries. Each query is fast individually; the cumulative latency is the entire request budget.
I have caught this in code review hundreds of times. The fix is always the same shape; the discipline to avoid it is what most teams lack.
What it looks like
In most ORMs, the pattern is:
# Get 100 customers
customers = Customer.objects.all()[:100]
# Render each one with their orders
for customer in customers:
print(customer.name, len(customer.orders)) # triggers an N+1
The customer.orders access on each iteration triggers a separate SELECT * FROM orders WHERE customer_id = ? query. 100 customers → 1 + 100 = 101 queries.
In Postgres logs, this is 101 lines of identical query shape with different parameters. The application's request takes 100x longer than it should.
How to detect it
The simplest signal: query count per request.
- Log the number of database queries in each application request.
- Alert on requests that exceed a threshold (e.g., 50 queries per request).
Most ORMs have middleware or libraries that count queries per request. New Relic, Datadog, and similar APMs surface this automatically.
For a more direct database-side detection:
SELECT
query,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows / nullif(calls, 0) AS avg_rows_per_call
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
Queries with very high calls and low avg_rows_per_call are N+1 candidates. If a query is called 10,000 times per minute and returns 1 row each time, it is probably part of a loop.
The fix in three patterns
1. Eager loading (the ORM way).
Most ORMs have a way to declare "fetch parent and children together." The shape varies:
- Django:
Customer.objects.prefetch_related('orders') - ActiveRecord:
Customer.includes(:orders) - SQLAlchemy:
db.session.query(Customer).options(joinedload(Customer.orders)) - Sequelize:
Customer.findAll({ include: [Order] })
The ORM issues either a single JOIN or two queries (one for parents, one for all children, joined in code). Either way, query count is constant in N.
2. Manual batch loading.
For cases the ORM does not handle automatically:
customers = Customer.objects.all()[:100]
customer_ids = [c.id for c in customers]
orders_by_customer = defaultdict(list)
for order in Order.objects.filter(customer_id__in=customer_ids):
orders_by_customer[order.customer_id].append(order)
for customer in customers:
customer_orders = orders_by_customer[customer.id]
# ... render
Two queries. One for parents, one for children. The application correlates in memory.
3. DataLoader pattern.
A dataloader buffers requests within a single execution context, batches them, and returns results to each call site. Useful in GraphQL resolvers or any framework where multiple parts of the request might independently ask for child data.
Most languages have a dataloader implementation: dataloader in JS, aiodataloader in Python, etc. The pattern is the same: collect IDs requested during the request, batch into a single query, distribute results.
When eager loading is wrong
Eager loading is not free:
- It fetches all children, even if some parents do not use them.
- It can fetch enormous amounts of data — "give me all 100 customers and all their orders" might be 100,000 rows.
- For deeply nested relationships (customer → orders → items → variants), eager loading multiplies.
For each of these, the alternative is selective loading:
- Fetch only the children you need (filtered).
- Use pagination.
- Use lazy loading where the access pattern is sparse.
The rule: eager load when the loop will use the data. Lazy load when usage is conditional.
The reverse problem: too much eager loading
Sometimes the bug is the opposite — the ORM is eagerly fetching far more than the request needs. A single API endpoint loads 50KB of data because the ORM has been configured to always include 10 relationships.
The fix is to make eager loading per-query, not global. Each query asks for what it needs.
In ActiveRecord, this is the difference between Customer.includes(:orders).first (fetches orders for one customer — fine) and a global default_scope { includes(:orders) } (fetches orders for every Customer query, regardless of need — bad).
What I check in code review
For any code that loops over a list of database objects:
- Does the loop access related data? If yes, is the relationship eager-loaded above?
- Does the relationship access trigger another query? If yes, that is N+1.
- Is the eager loading scoped to the specific use case? Or is it pulling more than needed?
Three questions, ten seconds each. Catches the issue before it ships.
A diagnostic walkthrough
Real scenario: a search endpoint returning 50 results was taking 3 seconds. Expected: 100ms.
Diagnostic:
-- Top queries by call rate
SELECT query, calls, total_exec_time::int AS total_ms
FROM pg_stat_statements
ORDER BY calls DESC LIMIT 5;
The top query was a 1-row SELECT against the tags table, called millions of times. Adjusting the application code:
# Before: lazy load
for result in results:
print(result.name, result.tags) # N+1 on tags
# After: eager load
for result in Result.objects.prefetch_related('tags').filter(...):
print(result.name, result.tags)
Query count dropped from 51 to 2. Latency dropped from 3 seconds to 90ms.
The fix was one line. The detection took an hour because the application code did not make it obvious that result.tags was lazy-loaded.
What I commit to
For any application using an ORM:
- Query count per request is monitored.
- Alert on requests exceeding 30-50 queries.
- Code review checks for unindexed loop-then-fetch patterns.
- Eager loading is the default for known relationships; lazy loading is the exception.
- APM (or query log) is used during development to spot N+1s before merge.
N+1 is one of those bugs that is universal — every ORM has it; every team has produced it; the symptom is always the same. The discipline to catch it early is what separates fast applications from slow ones.