JPQL Joins & Fetch Joins
IntermediateRegular 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.
// 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).
// ✗ 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.
// ✗ 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 AriaWhat is the difference between JOIN and JOIN FETCH in JPQL?
What causes the Hibernate "cannot page on collection fetch joins" warning?
What is MultipleBagFetchException and how do you fix it?
How does @EntityGraph differ from JOIN FETCH for pagination?
When would you use LEFT JOIN FETCH instead of JOIN FETCH?
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.