JPA N+1 query problem
Why lazy associations trigger N+1 queries, how to detect them, and the four standard fixes.
On this page
The shape of the bug
Load a list of Order rows, then iterate to read order.getCustomer().getName(). With FetchType.LAZY, Hibernate emits one query to load the orders and N more queries — one per row — to resolve the customer association.
For 200 orders, that’s 201 round-trips. The application looks fine in isolation, slows linearly under data growth, and the symptom only surfaces when someone actually paginates a real result set.
How to detect it
-
Log SQL in a focused test
Set
spring.jpa.show-sql=trueand count statements per endpoint. -
Assert statement count
Wire a
StatementInspectoror use@Sqlto fail when an endpoint exceeds N statements. -
Watch p99 by endpoint
Anything that fans out per row will diverge from p50 as datasets grow. That divergence is your signal.
The pattern
Lazy associations look fine until you traverse them in a loop. Each traversal is one round-trip.
Pick a strategy per read path: JOIN FETCH, @EntityGraph, @BatchSize, or DTO projection.
Don’t inherit the default.
One query (or k batches) instead of N+1. Slightly more verbose repository code. Pagination
needs care with JOIN FETCH on collections.
The four standard fixes
public List<OrderSummary> recent() {
var orders = repo.findByStatus(ACTIVE);
return orders.stream()
.map(o -> new OrderSummary(o.getId(), o.getCustomer().getName()))
.toList();
}@Query("""
select o from Order o
join fetch o.customer
where o.status = :status
""")
List<Order> findActiveWithCustomer(@Param("status") OrderStatus status);| Axis | JOIN FETCH | @BatchSize |
|---|---|---|
| Statement count | 1 | ceil(N/batchSize) + 1 |
| Result cardinality | Explodes on collections | Stays flat |
| Pagination | Breaks for collections (warning, in-memory) | Works fine |
| Granularity | Per query | Per association, repo-wide |
Anti-pattern: blanket eager loading
Annotating every @ManyToOne and @OneToMany with FetchType.EAGER “to be safe”.
A team hits N+1 once, panics, and globally switches the default. Now every read pulls a huge object graph whether the caller needs it or not.
Keep lazy as the default. Make fetching explicit at the query level — JOIN FETCH,
@EntityGraph, or DTO projections. Different read paths need different graphs.
What to remember
Lazy is a default, not a recommendation. Every association you traverse in the read path needs an explicit fetch strategy — chosen, not inherited. See 0003 idempotency keys · System Design for a similarly-shaped “obvious in retrospect” note on write-path reliability.