← all posts
·2 min read

The N+1 query problem in JPA/Hibernate, and how to actually fix it

JavaJPAHibernatePerformance

You fetch 100 orders, then render their items, and your database logs show 101 queries. That is the N+1 problem, and it is the single most common performance bug in JPA applications — it usually passes tests with three rows and falls over in production with ten thousand.

Why it happens

You run one query to load N parents. Each parent has a lazily-loaded association. The moment your code touches that association — in a loop, a toString(), or a Jackson serializer walking the object — Hibernate fires one more query to load it. One query plus N more equals 1+N round trips.

// N+1: one query for orders, then one per order for items
List<Order> orders = repo.findAll();
orders.forEach(o -> o.getItems().size());   // fires N extra queries

Fix 1: JOIN FETCH

Fetch parents and children in a single query with an explicit join. This is the standard fix.

@Query("select distinct o from Order o join fetch o.items where o.status = :s")
List<Order> findWithItems(@Param("s") Status s);

Fix 2: @EntityGraph

The same idea, declared on the repository method instead of in JPQL. It composes better with Spring Data derived queries.

@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(Status status);

Fix 3: batch fetching (the low-effort global win)

Batch fetching does not eliminate the N, but it collapses it into ceil(N / batchSize) IN-clause queries. Set it globally and it applies everywhere with almost no code change — often the best effort-to-payoff ratio available.

@BatchSize(size = 50)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<Item> items;

// Or globally, in application.yml:
// spring.jpa.properties.hibernate.default_batch_fetch_size: 50

Fix 4: DTO projection

If you only need a few fields, select them straight into a DTO and never load entities at all.

@Query("select new com.x.OrderDto(o.id, o.total, count(i)) " +
       "from Order o join o.items i group by o.id, o.total")
List<OrderDto> summaries();

Two traps when applying these

JOIN FETCH on two collections at once produces a cartesian product, and Hibernate throws MultipleBagFetchException for two List-typed 'bags'. Fetch one collection and batch the other, or use Set instead of List.

JOIN FETCH plus pagination is worse because it is silent: Hibernate cannot paginate in SQL once a collection is joined, so it fetches the entire result set and paginates in memory — you will see the warning 'firstResult/maxResults specified with collection fetch; applying in memory'. On a large table that is an out-of-memory waiting to happen. Use @EntityGraph with batch fetching, or split into two queries (fetch IDs first, then fetch by IDs).

What is NOT a fix

Switching the association to FetchType.EAGER. That does not remove the N+1 — it just moves it earlier and makes it unconditional, firing on every query whether you need the association or not. Default to LAZY and fetch explicitly per use case.