10.6 JPQL JOIN and Advanced Queries
When you need to load related entities in one go or query across multiple tables, you use JPQL (Java Persistence Query Language) ** JOINsyntax. This page covers INNER/LEFT JOIN, ** FETCH JOIN, @EntityGraph, pagination pitfalls, and DTO projection—patterns used often in production.
Reference: Spring Data JPA, Hibernate (JPA 3.x / Jakarta Persistence)
1. JPQL JOIN Types
JPQL queries target entities(class names, not table names). JOINs combine entities that have an association.
| JOIN type | JPQL keyword | Description | Resulting SQL |
|---|---|---|---|
| Inner join | JOIN or INNER JOIN | Only rows with matching related data | INNER JOIN |
| Outer join | LEFT JOIN or LEFT OUTER JOIN | All left-side rows; right side null when no match | LEFT OUTER JOIN |
| Fetch join | JOIN FETCH | Load associated entity in ** the same SELECT**(avoids N+1) | INNER JOIN + single load |
2. Basic JOIN (INNER / LEFT)
In @Query JPQL, you JOIN on association fields.
Sample entities
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String orderNo;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}
INNER JOIN
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN o.member m WHERE m.name = :name")
List<Order> findByMemberName(@Param("name") String name);
}
JOIN o.member m: Inner join betweenOrderandMember. Orders without a member are excluded.- Generated SQL:
SELECT o.* FROM orders o INNER JOIN members m ON o.member_id = m.id WHERE m.name = ?
LEFT JOIN (include orders even when member is missing)
@Query("SELECT o FROM Order o LEFT JOIN o.member m WHERE m.name = :name OR m.name IS NULL")
List<Order> findByMemberNameOrNoMember(@Param("name") String name);
LEFT JOIN o.member m: All orders are returned;mis null when there is no related member.- Note: Without ** FETCH**, only
Orderis loaded in this query;membermay be loaded later via ** Lazy**(extra queries). To load the association in one go, use ** JOIN FETCH**below.
3. JOIN FETCH — The Key to Fixing N+1
JOIN FETCH means “load the associated entity in this same query.” It is the main tool to eliminate N+1 from lazy loading.
Syntax
@Query("SELECT o FROM Order o JOIN FETCH o.member WHERE o.id = :id")
Optional<Order> findByIdWithMember(@Param("id") Long id);
JOIN FETCH o.member: When loadingOrder, member is included in the same SELECT.- Generated SQL:
SELECT o.*, m.* FROM orders o INNER JOIN members m ON o.member_id = m.id WHERE o.id = ? - After this, calling
order.getMember().getName()in the service does not trigger an extra query.
DISTINCT with collection (OneToMany) FETCH
When you FETCH a one-to-many association (e.g. List<Comment>), the SQL result has one row per related row, so the parent entity can appear multiple times. Use ** distinct**in JPQL so JPA returns each parent entity once.
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems WHERE o.member.id = :memberId")
List<Order> findByMemberIdWithItems(@Param("memberId") Long memberId);
- DISTINCT: JPA deduplicates the same
Orderinstance so you get a unique list of orders. - SQL may also get a
DISTINCT, but the main deduplication is at the entity level.
Fetching multiple associations in one query
You can FETCH multiple associations in one query (at the same level).
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.member " +
"JOIN FETCH o.orderItems " +
"WHERE o.id = :id")
Optional<Order> findByIdWithMemberAndItems(@Param("id") Long id);
- Caution: If
orderItemsis a collection, the result set can grow (Cartesian product). For large data, consider FETCHing only one association and using a separate query or@BatchSizefor the rest.
4. @EntityGraph — Declarative FETCH
@EntityGraph lets you declare “load this association eagerly for this query” without writing JPQL. It gives the same effect as a FETCH JOIN for method-name or simple queries.
attributePaths
@EntityGraph(attributePaths = {"member"})
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdWithMember(@Param("id") Long id);
attributePaths = {"member"}: When loadingOrder, member is joined and loaded in the same query.- With a derived query:
@EntityGraph(attributePaths = {"member", "orderItems"})
List<Order> findByMemberId(Long memberId);
- The SELECT generated for
findByMemberIdwill include member and orderItems via JOIN FETCH.
Nested associations
@EntityGraph(attributePaths = {"member", "orderItems.product"})
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdWithMemberAndItemProducts(@Param("id") Long id);
orderItems.product: LoadsOrderItemand its Product in one go.
5. JOIN with ON conditions
Use the ON clause when you need extra join conditions(JPA 2.1+).
@Query("SELECT o FROM Order o LEFT JOIN o.member m ON m.status = 'ACTIVE' WHERE o.createdAt >= :since")
List<Order> findOrdersWithActiveMemberSince(@Param("since") LocalDateTime since);
- The mapping usually provides the default FK condition; ON adds conditions like member status or date ranges.
6. Pagination and JOIN FETCH — Pitfalls
Using Pageable with a query that uses JOIN FETCH(especially on a collection) can trigger Hibernate ** warningsor ** in-memory pagination. ** Collection FETCHand ** paginationtogether are limited.
Recommended patterns
- No FETCH in the paged query; use @BatchSize or a separate query to load associations:
Page<Order> findByMemberId(Long memberId, Pageable pageable);
// In service, call findByIdWithItems(id) when needed - DTO projection: Select only the columns you need and page that query:
@Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.orderNo, m.name) " +
"FROM Order o JOIN o.member m WHERE o.member.id = :memberId")
Page<OrderSummaryDto> findOrderSummariesByMember(@Param("memberId") Long memberId, Pageable pageable); - If you really need collection FETCH, consider ** no pagination**(e.g. list + application-level slice or cursor).
7. DTO projection with JOIN
For API responses you often want a DTO with a subset of fields instead of full entities. JPQL constructor expression lets you map JOIN results directly into a DTO.
public record OrderSummaryDto(Long orderId, String orderNo, String memberName) {}
@Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.orderNo, m.name) " +
"FROM Order o JOIN o.member m WHERE o.member.id = :memberId")
List<OrderSummaryDto> findOrderSummariesByMemberId(@Param("memberId") Long memberId);
new package.Class(constructor args): Passes the join result into the DTO constructor.- Benefits: No need to load full entities; only required columns are selected, reducing memory and query cost. Works well with pagination.
8. Native query with JOIN (reference)
Use nativeQuery = true only when you need DB-specific functions or syntax. Prefer JPQL and DTO projection for maintainability.
@Query(value = "SELECT o.*, m.name as member_name FROM orders o " +
"LEFT JOIN members m ON o.member_id = m.id WHERE o.id = :id",
nativeQuery = true)
Optional<Object[]> findOrderWithMemberNameNative(@Param("id") Long id);
- Return type becomes awkward; often used with Projection interfaces or SqlResultSetMapping.
9. Summary
| Goal | Recommended approach | Note |
|---|---|---|
| Fix N+1 (single association) | JOIN FETCH or @EntityGraph(attributePaths = {"member"}) | Load in one query |
| Fix N+1 (collection) | JOIN FETCH + DISTINCT or @BatchSize | Watch Cartesian size |
| Pagination + related data | DTO projection + normal JOIN, or separate batch load | Avoid collection FETCH + pagination |
| Minimal response data | SELECT new ... DTO(...) FROM ... JOIN ... | Less entity load and exposure |
| Extra join condition | LEFT JOIN o.member m ON m.status = 'ACTIVE' | Use ON clause |
In practice: use @EntityGraph for a single association; use @Query with JPQL JOIN and constructor projection when conditions are complex or you need DTOs directly. For dynamic conditions and complex search, see 10.7 Querydsl for type-safe joins and projections.