Skip to content

JPA N+1 query problem

Why lazy associations trigger N+1 queries, how to detect them, and the four standard fixes.

Updated May 20, 2026

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

  1. Log SQL in a focused test

    Set spring.jpa.show-sql=true and count statements per endpoint.

  2. Assert statement count

    Wire a StatementInspector or use @Sql to fail when an endpoint exceeds N statements.

  3. Watch p99 by endpoint

    Anything that fans out per row will diverge from p50 as datasets grow. That divergence is your signal.

The pattern

Pattern
Explicit fetch strategy
Problem

Lazy associations look fine until you traverse them in a loop. Each traversal is one round-trip.

Solution

Pick a strategy per read path: JOIN FETCH, @EntityGraph, @BatchSize, or DTO projection. Don’t inherit the default.

Consequences

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

Naive (N+1)
OrderService.java
public List<OrderSummary> recent() {
  var orders = repo.findByStatus(ACTIVE);
  return orders.stream()
    .map(o -> new OrderSummary(o.getId(), o.getCustomer().getName()))
    .toList();
}
With join fetch
OrderRepository.java
@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

Anti-pattern
FetchType.EAGER everywhere
Smell

Annotating every @ManyToOne and @OneToMany with FetchType.EAGER “to be safe”.

Why it happens

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.

Fix instead

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.

Related