Quick answer
The N+1 query problem happens when backend code loads one list of records and then runs one extra query for each record in that list.
For example, the application loads 50 orders with one query, then loads customer details with 50 more queries. The page still looks simple, but the database now receives 51 queries for one request.
The usual fixes are eager loading, explicit joins, batched loading, better query shapes, and measuring query counts in tests or logs.
Why the N+1 problem matters
The N+1 problem is easy to miss because each individual query may be fast. The slow part is the repeated round trip between the application and the database.
A backend endpoint can look harmless:
GET /orders/recent
The implementation might load recent orders and then access the customer for each order:
List<Order> orders = orderRepository.findRecentOrders();
for (Order order : orders) {
String customerName = order.getCustomer().getName();
}
If getCustomer() triggers a lazy database lookup, the loop can produce one query for orders plus one query per order.
1 query to load orders
50 queries to load customers
= 51 total queries
The endpoint may pass tests with five rows and become slow in production with hundreds of rows.
A simple SQL example
The first query loads orders:
SELECT id, customer_id, total, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 50;
Then the application loads each customer separately:
SELECT id, name
FROM customers
WHERE id = 101;
SELECT id, name
FROM customers
WHERE id = 102;
SELECT id, name
FROM customers
WHERE id = 103;
This pattern continues until every customer is loaded.
The problem is not that SQL is being used. The problem is that the application is asking the database the same kind of question repeatedly when it could ask once with the right shape.
Why ORMs make it easy to create
Object-relational mappers often support lazy loading. Lazy loading means related data is fetched only when code accesses it.
That can be convenient for simple code:
order.getCustomer().getName();
But the call hides a database query. Inside a loop, hidden database access becomes dangerous because the loop size controls query count.
Lazy loading is not automatically bad. It becomes a problem when the code path needs related data for many records and the query behavior is not visible.
Fix 1: eager loading
Eager loading tells the ORM to fetch needed related records as part of the original request.
Depending on the framework, this may look like:
List<Order> orders = orderRepository.findRecentOrdersWithCustomer();
The ORM might generate a join or a small number of related queries instead of one query per row.
Eager loading is useful when the endpoint always needs the related data. It should still be used intentionally because loading too many relationships can create large result sets.
Fix 2: explicit joins
For read-heavy endpoints, an explicit query is often clearer than relying on entity navigation.
SELECT
o.id,
o.total,
o.created_at,
c.id AS customer_id,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC
LIMIT 50;
This returns the exact data needed by the response. It can be mapped to a DTO instead of a full entity graph.
This approach works well for API list endpoints, dashboard views, exports, and reports where the response shape is known.
Fix 3: batch loading
Sometimes a join is not the best option. Batch loading collects the foreign keys and fetches related rows in one query.
SELECT id, name
FROM customers
WHERE id IN (101, 102, 103, 104);
The application then builds a lookup map:
Map<Long, Customer> customersById = customers.stream()
.collect(Collectors.toMap(Customer::getId, Function.identity()));
Batch loading is common in GraphQL resolvers, service-layer aggregations, and endpoints that combine data from multiple sources.
Fix 4: projection queries
If the API only needs a few fields, do not load the full object graph.
Instead of loading Order, Customer, Address, and PaymentMethod entities, create a query that returns the response projection:
SELECT
o.id,
o.status,
o.total,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.account_id = ?
ORDER BY o.created_at DESC
LIMIT 50;
Projection queries reduce data transfer, avoid accidental lazy loading, and make the endpoint’s database needs easier to review.
Fix 5: watch query counts
The N+1 problem should be caught before users notice it.
Helpful signals include:
- Number of SQL statements per request.
- Slow query logs grouped by endpoint.
- Database round-trip time.
- ORM query logging in development.
- Integration tests that assert query count for critical endpoints.
- Application performance monitoring traces.
A good review question is: if this endpoint returns 20, 100, or 1,000 items, how many database queries does it run?
Indexes still matter
Fixing N+1 query count does not automatically make every query fast. The replacement query still needs the right indexes.
For the joined order list above, the database may need indexes on:
orders(account_id, created_at).orders(customer_id).customers(id).
Read Database Indexes Explained for how index choice follows filters, joins, sorting, and pagination.
N+1 and pagination
Pagination can hide or amplify the N+1 problem.
If the endpoint returns 20 rows per page, an N+1 issue may look acceptable during early testing. Later, a larger admin export, report, or background job may reuse the same code with thousands of rows.
For public APIs, pagination also affects response design. Read Pagination Strategies for REST APIs and API Pagination Metadata Best Practices for API-level tradeoffs.
Common mistakes
The first mistake is only testing with tiny datasets. N+1 problems often stay invisible until the table has enough rows.
The second mistake is fixing the endpoint by increasing database capacity before checking query count. More capacity may delay the symptom, but it does not remove repeated round trips.
The third mistake is eager loading every relationship. That can create huge joins, duplicate rows, and memory pressure.
The fourth mistake is relying on cache to hide bad query shape. Caching may help hot reads, but the endpoint should still have a reasonable uncached path.
Practical backend checklist
When reviewing a list endpoint:
- Identify every relationship accessed inside loops.
- Enable query logging locally and count SQL statements.
- Test with realistic row counts.
- Prefer explicit projection queries for stable API responses.
- Use eager loading when the relationship is always needed.
- Use batch loading when related records are shared or multi-source.
- Check indexes for joins, filters, and sorting.
- Add regression tests or trace alerts for important endpoints.
The goal is not to remove every join or every ORM feature. The goal is to make database access visible, bounded, and proportional to the request.
Related reading
Read Database Indexes Explained for query planning and index tradeoffs, and Caching Strategies Explained for when caching helps after the query shape is correct. For API list design, read Pagination Strategies for REST APIs. Use the SQL Formatter when reviewing generated SQL, and browse the System Design Learning Path or Topics for related backend architecture guides.