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 javax.persistencejakarta.persistence namespace change, the ORM 6 query-engine rewrite, the Hibernate Search 6+ Elasticsearch backend), so the official documentation above wins on any discrepancy.

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 / @OneToOne default to FetchType.EAGER.

  • @OneToMany / @ManyToMany default to FetchType.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.

The N+1 blow-up and its fix

sequenceDiagram participant App participant DB as Database App->>DB: SELECT * FROM orders WHERE status = 'PENDING' (1 query, 100 rows) DB-->>App: 100 Order rows loop for each of the 100 orders (naive lazy access) App->>DB: SELECT * FROM customer WHERE id = ? DB-->>App: 1 Customer row end Note over App,DB: 1 + 100 queries total -- the N+1 problem App->>DB: SELECT o.*, c.* FROM orders o JOIN customer c ON ... WHERE o.status = 'PENDING' Note over App,DB: JOIN FETCH -- 1 query total App->>DB: SELECT * FROM customer WHERE id IN (?, ?, ..., ?) Note over App,DB: @BatchSize alternative -- 1 (orders) + ceil(100/batchSize) queries