The N+1 problem is one of the most common performance traps in the back-end, and the sneakiest part is that the code looks perfect. It comes from the convenience of ORMs and only shows up when data volume grows. Knowing how to spot and fix it is expected of any mid-level dev.
What the N+1 problem is
The name describes exactly what happens: you run 1 query to fetch a list of items, and then N queries — one per item — to fetch related data. If you list 100 orders and fetch the customer for each one, that's 1 + 100 = 101 queries where two would do.
How the ORM betrays you
ORMs (Object-Relational Mappers) let you access relations as if they were ordinary attributes. The catch is that each access can trigger a hidden query — so-called lazy loading:
orders = Order.all() // 1 query: SELECT * FROM orders
for order in orders:
print(order.customer.name) // N queries! each .customer
// fires SELECT * FROM customers WHERE id = ?
The code is clean and readable — that's the betrayal. Nothing visually indicates that order.customer is hitting the database on every iteration. With 10 orders you won't even notice; with 10 thousand, the page grinds to a halt.
How to detect it
The most direct way is to turn on the ORM's query log in your development environment. If you see dozens of nearly identical SELECTs varying only by ID, you've found an N+1:
SELECT * FROM orders;
SELECT * FROM customers WHERE id = 1;
SELECT * FROM customers WHERE id = 2;
SELECT * FROM customers WHERE id = 3;
... (repeats N times)
Day to day, this shows up when an endpoint that used to be fast starts degrading as the database grows. APM tools and the query log itself are your allies.
How to fix it: eager loading
The fix is to tell the ORM up front that you'll need the relation. This is eager loading, which the ORM translates into a JOIN or a second batched query:
// Before (N+1):
orders = Order.all()
// After (eager loading):
orders = Order.all().include('customer')
// The ORM generates something like:
// SELECT * FROM orders;
// SELECT * FROM customers WHERE id IN (1, 2, 3, ...); // just 1 query
Before and after: the impact
| Scenario | Queries (100 items) | Result |
|---|---|---|
| Lazy loading (N+1) | 101 | Slow, scales poorly |
| Eager loading (JOIN or IN) | 1 or 2 | Fast and constant |
The difference isn't just speed: N+1 gets worse linearly with the number of items, while eager loading keeps the cost practically constant. It's the difference between an endpoint that can handle growth and one that becomes a bottleneck.
N+1 isn't an ORM bug — it's the ORM doing exactly what you asked without you noticing. The fix is to make your intent explicit: say up front that you'll need the relation.
When to accept it, and caveats
Not every loop that accesses a relation is a problem: if the list has 2 items, N+1 is irrelevant. On the other hand, excessive eager loading (loading relations you don't use) also wastes resources. The balance is to load what you'll actually use, in batches. This topic ties in directly with caching in the back-end, another performance lever.
Conclusion
The N+1 problem is the perfect example of how convenient abstractions hide costs. Learn to read the query log, recognize the pattern of repeated SELECTs and use eager loading to collapse N queries into one. It's one of those pieces of knowledge that, once learned, you start seeing everywhere.