Fetching and the N+1 Problem
|
This section documents Hibernate ORM 7.4.x (User Guide, Introduction, Query Language Guide, Data Repositories Guide), Jakarta Persistence 3.2, Hibernate Search 8.4.x, and the Hibernate Validator / Hibernate Reactive references — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Three older reference books were consulted as bibliography only while preparing these pages and are not the
primary or main source for any page. All three predate Jakarta Persistence 3.2 and Hibernate ORM 6/7 (the
This section’s bibliography lists the reference material consulted while preparing these pages. |
Hibernate (JPA & ORM) already introduces the N+1 problem briefly. This page goes deeper on every fetch strategy available to prevent it and the trade-offs between them.
Lazy vs. eager, and the JPA defaults
-
@ManyToOne/@OneToOnedefault toFetchType.EAGER. -
@OneToMany/@ManyToManydefault toFetchType.LAZY.
The JPA-mandated defaults are a poor fit for most applications: an eager @ManyToOne fetches the associated
row on every load of the owning entity whether or not that use case needs it, which itself is a source of
unnecessary joins and, transitively, its own N+1 risk when the eagerly-loaded association has further lazy
associations. The common house rule: default every association to FetchType.LAZY (explicitly, on
@ManyToOne/@OneToOne too) and load eagerly only per-query, using one of the mechanisms below, for the
specific associations a given use case actually needs.
Proxies, bytecode enhancement, and LazyInitializationException
A lazy @ManyToOne/@OneToOne is represented by a runtime-generated proxy subclass that holds only the
identifier until a non-identifier method is first called, at which point it triggers a SELECT — this needs
the entity class to be non-final with a non-private no-arg constructor. Lazy basic attributes and lazy
collections work differently: they need build-time bytecode enhancement (the Hibernate Gradle/Maven
enhancement plugin) to intercept field access, since there is no proxy object to substitute for a String or a
List.
LazyInitializationException fires when a lazy association/collection/attribute is accessed after its
persistence context has already closed — there is no session left to run the SELECT. Open-session-in-view
(spring.jpa.open-in-view=true, Spring Boot’s default) works around this by keeping the persistence context
open for the whole HTTP request, so lazy access from the view/serialization layer still succeeds — at the cost
of holding a database connection for the request’s full duration and hiding N+1 problems that surface only in
that layer. See Hibernate (JPA & ORM) — Spring
Boot integration for the trade-off in full; the fetch strategies below are the alternative that avoids needing
OSIV at all.
JOIN FETCH
The most direct fix: ask for the association in the same query, as a real SQL join, instead of a separate
lazy-triggered SELECT.
List<Order> orders = em.createQuery("""
SELECT DISTINCT o FROM Order o
JOIN FETCH o.customer
WHERE o.status = :status
""", Order.class)
.setParameter("status", OrderStatus.PENDING)
.getResultList();
DISTINCT in JPQL/HQL deduplicates the Java result list (it also still appears in the generated SQL by
default) when fetch-joining a to-many association would otherwise return one row per child, duplicating the
parent. Only one to-many association can be JOIN FETCH`ed per query without risking a Cartesian-product
explosion (or, for two bag-typed to-many associations specifically, an outright
`MultipleBagFetchException — see Collections).
Entity graphs
An @EntityGraph or named fetch profile declares, independently of any specific query’s JPQL, which
associations should be loaded eagerly for this fetch only — reusable across several queries without
duplicating join syntax in each:
@Entity
@NamedEntityGraph(
name = "Order.withCustomerAndLines",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("lines")
})
public class Order { /* ... */ }
// ...
EntityGraph<?> graph = em.getEntityGraph("Order.withCustomerAndLines");
Order order = em.find(Order.class, id, Map.of("jakarta.persistence.fetchgraph", graph));
jakarta.persistence.fetchgraph loads exactly the named attributes eagerly and everything else per its mapped
default; jakarta.persistence.loadgraph loads the named attributes eagerly in addition to whatever is
already mapped eager, rather than instead of it.
@BatchSize and subselect fetching
For associations that will be lazily initialized anyway (rather than fetch-joined up front), batching turns N
follow-up SELECT`s into a handful of `IN-batched ones:
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@BatchSize(size = 20)
private List<Book> books;
}
hibernate.default_batch_fetch_size sets the same behavior globally instead of per-association.
@Fetch(FetchMode.SUBSELECT) takes a different approach: when the owning collection is lazily initialized for
one of several already-loaded parent entities, it re-runs the original query that loaded the parents as a
subselect to fetch every parent’s collection in one extra query, rather than batching by id — a good fit when
the parents were loaded by a complex query and their ids alone would not reproduce the same set.
@Fetch(SELECT | JOIN | SUBSELECT)
@Fetch on an association picks the SQL strategy Hibernate uses when it does decide to load that lazy
association: FetchMode.SELECT (one SELECT per parent — the N+1-prone default), FetchMode.JOIN (always
join-fetch it, effectively forcing eager loading regardless of the declared FetchType), or
FetchMode.SUBSELECT (as above). @BatchSize and @Fetch(FetchMode.SELECT) compose; @Fetch(FetchMode.JOIN)
overrides batching for that association.
DTO projections to avoid loading entities at all
The most direct way to avoid N+1 (and the overhead of loading full managed entities) for a read-only view is to not load entities at all — project straight into a DTO:
public record BookSummary(Long id, String title, String authorName) {}
List<BookSummary> summaries = em.createQuery("""
SELECT new com.example.BookSummary(b.id, b.title, a.name)
FROM Book b JOIN b.author a
""", BookSummary.class)
.getResultList();
SELECT new … DTO projections are covered fully in HQL/JPQL; they
sidestep fetching strategy entirely since there are no lazy associations on a projection.