Home/Learn/Hibernate & JPA/JPQL Joins & Fetch Joins

JPQL Joins & Fetch Joins

Intermediate
Querying

Regular JPQL JOIN does not initialise the lazy collection; JOIN FETCH forces Hibernate to load it in the same query, preventing N+1 selects for that association.

Overview

JPQL (Java Persistence Query Language) operates on entity object graphs, not tables. JOIN in JPQL creates a path for filtering but does NOT initialise the associated collection/entity for the caller. JOIN FETCH eagerly loads the association in the same SQL query. Use JOIN FETCH to solve N+1 queries on collections. Two critical limitations: (1) JOIN FETCH on a collection + LIMIT causes in-memory pagination (Hibernate warning); (2) multiple collection JOIN FETCHes produce a Cartesian product — fetch each collection in a separate query instead.

JOIN vs JOIN FETCH

JOIN filters but does not load. JOIN FETCH loads the association. The generated SQL for JOIN FETCH adds a SQL JOIN and reads the associated rows into the persistence context.

Java — JOIN vs JOIN FETCH
// Regular JOIN — use for filtering only, does NOT initialise the collection
@Query("SELECT o FROM Order o " +
       "JOIN o.customer c " +
       "WHERE c.tier = 'GOLD'")
List<Order> findGoldCustomerOrders();
// SQL: SELECT o.* FROM orders o JOIN customers c ON o.customer_id=c.id WHERE c.tier='GOLD'
// Accessing order.getCustomer() later triggers N lazy selects!

// JOIN FETCH — loads the association in the SAME query
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.customer " +
       "WHERE o.status = :status")
List<Order> findWithCustomerByStatus(@Param("status") OrderStatus status);
// SQL: SELECT o.*, c.* FROM orders o JOIN customers c ON o.customer_id=c.id WHERE o.status=?
// Accessing order.getCustomer() hits the L1 cache — no additional SQL

// LEFT JOIN FETCH — include orders even if customer is null
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findWithOptionalCustomer(@Param("id") Long id);

Collection Fetch & Pagination Warning

JOIN FETCH on a collection + Pageable causes Hibernate to load ALL rows into memory then paginate — the "HHH90003004" warning. Fix with @EntityGraph + Pageable (uses a 2-query approach: count + data).

Java — fixing in-memory pagination on collections
// ✗ DANGER: JOIN FETCH + LIMIT = in-memory pagination
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.customerId = :cid")
Page<Order> findWithItems(@Param("cid") Long cid, Pageable pageable);
// WARNING: HHH90003004 — cannot page on collection fetch joins
// Hibernate loads ALL matching orders + items into memory, then paginates in Java!

// ✓ Fix 1: Use @EntityGraph — Spring Data applies a 2-step approach
@EntityGraph(attributePaths = {"items"})
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
// Step 1: SELECT o.id FROM orders WHERE customer_id=? LIMIT 20
// Step 2: SELECT o.*, i.* FROM orders o JOIN items i ON ... WHERE o.id IN (...)

// ✓ Fix 2: Load IDs first, then fetch with JOIN FETCH
@Query("SELECT o.id FROM Order o WHERE o.customerId = :cid")
Page<Long> findOrderIds(@Param("cid") Long cid, Pageable pageable);

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findWithItemsByIds(@Param("ids") List<Long> ids);

Multiple Bag Fetch Exception

Fetching two or more collections simultaneously with JOIN FETCH causes MultipleBagFetchException (Hibernate cannot merge two bags). Solutions: use Sets instead of Lists, or load each collection in a separate query.

Java — solving MultipleBagFetchException
// ✗ MultipleBagFetchException — two List collections
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.items " +          // List<OrderItem>
       "JOIN FETCH o.payments " +       // List<Payment>
       "WHERE o.id = :id")
Optional<Order> findWithAll(@Param("id") Long id);
// Throws: org.hibernate.loader.MultipleBagFetchException

// ✓ Fix 1: Change to Set<> (bags → sets — deduplication prevents the problem)
@OneToMany(mappedBy = "order")
private Set<OrderItem> items = new HashSet<>();  // Set, not List

// ✓ Fix 2: Load each collection in a separate query
Optional<Order> order = orderRepo.findWithItems(id);  // JOIN FETCH o.items
order.ifPresent(o -> Hibernate.initialize(o.getPayments()));  // trigger lazy load

// ✓ Fix 3: Use @NamedEntityGraph with FETCH (Hibernate resolves separately)
@NamedEntityGraph(name = "Order.full", attributeNodes = {
    @NamedAttributeNode("items"),
    @NamedAttributeNode("payments")
})

Key Points to Remember

  • 1JOIN in JPQL filters but does NOT initialise the lazy association (N+1 still occurs on access).
  • 2JOIN FETCH loads the association in the same SQL query — eliminates N+1 for that path.
  • 3JOIN FETCH on a collection + Pageable triggers in-memory pagination — use @EntityGraph instead.
  • 4Fetching two List collections in one query causes MultipleBagFetchException.
  • 5Fix: change List to Set, or load each collection in a separate query.
  • 6LEFT JOIN FETCH includes the parent even when the child collection is empty.

Interview Questions

Sign in to ask Aria
1

What is the difference between JOIN and JOIN FETCH in JPQL?

MediumAmazon
2

What causes the Hibernate "cannot page on collection fetch joins" warning?

HardNetflix
3

What is MultipleBagFetchException and how do you fix it?

HardPivotal
4

How does @EntityGraph differ from JOIN FETCH for pagination?

HardUber
5

When would you use LEFT JOIN FETCH instead of JOIN FETCH?

MediumInfosys

Ask Aria about JPQL Joins & Fetch Joins

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…