04-persistence-data-access

Lazy Loading, Eager Loading, and the N+1 Query Problem

How Hibernate fetch strategies work, why N+1 query bugs happen in production, and the concrete fixes: JOIN FETCH, entity graphs, and batch fetching.

August 14, 2026
backend-engineerlazyeagernplusonefetch-strategyhibernate

Lazy Loading, Eager Loading, and N+1

If there is one Hibernate bug class that has quietly degraded more production APIs than any other, it's the N+1 query problem. It doesn't show up in unit tests against an in-memory dataset of three rows. It shows up three months later when a customer list endpoint that used to take 40ms starts taking 4 seconds, because someone added a new field that walks a lazy association inside a loop. This guide covers how fetch strategies actually work under the hood and the concrete, production-tested fixes.


1. FetchType.LAZY vs FetchType.EAGER

FetchType controls when an association is loaded relative to its owning entity.

  • LAZY: the association is loaded on first access, via a proxy or an uninitialized collection wrapper. Nothing is fetched until you actually call a method on it.
  • EAGER: the association is loaded immediately, in the same query (via a JOIN) or a follow-up query, whenever the owning entity is loaded — whether you need it or not.

Default fetch types by association

AnnotationSpec defaultPractical recommendation
@ManyToOneEAGEROverride to LAZY explicitly, always
@OneToOneEAGEROverride to LAZY explicitly, always
@OneToManyLAZYKeep as LAZY
@ManyToManyLAZYKeep as LAZY
🚨

The JPA spec defaults @ManyToOne and @OneToOne to EAGER — the opposite of what you want in almost every production system. Every @ManyToOne/@OneToOne mapping should be written with fetch = FetchType.LAZY explicitly. Leaving the default in place is one of the most common sources of accidental deep object graphs being pulled into memory.

java
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;
 
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<LineItem> lineItems = new ArrayList<>();
}

How LAZY actually works

For @ManyToOne/@OneToOne, Hibernate returns a bytecode-generated proxy — a subclass of your entity with all fields empty except the ID, which only hits the database when a non-identifier method is called on it. For @OneToMany/@ManyToMany collections, Hibernate wraps them in PersistentBag, PersistentSet, or PersistentList — collection implementations that trigger a SELECT on first iteration, size(), or similar access.

java
Order order = entityManager.getReference(Order.class, 1L);
// no SQL yet — order is a proxy with only the ID populated
 
Long customerId = order.getId();
// still no SQL — the ID was passed in, no DB hit needed
 
String status = order.getStatus().name();
// SQL fires here — first real field access initializes the proxy

2. Cascade Types

Cascading propagates entity state operations (persist, merge, remove, etc.) from a parent entity to its associated children.

java
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LineItem> lineItems = new ArrayList<>();
Cascade typeEffect
PERSISTSaving the parent also saves new children
MERGEMerging the parent also merges children
REMOVEDeleting the parent also deletes children
REFRESHRefreshing the parent also refreshes children from the DB
DETACHDetaching the parent also detaches children
ALLAll of the above

orphanRemoval = true is a related but distinct setting: it deletes a child row when it's removed from the collection, even if the parent itself isn't deleted.

⚠️

CascadeType.ALL + @ManyToOne is a common production landmine. Cascading REMOVE from a child to a shared parent (e.g., cascading from LineItem up to Product) can delete a Product that's referenced by dozens of other orders. Only cascade REMOVE/ALL on associations where the child's lifecycle is truly owned by the parent — the classic "composition" relationship (OrderLineItem), never a "reference" relationship (LineItemProduct).


3. How the N+1 Problem Happens

The N+1 problem occurs when you fetch a list of N parent entities with one query, then trigger N additional queries — one per parent — to lazily load an association for each.

java
// Looks innocent, isn't
List<Order> orders = orderRepository.findAll();   // 1 query
for (Order order : orders) {
    System.out.println(order.getCustomer().getName());  // N queries — one per order!
}
// Total: 1 + N queries, where N = orders.size()

For 20 orders on a page, that's 21 round trips to the database instead of 1 or 2. At scale, with connection pool contention and network latency added to each round trip, this alone can be the difference between a 30ms and a 3-second response.

💡

N+1 isn't unique to @ManyToOne — it happens just as easily with @OneToMany collections accessed in a loop, and with derived/@Query methods that return a list of entities whose lazy associations get walked downstream (e.g., in a serialization layer or a mapper).

Detecting N+1 in practice

yaml
# application.yml — log every SQL statement Hibernate issues
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG

In production, don't rely on show-sql — use a SQL-counting library like datasource-proxy or p6spy, or Hibernate's built-in Statistics API, wired into an integration test assertion:

java
@Test
void listOrders_shouldNotTriggerNPlusOne() {
    Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
    stats.setStatisticsEnabled(true);
    stats.clear();
 
    List<OrderSummary> result = orderService.listRecentOrders();
 
    assertThat(stats.getQueryExecutionCount()).isLessThanOrEqualTo(2);
}

A good habit: assert query counts in integration tests for any endpoint returning a list. It turns N+1 regressions into a CI failure instead of a production incident three sprints later.


4. Fix #1: JOIN FETCH

The most direct fix — pull the association into the same query using JPQL's JOIN FETCH.

java
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
    List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
}

This generates a single SQL JOIN:

sql
SELECT o.*, c.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = ?
⚠️

JOIN FETCH on a @OneToMany collection combined with pagination silently breaks. Hibernate cannot apply LIMIT/OFFSET at the SQL level when a collection join would multiply row counts — it fetches the entire result set into memory and paginates in Java, logging a warning ("firstResult/maxResults specified with collection fetch; applying in memory"). Never combine JOIN FETCH on a to-many association with Pageable. Use entity graphs or batch fetching instead for paginated queries.

java
// DANGEROUS: pagination silently happens in memory, not in SQL
@Query("SELECT o FROM Order o JOIN FETCH o.lineItems")
Page<Order> findAllWithLineItems(Pageable pageable);

5. Fix #2: Entity Graphs

@EntityGraph lets you declare fetch plans without hand-writing JPQL, and it composes cleanly with derived query methods.

java
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @EntityGraph(attributePaths = {"customer", "lineItems"})
    List<Order> findByStatus(OrderStatus status);
 
    @EntityGraph(attributePaths = {"customer", "lineItems.product"})
    Optional<Order> findWithFullDetailById(Long id);
}

Or defined declaratively on the entity itself and referenced by name:

java
@Entity
@NamedEntityGraph(
    name = "Order.withCustomerAndItems",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode("lineItems")
    }
)
public class Order { /* ... */ }
java
@EntityGraph(value = "Order.withCustomerAndItems", type = EntityGraph.EntityGraphType.LOAD)
List<Order> findByStatus(OrderStatus status);
EntityGraphTypeBehavior
FETCHAttributes in the graph are EAGER; everything else falls back to LAZY, ignoring the entity's normal mapping default
LOADAttributes in the graph are EAGER; everything else uses its mapped fetch type (respects your @ManyToOne(fetch = LAZY) elsewhere)

EntityGraphType.LOAD (the default) is almost always what you want — it only affects the attributes you explicitly list, leaving your normal LAZY mappings intact everywhere else in the entity.


6. Fix #3: Batch Fetching

For cases where you can't easily rewrite the query (deeply nested lazy chains, dynamic access patterns), batch fetching collapses N individual SELECTs into a handful of SELECT ... WHERE id IN (?, ?, ?, ...) queries.

yaml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25

Or per-association:

java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
@BatchSize(size = 25)
private Customer customer;
 
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<LineItem> lineItems;

With batch size 25, loading 100 orders and then walking order.getCustomer() for each produces 4 queries (100 / 25) instead of 100:

sql
SELECT * FROM customers WHERE id IN (?, ?, ?, ..., ?);  -- up to 25 ids at a time

Comparing the three fixes

FixQuery countPagination-safeBest for
JOIN FETCH1No (collections)Single-entity or @ManyToOne-only queries
@EntityGraph1-2No (collections)Declarative, reusable fetch plans on repository methods
Batch fetching (default_batch_fetch_size / @BatchSize)Ceil(N / batchSize)YesPaginated lists, deeply nested lazy graphs
💡

In real systems, all three coexist: JOIN FETCH or entity graphs for the primary association you always need, and a global default_batch_fetch_size as a safety net for everything else you didn't explicitly plan for.


7. LazyInitializationException and Open Session in View

Accessing a lazy association after the persistence context (Hibernate Session) has closed throws LazyInitializationException.

java
@Service
public class OrderService {
    private final OrderRepository orderRepository;
 
    @Transactional(readOnly = true)
    public Order getOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
        // transaction, and the Session, close when this method returns
    }
}
 
// In a controller or mapper, called later:
Order order = orderService.getOrder(1L);
order.getLineItems().size();  // LazyInitializationException — session is closed

Spring Boot ships with Open Session in View (OSIV) enabled by default, which keeps the Hibernate session open for the entire HTTP request — masking this class of bug by letting lazy loading happen even in the view/serialization layer.

🚨

OSIV is a trap, not a safety net. It hides N+1 problems during development (everything "just works") and moves the cost of lazy initialization into the HTTP response-rendering phase, holding a DB connection open for the full request lifecycle — including template rendering or JSON serialization time. Most staff-level Spring Boot teams disable it (spring.jpa.open-in-view: false) and instead fetch everything needed inside an explicit @Transactional service method, using JOIN FETCH/entity graphs/DTO projections deliberately.

yaml
spring:
  jpa:
    open-in-view: false

With OSIV off, the fix is architectural discipline: service-layer methods must return fully-initialized data (either fully fetched entities or DTOs), never lazy proxies for the controller layer to poke at later.

java
@Service
public class OrderService {
 
    @Transactional(readOnly = true)
    public OrderDetailDto getOrderDetail(Long id) {
        Order order = orderRepository.findWithFullDetailById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
        return OrderDetailDto.from(order);  // mapped to DTO while session is open
    }
}

8. Production Observations

  • Default every @ManyToOne/@OneToOne to LAZY — treat the JPA spec's EAGER default as a bug you have to opt out of on every mapping.
  • Never trust "it worked in the test" — small test datasets hide N+1 bugs completely. Test with realistic row counts or assert query counts explicitly.
  • open-in-view: false is the right default for services beyond a prototype — it forces fetch strategy decisions to be explicit and visible in code review.
  • Combine strategies: JOIN FETCH/@EntityGraph for the "always needed" association, default_batch_fetch_size as the safety net for everything else.
  • Watch out for mappers/serializers: a Jackson @JsonIgnoreProperties or MapStruct mapper that touches a lazy field outside the transaction is a very common source of LazyInitializationException in real codebases.

Key takeaways

  • @ManyToOne/@OneToOne default to EAGER per spec — always override to LAZY explicitly.
  • N+1 happens when a list query is followed by N per-row queries for a lazily-loaded association accessed in a loop.
  • JOIN FETCH and @EntityGraph collapse N+1 into 1-2 queries but break in-database pagination on to-many associations.
  • Batch fetching (default_batch_fetch_size, @BatchSize) is the pagination-safe fix — it turns N queries into N / batchSize queries.
  • LazyInitializationException means you accessed an association after the persistence context closed; the real fix is fetching what you need inside the transaction, not blindly enabling OSIV.
  • Open Session in View hides N+1 bugs during development and holds DB connections open longer than necessary — disable it in production-grade services.
  • Assert SQL query counts in integration tests for list-returning endpoints; it's the cheapest regression guard against N+1 creeping back in.

Interview Questions

  • What is the difference between FetchType.LAZY and FetchType.EAGER?
  • Why does the JPA spec default @ManyToOne to EAGER, and why is that usually wrong for production code?
  • Walk through exactly how the N+1 query problem occurs when iterating a list of entities and accessing a lazy association.
  • How would you detect an N+1 problem in an existing codebase without a profiler?
  • What SQL does JOIN FETCH generate, and why does it break with pagination on collection associations?
  • What is @EntityGraph, and what's the difference between EntityGraphType.FETCH and EntityGraphType.LOAD?
  • How does batch fetching (default_batch_fetch_size) reduce query count, and why is it pagination-safe when JOIN FETCH isn't?
  • What causes a LazyInitializationException, and what are the two ways to avoid it?
  • What is Open Session in View, and why do many senior engineers recommend disabling it?
  • What's the difference between CascadeType.REMOVE and orphanRemoval = true?
  • Why is CascadeType.ALL dangerous on a @ManyToOne association?
  • Given a REST endpoint that got slow after adding a new field to its response DTO, how would you diagnose whether N+1 is the cause?
  • How do proxies work for @ManyToOne lazy associations versus PersistentBag/PersistentSet for collections?