A fetch plan determines which related entity state a query loads, and lazy relationship traversal can issue extra statements after the root query.
Spring JPA fetch plans: measure N+1 queries before changing mappings
The downloadable Spring source kit pins Java 21 and Spring Boot 4.0.8 with its managed dependencies. Run mvn test to check the named fixture.
Count statements in a fresh context
The fixture stores three stock lines, each linked to a distinct account. One query loads the lines, then reading each account’s available count produces three extra statements. The assertion checks four prepared statements after clearing Hibernate statistics. This makes the cost visible instead of inferring it from a method name.
Read account state through the getter: direct public-field access on a lazy proxy does not trigger its loading method. The test checks that distinction before traversal. A second test uses a new context and a join fetch for the single-valued account association. The same total, 300, now requires one statement. The fresh context matters: reusing already-loaded account objects could hide the extra queries and make an inefficient traversal appear cheap.
Fetch only what the operation needs
Switching every relationship to eager loading can move extra work to requests that never need it. Choose a query for the actual response contract, or return a projection that contains just the required fields. Query count is one measure; row width, transferred bytes and execution plans still matter.
This example fetches a many-to-one association. Fetching a collection can multiply result rows, and applying page limits to such a join needs separate reasoning. Offset bounds do not prove collection fetch pagination is correct. The lesson does not claim that its single statement is the best plan for every database.
Checked source
var lines = manager.createQuery(
"select line from StockLine line join fetch line.account order by line.id",
JpaContracts.StockLine.class).getResultList();
int total = 0;
for (var line : lines) total += line.account.getAvailable();
assertEquals(300, total);
assertEquals(1, kit.statistics().getPrepareStatementCount());Test the boundary
JpaBoundaryTest.lazyTraversalCountsOneQueryPerDistinctAccount and fetchJoinLoadsAccountsInOneStatement checks this contract in the source kit. Excerpts belong to the named classes; use the downloadable files for imports, configuration and assertions.
Costs and boundaries
The checked traversal reads three lines and three accounts. The lazy path has 1+n statements for n distinct unloaded accounts in this mapping; the fetch path has one statement but still transfers and retains the required rows. Both counts are isolated local measurements, not latency benchmarks.
Common Mistakes
- Clear provider statistics and use a fresh context before comparing query counts.
- Do not make every relationship eager to fix one request.
- Do not apply collection-fetch pagination rules to a single-valued join blindly.
Read next
Spring JPA entity lifecycle: managed changes and detached objects, Spring API pagination: bounded requests and immutable snapshots, Spring JPA optimistic locking: reject a stale stock update, Java JMH benchmarks: consumed results, fixtures and limited measurements.
Extend the tested workflow
Continue with Spring Data JPA projections: return selected fields without a full entity.
