Spring BootFetch Types
Spring Boot

Fetch Types

Fetch types control when Hibernate loads associated entities — immediately with the parent (EAGER) or only when the association is first accessed (LAZY). Choosing the wrong fetch type is one of the most common sources of N+1 query problems, LazyInitializationException, and unexpected performance issues in Spring Boot applications. The rule is simple: always declare LAZY, then fetch eagerly only when needed using JOIN FETCH or EntityGraph.

EAGER vs LAZY — The Fundamental Difference

FetchType.EAGER loads the associated entity or collection in the same database operation as the parent. FetchType.LAZY defers loading until the association is first accessed in Java code. The JPA specification defaults are counterintuitive: @ManyToOne and @OneToOne default to EAGER; @OneToMany and @ManyToMany default to LAZY. Always override to LAZY and use explicit fetching when associations are needed.
Java
// ── JPA default fetch types (the defaults are problematic): ─────────
@ManyToOne                     // default: FetchType.EAGER — override this
@OneToOne                      // default: FetchType.EAGER — override this
@OneToMany                     // default: FetchType.LAZY  — keep this
@ManyToMany                    // default: FetchType.LAZY  — keep this

// ── Always override @ManyToOne and @OneToOne to LAZY: ─────────────────
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;

// ── What EAGER actually does: ─────────────────────────────────────────
// Option 1 — JOIN in the same query (when one association is EAGER):
// SELECT c.*, u.* FROM comments c LEFT JOIN users u ON u.id = c.user_id
// WHERE c.id = ?

// Option 2 — separate SELECT per parent row (the N+1 problem):
// SELECT * FROM comments WHERE post_id = ?     (1 query — N results)
// SELECT * FROM users WHERE id = 1             (1 query per comment)
// SELECT * FROM users WHERE id = 2
// SELECT * FROM users WHERE id = 3             ... N additional queries

// ── What LAZY actually does: ──────────────────────────────────────────
// SELECT * FROM comments WHERE post_id = ?   (1 query — user not loaded)
// comment.getUser()  ← triggers: SELECT * FROM users WHERE id = ?
// comment.getUser()  ← returns cached proxy — no second query

The N+1 Problem

The N+1 problem is the most common Hibernate performance issue caused by fetch types. It occurs when loading a collection of N entities triggers N additional queries to load an association on each one. EAGER fetch does not prevent N+1 — it makes it invisible by executing the extra queries automatically.
Java
// ── N+1 with LAZY (visible): ──────────────────────────────────────────
@Transactional(readOnly = true)
public List<CommentResponse> findAll() {
    List<Comment> comments = commentRepository.findAll();
    // SELECT * FROM comments  → 1 query, returns N comments

    return comments.stream()
        .map(c -> new CommentResponse(
            c.getId(),
            c.getBody(),
            c.getUser().getName()    // ← triggers SELECT per comment!
        ))
        .toList();
    // SELECT * FROM users WHERE id = 1   ← query 2
    // SELECT * FROM users WHERE id = 2   ← query 3
    // SELECT * FROM users WHERE id = 3   ← query 4
    // ... N+1 total queries
}

// ── N+1 with EAGER (invisible but still present): ─────────────────────
// EAGER does not eliminate N+1 — it just hides it.
// Hibernate may still issue N separate SELECTs for each parent row.
// The association fires automatically, making it harder to notice.

// ── FIX 1: JOIN FETCH in JPQL ─────────────────────────────────────────
@Query("SELECT c FROM Comment c JOIN FETCH c.user")
List<Comment> findAllWithUser();
// SELECT c.*, u.* FROM comments c JOIN users u ON u.id = c.user_id
// 1 query — all data loaded together

// ── FIX 2: EntityGraph ────────────────────────────────────────────────
@EntityGraph(attributePaths = {"user"})
@Query("SELECT c FROM Comment c")
List<Comment> findAllWithUserGraph();

// ── FIX 3: default_batch_fetch_size (global — easiest to apply): ──────
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25
// Hibernate batches lazy loads: instead of N queries, uses IN (...):
// SELECT * FROM users WHERE id IN (1, 2, 3, ..., 25)
// Reduces N+1 to ceil(N/25) queries — dramatically better.

// ── Detecting N+1 in tests: ───────────────────────────────────────────
// Add to test application.yml:
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
logging:
  level:
    org.hibernate.stat: DEBUG
// Logs: "HikariPool-1 - 47 statements executed" → investigate if unexpected

JOIN FETCH — On-Demand Eager Loading

JOIN FETCH in JPQL instructs Hibernate to load the specified association in the same SQL query using a JOIN. This is the primary mechanism for loading associations when you know they will be needed — without changing the entity's default fetch type.
Java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    // ── Single association: ────────────────────────────────────────────
    @Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.id = :id")
    Optional<Order> findByIdWithCustomer(@Param("id") Long id);

    // ── Collection association: ────────────────────────────────────────
    @Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
    Optional<Order> findByIdWithItems(@Param("id") Long id);
    // LEFT JOIN FETCH — returns the order even if it has no items
    // INNER JOIN FETCH — excludes orders with no items

    // ── Multiple associations — use DISTINCT to remove duplicates: ────
    @Query("SELECT DISTINCT o FROM Order o " +
           "LEFT JOIN FETCH o.items " +
           "LEFT JOIN FETCH o.customer " +
           "WHERE o.id = :id")
    Optional<Order> findByIdWithItemsAndCustomer(@Param("id") Long id);
    // DISTINCT removes duplicate Order rows caused by the JOIN

    // ── JOIN FETCH with filtering: ────────────────────────────────────
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "WHERE c.country = :country " +
           "ORDER BY o.createdAt DESC")
    List<Order> findByCustomerCountryWithCustomer(@Param("country") String country);

    // ── JOIN FETCH with pagination — WARNING: ─────────────────────────
    // JOIN FETCH + Pageable on a collection causes HHH90003004 warning:
    // "HHH90003004: firstResult/maxResults specified with collection fetch;
    //  applying in memory!"
    // Hibernate fetches ALL rows into memory and paginates in Java — avoid.
    // WRONG:
    @Query("SELECT o FROM Order o LEFT JOIN FETCH o.items")
    Page<Order> findAllWithItemsPaged(Pageable pageable);  // memory pagination!

    // CORRECT: paginate orders, then batch-fetch items separately:
    @Query(value = "SELECT o FROM Order o",
           countQuery = "SELECT COUNT(o) FROM Order o")
    Page<Order> findAllPaged(Pageable pageable);  // paginate without JOIN FETCH
    // Then use default_batch_fetch_size to load items in batches.
}

EntityGraph — Named and Ad-hoc Fetch Plans

@EntityGraph is an alternative to JOIN FETCH that defines fetch plans on the repository method or on the entity class. It is cleaner than @Query when you only need to specify which associations to fetch without changing the query logic.
Java
// ── Ad-hoc EntityGraph on repository method (most common): ───────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // Fetch user with orders and profile in one query:
    @EntityGraph(attributePaths = {"orders", "profile"})
    Optional<User> findById(Long id);

    // EntityGraph on a derived method:
    @EntityGraph(attributePaths = {"orders.items"})  // nested — orders + their items
    @Query("SELECT u FROM User u WHERE u.email = :email")
    Optional<User> findByEmailWithOrderItems(@Param("email") String email);

    // Different fetch plan for a different use case:
    @EntityGraph(attributePaths = {"profile"})
    Optional<User> findWithProfileById(Long id);
}

// ── Named EntityGraph on the entity class: ────────────────────────────
@Entity
@Table(name = "users")
@NamedEntityGraph(
    name = "User.withOrdersAndProfile",
    attributeNodes = {
        @NamedAttributeNode("profile"),
        @NamedAttributeNode(value = "orders",
                            subgraph = "orders.items")
    },
    subgraphs = @NamedSubgraph(
        name = "orders.items",
        attributeNodes = @NamedAttributeNode("items")
    )
)
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // ...
}

// Use the named graph in the repository:
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph("User.withOrdersAndProfile")
    Optional<User> findWithFullGraphById(Long id);
}

// ── EntityGraph type: FETCH vs LOAD: ─────────────────────────────────
// FETCH (default): attributes in the graph are EAGER;
//                  all others revert to LAZY regardless of entity default.
// LOAD:            attributes in the graph are EAGER;
//                  all others keep their declared fetch type.
@EntityGraph(value = "User.withOrdersAndProfile",
             type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findById(Long id);

LazyInitializationException — Causes and Fixes

LazyInitializationException is thrown when a lazy association is accessed outside an active Hibernate session. In Spring Boot REST APIs with open-in-view disabled, this happens whenever a lazy association is accessed after the @Transactional method has returned.
Java
// ── The error: ────────────────────────────────────────────────────────
// org.hibernate.LazyInitializationException:
//   failed to lazily initialize a collection of role: com.example.Order.items,
//   could not initialize proxy - no Session

// ── Root cause — transaction ends before lazy association is accessed: ─
@Service
@Transactional(readOnly = true)
public Order findById(Long id) {
    return orderRepository.findById(id).orElseThrow();
    // Transaction ends here — order.items is a lazy proxy
}

@RestController
public class OrderController {
    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        Order order = orderService.findById(id);  // transaction already ended
        order.getItems().size();  // ← LazyInitializationException!
    }
}

// ── FIX 1: Map to DTO inside the transaction (recommended): ──────────
@Service
@Transactional(readOnly = true)
public OrderResponse findById(Long id) {
    Order order = orderRepository.findByIdWithItems(id).orElseThrow();
    return OrderResponse.from(order);  // access items while session is open
}

// ── FIX 2: JOIN FETCH the association: ───────────────────────────────
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

// ── FIX 3: Hibernate.initialize() — explicit initialisation: ─────────
@Transactional(readOnly = true)
public Order findByIdInitialised(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    Hibernate.initialize(order.getItems());  // forces loading while session open
    return order;  // items are now loaded — safe to access after return
}

// ── ANTI-FIX: open-in-view: truedo NOT use this: ──────────────────
// spring.jpa.open-in-view: true keeps the session open for the entire
// HTTP request — lazy loads succeed but trigger hidden queries during
// Jackson serialisation. Causes unpredictable N+1 and holds DB connections
// longer than necessary. Always set: spring.jpa.open-in-view: false

Fetch Type Best Practices

A concise set of rules that prevent the most common fetch type mistakes.
Java
// ── Rule 1: Always declare LAZY on @ManyToOne and @OneToOne ──────────
@ManyToOne(fetch = FetchType.LAZY)    // ALWAYS
@OneToOne(fetch = FetchType.LAZY)     // ALWAYS
@OneToMany(fetch = FetchType.LAZY)    // already the default — keep it
@ManyToMany(fetch = FetchType.LAZY)   // already the default — keep it

// ── Rule 2: Never change a default to EAGER on the entity ─────────────
// Changing to EAGER on the entity class affects every query that loads
// the entity — even queries that do not need the association.
// Use JOIN FETCH or EntityGraph per-query instead.

// ── Rule 3: Map to DTOs inside the @Transactional boundary ───────────
@Service
@Transactional(readOnly = true)
public List<OrderResponse> findAll() {
    return orderRepository.findAllWithItems()
        .stream()
        .map(OrderResponse::from)   // map while session is open
        .toList();
}
// Never return managed entities from @RestController methods.
// Jackson will attempt to serialise lazy proxies → N+1 or LazyInitException.

// ── Rule 4: Use default_batch_fetch_size as a global safety net ───────
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25
// Converts N+1 into ceil(N/batchSize) queries — a cheap, effective baseline.

// ── Rule 5: Use JOIN FETCH for single-entity loads that need associations
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.items " +
       "JOIN FETCH o.customer " +
       "WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);

// ── Rule 6: Do NOT JOIN FETCH collections when paginating ─────────────
// Hibernate fetches all rows in memory when you combine Pageable +
// collection JOIN FETCH. Paginate first, then batch-fetch collections.
Page<Order> findAll(Pageable pageable);  // paginate without JOIN FETCH
// Hibernate uses default_batch_fetch_size to load items in batches.

// ── Rule 7: Use @Transactional(readOnly = true) for all reads ─────────
// readOnly = true: disables dirty checking, hints connection pool for
// read replica routing, and slightly reduces Hibernate overhead.
@Transactional(readOnly = true)
public UserResponse findById(Long id) { ... }