JPQL Joins & Fetch Joins
JPQL Joins & Fetch Joins
In SQL you join tables. In JPQL you join entity relationships. The shift is subtle but profound: instead of naming columns and foreign keys you navigate the object graph that JPA already knows about, and Hibernate translates that navigation into whatever SQL the underlying database needs. This lesson covers the full join vocabulary of JPQL — inner, outer, implicit, explicit, and the critically important fetch join — and explains the performance trade-offs every production developer must understand.
The Domain Model
All examples in this lesson use a small e-commerce model. An Order has many OrderItems, each linked to a Product; an Order also belongs to one Customer.
Notice that every @ManyToOne and @OneToMany uses FetchType.LAZY. That is the right default for production — associations are only loaded when explicitly accessed. It is also what makes understanding joins so important: lazy loading without a proper join leads directly to the N+1 problem.
Implicit vs Explicit Joins
JPQL supports two syntactic styles for joining related entities.
Implicit join — dot-notation path traversal. Hibernate generates the required SQL JOIN automatically:
Explicit join — the JOIN keyword with an alias, exactly like SQL:
Both produce the same SQL INNER JOIN. Prefer explicit joins as soon as you need to reference the joined entity more than once in the query — it avoids redundant path traversals and makes the intent clear.
Inner Join, Left Join, and Cross Join
JPQL mirrors standard SQL join types, but targets relationships rather than tables:
JOIN/INNER JOIN— returns only rows where the relationship exists (non-null FK, non-empty collection).LEFT JOIN/LEFT OUTER JOIN— returns the owning entity even when the related entity is absent (NULL on the right side).CROSS JOIN(rarely needed) — Cartesian product of two entity ranges.
DISTINCT to the SQL, and it de-duplicates the Java result list. Without it, a Customer with three orders would appear three times in the list because the SQL produces three rows.
The N+1 Problem — Why Fetch Joins Exist
Consider loading 50 orders and then printing each order's customer name:
This fires 1 query to fetch the orders plus 50 additional queries to fetch each customer one by one — 51 round trips in total. With hundreds of rows this becomes a serious bottleneck. This is the classic N+1 select problem.
FETCH JOIN — Loading the Graph in One Query
A fetch join tells Hibernate to load the associated entity eagerly in the same SQL statement, overriding the lazy default for this specific query:
The generated SQL is roughly:
One query instead of 51. The trade-off is a wider result set — more columns per row — but that is almost always preferable to dozens of extra round trips.
LEFT JOIN FETCH for Nullable Associations
If the association might be null (e.g., an order can exist without a customer in your domain), use LEFT JOIN FETCH so orders without a customer are still returned:
Fetch Joining a Collection — The Duplicate Row Trap
Fetching a @OneToMany collection inflates the result at the SQL level. A single order with four items produces four SQL rows, all pointing at the same Order object. Without DISTINCT, your Java list will contain four copies of that order:
LIMIT/OFFSET) to a query that fetch-joins a collection, because the row count at the database layer does not equal the entity count. Hibernate falls back to fetching ALL rows into memory and then paginating there. For paginated list endpoints, fetch the IDs first with a plain query and pagination, then load the full graph in a second query using WHERE o.id IN :ids.
Chained Fetch Joins
You can fetch multiple associations in one query. Here we load orders with their items, and also initialise each item's product:
o.items and o.tags) creates a Cartesian product of both collections. Use @BatchSize or separate queries for the second collection.
Using Fetch Joins in a Spring Data Repository
Spring Data JPA lets you place JPQL queries directly on repository methods with @Query:
The separate countQuery is required when the main query contains a fetch join — Spring Data cannot derive the count query automatically from a fetch-join query.
Summary
JPQL joins follow the same semantic rules as SQL but operate on the object graph. Use explicit JOIN / LEFT JOIN when you need to filter or sort by a related entity. Use JOIN FETCH / LEFT JOIN FETCH when you need to initialise an association and avoid N+1 selects. Always add DISTINCT when fetch-joining a collection, and avoid mixing pagination with collection fetch joins. The next lesson covers JPQL aggregation functions and grouping.