1. Choosing a tool is part of design
When a developer first learns about JOIN FETCH, there is usually an emotional scene: “Oh! So you can get rid of N+1 with a single query!” And then production arrives (or just a large dataset), and it turns out that one query can be so bloated that it needs a separate gym membership. That is why, at this stage, it is important to switch from “I like JOIN FETCH” to “what price am I paying for the shape of the SQL and the shape of the data in memory?”
In Hibernate, fetching is always about trade-offs. You almost never have “the best possible way” for all reads. There is reading a product card, where you want to load ProductDetails in advance, and there is a list of orders, where you want to show 20 table rows and not accidentally pull half the database “just in case”. And yes, unfortunately, Hibernate does not read your mind. You need to either explicitly say “do a join,” or provide a graph, or configure batch fetching — and then be sure to check what happened.
In short, today’s lecture is about moving from “I know three tools” to “I can make a decision and defend it in code review.”
2. Read-case before choosing fetching
It is very tempting to start with the tool: “Okay, where is @EntityGraph, where do I attach it?” But fetching does not work that way. In mature persistence code, you first define the read-case (what we read and why), and only then choose the mechanism. It is like choosing transport: if you need to move a sofa, you do not choose between “scooter or bicycle” — you first admit that you have a sofa. In ORM, the role of the “sofa” is played by collections, pagination, and the number of root entities.
To keep this from being abstract, it is useful to keep a few questions in mind. I usually say them out loud (yes, colleagues sometimes look at me strangely, but at least then SQL does not look at me strangely).
Here is a compact table of “question → why it matters → what is usually chosen”:
| Question for the read-case | Why this solves half the problem | What usually “pulls” by tool choice |
|---|---|---|
| Are we reading one entity (detail) or a list (list)? | For a single entity, you can afford an expensive SQL join; for a list, it is easy to get an inflated result set | detail: JOIN FETCH / EntityGraph; list: more often @BatchSize or a very careful join |
| Do we need to-one relations (for example, order.customer) or to-many (order.items)? | to-one joins are usually more predictable; to-many almost always changes the number of result rows | to-one: JOIN FETCH and graphs are often ok; to-many: be careful with JOIN FETCH, more often @BatchSize or a targeted detail read |
| Do we need pagination (setFirstResult/setMaxResults, Pageable) on the root entities specifically? | Pagination cuts result rows, and a join on a collection multiplies rows — we get a “page of fragments” | for page + to-many: avoid collection JOIN FETCH, look toward batch fetching |
| Is the set of relations that need to be loaded stable or does it change from scenario to scenario? | If the set changes, rewriting JPQL for every variation hurts, and graphs give control | changes: EntityGraph; stable: JOIN FETCH is often simpler |
Notice that there is no question about which tool is trendier. That is a good sign: you are no longer at the level of “ORM religion,” but at the level of “engineering constraints.”
3. Three fetching comparison metrics
There is a tricky trap in fetching decisions: you see “1 SQL instead of 21 SQL” and feel victorious. But sometimes it is a victory in the style of “I threw out the trash together with the refrigerator” — formally the problem is solved, but something went wrong. That is why we always compare not by one number, but by at least three.
The first metric is number of queries. This is the easiest to see in SQL trace and Hibernate statistics. The second metric is shape of the result: how many rows and what kind of joins were produced on the database side. This is exactly where JOIN FETCH on collections can “blow up” the result set. The third metric is the readability of the solution in code: will you be able to understand in a week why there is a graph here rather than a join, and not break everything with a “small refactor”.
To make it simpler, let us put “how to measure” into a table:
| Metric | How to see it | What is suspicious |
|---|---|---|
| Number of SQL statements | SQL trace, query count, statistics | “20 orders on a list took 41 queries” — classic N+1 |
| Result size/shape | SQL shape, understanding joins, sometimes EXPLAIN (but later) | one query that returns thousands of rows because of a join on a collection |
| Fetch-plan clarity | repository/service code, locality of the solution | “a global graph for the whole application,” @BatchSize on every field “just in case” |
And an important practical nuance: if you changed fetching and did not look at the SQL, then you did not optimize — you guessed from coffee grounds. Coffee is tasty, but the query planner does not react to it.
4. JOIN FETCH: ideal and dangerous
JOIN FETCH wins when you have a detail read and you know exactly which relations are needed right now. A card for a single order, where you need to get customer and items immediately, is a typical case: the query explicitly describes the fetch-plan, associations are loaded within the same transaction, and the service does not assemble data in pieces. For such a scenario, a normal project-level pattern is select distinct o ... join fetch o.customer left join fetch o.items where o.id = :id.
Its strength is directness: open the query and you immediately see what will be loaded. No hope of accidental lazy behavior, no hunt for secondary SELECTs after the entity is returned.
Problems begin when the same technique is applied to lists without discrimination. As soon as a collection enters the fetch-plan, the join starts multiplying result rows, and with that come DISTINCT, heavy result sets, broken pagination, and the risk of accidentally trimming a collection with a filter. That is why JOIN FETCH is good where the graph size is known in advance and controlled. If the read-case sounds like “one root + a few needed relations right now,” this is a strong candidate for JOIN FETCH. If it sounds like “a list,” page, several collections, or conditions on children — you need to slow down.
5. EntityGraph: controlling the fetch-plan
EntityGraph is useful when the read condition stays the same, but the fetch-plan varies. For example, an order by orderNumber is read the same way, but in one scenario only customer is needed, and in another, items as well. If you rewrite JPQL every time, the queries start multiplying around the same filtering logic.
With a graph, you separate two questions: which rows to select and which relations to initialize in advance. In Spring Data this is especially nice: the fetch-plan is visible next to the method, rather than hidden inside a set of almost identical queries.
But EntityGraph is not magically safer than JOIN FETCH. If the graph includes to-many, the physics of reading does not disappear: the provider still pays the price of collection loading, and the size of the result set and pagination remain sensitive points. The advantage of a graph is in the controllability of the fetch-plan, not in canceling SQL math.
6. @BatchSize: fewer queries instead of one
@BatchSize is needed in another situation. LAZY is fine, but secondary SELECTs come one by one and degrade into N+1 — both on collections and on to-one associations. In that case, we do not rewrite the root query into a big join; instead, we ask Hibernate to load such relations in batches through IN (...).
On a paged list, this is often more honest than a collection JOIN FETCH: first you neatly get 20 root orders, and then items or product for those objects are pulled in 1–2 grouped queries instead of twenty. The formula is familiar: not 1 + N, but 1 + ceil(N / batchSize).
The cost is also clear: SQL still comes in several statements, the batch size needs to be chosen based on the real access pattern, and the setting lives at the mapping level and can affect different scenarios. Therefore, @BatchSize is not a replacement for the fetch-plan, but a compromise for a lazy model when a join is already too expensive.
7. A quick mini-framework for choosing
After all the tools, you want a “universal recipe.” But the honest recipe sounds like this: there is no universal one. However, there is a sequence of thoughts that almost always leads to a reasonable decision.
I suggest thinking like this: first you define the shape of the read, then the risk of result inflation, then the variability of the fetch-plan, and only then do you choose the tool.
Below is a simplified “decision tree” in diagram form. It does not replace your brain, but it is a good reminder of which branches even exist:
flowchart TD
A[Defined the read-case] --> B{Reading one
entity?}
B -->|Yes| C{Need relations
in advance?}
C -->|Yes| D[JOIN FETCH or EntityGraph]
C -->|No| E[Leave LAZY
and do not touch fetching]
B -->|No, list| F{Is there pagination
by the root?}
F -->|Yes| G{Need a to-many
collection?}
G -->|Yes| H["@BatchSize or a careful plan without collection JOIN FETCH"]
G -->|No, only to-one| I[JOIN FETCH to-one or EntityGraph]
F -->|No| J{Is the list small
and controlled?}
J -->|Yes| K[You can use JOIN FETCH, but check the result size]
J -->|No| H
Now, how do you turn this scheme into a practical habit? At Commerce Persistence Lab I would phrase it like this:
Imagine that you need a detail read of a single order, and the UI really shows line items. Here JOIN FETCH or EntityGraph usually wins, because it gives “one understandable SQL” and guarantees that everything is loaded within the transaction (we have open-in-view=false, remember). You choose between them by controllability: if the query is simple and you want the most explicit SQL possible, choose JOIN FETCH. If there are several such detail-read variants and you do not want to multiply queries, EntityGraph lets you keep the filter stable while changing only the fetch-plan.
Now imagine a list of orders in an admin screen with 20 rows and pagination. If you try to pull items through fetch join, you will either break pagination, or get an inflated result set, or both (yes, that happens too; those are not mutually exclusive joys). In that place, batch fetching looks like a compromise: the root query returns exactly 20 orders, and items are loaded in batches if they are actually needed.
And finally, the main “maturity test” of the solution: after choosing the tool, you should be able to answer three questions honestly. How many SQL queries does this result in? How many rows come back in the heaviest query? And can you tell from the code immediately which relations must be loaded? If the answer to any of these questions is “uh... seems fine,” that is a signal to open the SQL trace and stop guessing.
And this is where another important boundary appears. All three tools argue only about how to load an entity graph. If the read needs not a managed entity with relations, but simply a list of fields for a table, report, or API response, the right question is different: not “which fetch-plan is better,” but “why pull the managed graph at all.”
8. Typical mistakes when choosing fetching
Now is a good time to talk about where beginners (and sometimes not only beginners) usually step on rakes. These rakes are especially tricky because the code often “works,” and the problem shows up not as an exception, but as sudden slowness and a strange number of queries.
Mistake #1: choosing the tool before the read-case is described.
If you first decide “it will be JOIN FETCH” and then force everything else to fit it, you will inevitably start doing the wrong things: fetch joining collections in a paginated list, giant graphs “for everything,” @BatchSize on every relation “just in case it becomes useful.” The normal order is the opposite: first the question “what exactly should the user/service see,” then the choice of loading shape.
Mistake #2: treating “one SQL” as an automatic win.
Fetch join can indeed remove N+1, but sometimes it replaces “many small queries” with “one huge query with a million rows because of a collection join.” As a result, you win on round-trips but lose on result processing and data volume. If you did not look at the SQL shape and did not think about the number of rows, you are evaluating the decision by one metric out of three.
Mistake #3: using JOIN FETCH on collections in paged lists.
This is a classic source of surprises: limits are applied to the rows of the joined result, not to unique root entities. The result may look like “asked for 20 orders, got 7” or “got 20, but some are repeated” — and then the mystical hunt for UI bugs begins, although the bug is actually in the fetch-plan.
Mistake #4: making one “universal” EntityGraph that pulls everything.
That is almost the same as setting EAGER “everywhere,” only in a more sophisticated way. A graph should be scenario-specific: small, concrete, and tied to one read. Otherwise you will again start materializing unnecessary relations and wondering why the list “suddenly got heavier,” even though the code seems untouched.
Mistake #5: expecting @BatchSize to turn everything into one query.
Batch fetching is not “a join in different packaging.” It is a different compromise: several queries, but fewer than N+1. If you expect “after @BatchSize there should still be only one SQL,” you will be disappointed and start tweaking the solution without understanding it. With batch fetching, the success criterion is different: there are fewer secondary queries, and they are grouped better.
GO TO FULL VERSION