Choosing the Right Solution for LazyInitializationException
Alejandro Alonso Noguerales
Apr 13, 2026
In Part 1 we disabled open-in-view and fixed a silent deadlock. The application stopped hanging. Good.
What came next was less dramatic but longer: LazyInitializationException everywhere. Every entity with a lazy relation accessed outside a transaction blew up. And in an API with dozens of entities and relations at every level, that meant touching a lot of code.
The easy way out would have been to slap @Transactional on every service and move on to the next ticket. But we wanted to understand what we were doing. So we built a laboratory with Hibernate Statistics on real PostgreSQL to measure each strategy before choosing. All tests in the lab pass green and back every number shown here.
01 — First instinct: @EntityGraph on everything
The first LazyInitializationException hit us on Store. It has 3 ManyToOne relations: storeType, region, and timezone. The mapper needs them to build the DTO.
The fix was clean:
@EntityGraph(attributePaths = {"storeType", "region", "timezone"})
List<Store> findAllWithAllRelationsBy();
Hibernate generates a single SELECT with LEFT JOIN. 1 query, 10 rows, no fuss. The service doesn’t need @Transactional at all. We liked it.
And to be fair, not everything is problems with @EntityGraph. If the entity has few ManyToOne relations and at most one collection, it works perfectly: in the lab, Store with 3 ManyToOne + 1 storeEmployees resolves in 1 query, no cartesian product, no exceptions. The happy path exists and is quite common.
So we did the same with Department. 6 collections: employees, projects, budgets, equipment, policies, documents. We added the @EntityGraph with all the attributePaths and…
MultipleBagFetchException: cannot simultaneously fetch multiple bags
Hibernate can’t fetch multiple List<> in a single query. First wall.
The StackOverflow workaround that isn’t one
The first thing you find googling that error is “change List to Set”. We tried it. The exception disappeared. Tests passed. All good.
Until we looked at the SQL. With 3 Set collections of 5 items each:
5 × 5 × 5 = 125 rows transferred for 15 actual items
A silent cartesian product. Hibernate deduplicates in memory, so the result looks correct. But with production data — 50 items per collection — that would be 125,000 rows for 150 items. And nobody would notice until the DBA asks why the database is suffering.
What about pagination?
We also tried combining @EntityGraph with Pageable on entities with collections. Hibernate throws a warning (HHH90003004) and paginates in memory: loads ALL rows and trims in Java. The SQL has no LIMIT/OFFSET. With 100K records, this kills the application without warning.
It became clear: @EntityGraph is perfect for simple entities with few ManyToOne relations and at most one collection. For anything with multiple collections, we needed something else.
02 — The fallback: @Transactional(readOnly = true)
For Department and entities with deep relation trees, the solution was wrapping the service method:
@Transactional(readOnly = true)
public List<StoreDto> getAllStores() {
return storeRepository.findAll().stream()
.map(storeMapper::toDto)
.toList();
}
The Hibernate session stays open for the entire method. The mapper can traverse any chain of relations without blowing up. It worked with everything. But we were worried about N+1.
The N+1 isn’t what we thought
We expected 10 stores with 3 ManyToOne to generate 31 queries (1 + 3×10). But when we measured it we found something we hadn’t seen in any article: the N+1 depends on the cardinality of referenced entities, not the number of parents.
| Scenario | Queries |
|---|---|
| 10 stores, sharing 2 types, 2 regions, 2 tz | 7 |
| 10 stores, each with unique refs | 31 |
Hibernate fires 1 query per distinct referenced entity. If 10 stores point to the same 2 StoreType values, it only loads those 2 — not 10. In production, where catalogs are small shared tables, the actual N+1 is much lower than the theoretical one. But if each entity references something unique (like createdBy pointing to different users), it hits with full force.
Why readOnly=true and not just @Transactional
The readOnly=true is not decorative. Hibernate sets FlushMode.MANUAL, which means if you accidentally modify an entity inside the method, it doesn’t get persisted. It can also skip state snapshots — less memory per entity. We verified it in the lab: modifying stores without save() in a readOnly=true transaction persists nothing. Without readOnly, dirty checking detects the change and persists it on commit.
03 — The line that changed everything: batch_fetch_size=16
We had @EntityGraph for simple entities and @Transactional(readOnly=true) for complex ones. But the N+1 numbers in some cases were still ugly. Then we found this:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 16
One line. Instead of Hibernate firing 1 query per uninitialized proxy, it groups up to 16 of the same type into a single query with WHERE id IN (?, ?, ..., ?).
The lab results were brutal:
| Scenario | Without batch | With batch=16 |
|---|---|---|
| 10 stores, unique refs | 31 | 4 |
| 25 departments, 6 collections | 152 | 14 |
| 5,000 departments, 6 collections | 30,002 | 1,880 |
5,000 departments without batch: 30,002 queries in 64 seconds. With batch: 1,880 queries in 8 seconds.
No code changes. No cartesian product. Improves the entire project at once. If you can only do one thing from this entire list, it’s this. It should be the baseline for every Hibernate project and we don’t understand why it’s not enabled by default.
If you need finer control, you can also put @BatchSize(size = 16) directly on a specific collection — same effect, but granular. Note for Hibernate 7: @BatchSize only works on @OneToMany collections, not on @ManyToOne. For that you need the global default_batch_fetch_size.
04 — @Fetch(SUBSELECT): the one that scales best with collections
The batch_fetch_size was working well for us, but when scaling the Department tests to many records we noticed something: with 100 departments, batch was still doing 44 queries (the chunks of 16 kept adding up). Looking for alternatives we found @Fetch(FetchMode.SUBSELECT):
@OneToMany
@JoinColumn(name = "department_id")
@Fetch(FetchMode.SUBSELECT)
private List<Employee> employees = new ArrayList<>();
Instead of grouping in chunks of 16, SUBSELECT loads ALL collections of a type in 1 single query using a subquery that repeats the original query:
SELECT * FROM employees WHERE department_id IN (SELECT id FROM departments)
The numbers made it clear that for findAll with collections, this was the best option:
| Departments | SUBSELECT | @BatchSize(16) | Nothing |
|---|---|---|---|
| 25 | 7 queries, 11ms | 13 queries, 26ms | 152 queries, 96ms |
| 100 | 7 queries, 15ms | 43 queries, 49ms | 602 queries, 336ms |
| 500 | 7 queries, 53ms | 193 queries, 210ms | 3,002 queries, 2,121ms |
| 1,000 | 7 queries, 84ms | 379 queries, 436ms | 6,002 queries, 4,214ms |
| 5,000 | 7 queries, 439ms | 1,879 queries, 3,696ms | 30,002 queries, 45,603ms |
At 5,000 departments: SUBSELECT does 7 queries in 439ms vs 30,002 queries in 45.6 seconds without batch — 100x faster. Even vs @BatchSize(16), SUBSELECT is 8x faster (439ms vs 3.7s) with 270x fewer queries. It doesn’t grow with N. It’s always 1 base query + 1 per collection type.
The trade-off: it repeats the original query as a subquery, so if your base query is complex or expensive, it might not be ideal. And it’s not configurable globally — it’s an annotation per collection. But for entities with multiple collections where you load many parents at once, it’s the best option we found.
05 — Split Queries for a single parent
SUBSELECT solved the findAll with collections case. But for loading a single Department by ID with its 6 collections, the pattern recommended by Vlad Mihalcea was still cleaner: one JOIN FETCH query per collection, within the same transaction.
@Transactional(readOnly = true)
public DepartmentDto getDepartmentSplitQueries(Long id) {
Department dept = departmentRepository.findWithEmployeesById(id).orElseThrow();
departmentRepository.findWithProjectsById(id); // L1 cache merge
departmentRepository.findWithBudgetsById(id); // L1 cache merge
departmentRepository.findWithEquipmentById(id); // L1 cache merge
departmentRepository.findWithPoliciesById(id); // L1 cache merge
departmentRepository.findWithDocumentsById(id); // L1 cache merge
return departmentMapper.toDto(dept);
}
The trick is that all queries run within the same EntityManager. The L1 cache merges the results: when the second query loads the same Department with its projects, Hibernate updates the entity it already had in cache. By the end, dept has all its collections loaded.
7 queries. No cartesian product. No uncontrolled N+1. And those 7 queries stay constant from 5 to 5,000 items per collection — what grows is data volume, not round-trips.
More code than the other options, yes. But for loading a specific entity with multiple large collections, it’s the most controlled solution.
06 — DTO Projection: for when you don’t need the entity
While reviewing the services, we realized many endpoints only read data to return it as JSON. They didn’t modify anything. They didn’t need the managed entity. For those cases:
@Transactional(readOnly = true)
public List<StoreProjection> getAllStoresProjection() {
return entityManager.createQuery(
"SELECT new com.example.osivlab.dto.StoreProjection(" +
"s.id, s.name, s.address, st.name, r.name, tz.zoneId) " +
"FROM Store s " +
"LEFT JOIN s.storeType st " +
"LEFT JOIN s.region r " +
"LEFT JOIN s.timezone tz",
StoreProjection.class)
.getResultList();
}
No entities loaded. No persistence context, no dirty checking, no lazy loading. 1 query returning exactly the fields you need mapped to a record.
The memory difference is real
We measured consumption with 100K stores, all solutions head-to-head:
| Solution | Queries | Time | Memory |
|---|---|---|---|
| JdbcClient | 1 | 137ms | 56 MB |
| DTO Projection | 1 | 127ms | 59 MB |
| @Transactional | 7 | 242ms | 108 MB |
| @EntityGraph | 1 | 369ms | 118 MB |
| Interface Projection | 1 | 277ms | 130 MB |
DTO Projection and JdbcClient are the most efficient: 59 MB and 56 MB vs 108-130 MB for the alternatives. The managed entity overhead comes from the persistence context plus a snapshot copy of every field for dirty checking. At 1M records that would be ~600 MB of overhead just from using entities instead of DTOs.
The surprise was Interface Projection: even though it fires 1 single query and doesn’t load entities into the persistence context, it consumes 130 MB — more than @EntityGraph (118 MB). Spring Data generates dynamic proxies (java.lang.reflect.Proxy) with a Map<String, Object> per instance, which is heavier than a plain record. If memory matters, use DTO Projection with record instead of Interface Projection.
// DTO Projection — plain record, 59 MB at 100K
record StoreProjection(Long id, String name, String address,
String storeTypeName, String regionName, String timezoneZoneId) {}
// Interface Projection — dynamic proxy, 130 MB at 100K
public interface StoreView {
Long getId();
String getName();
String getStoreTypeName();
}
07 — The numbers at scale
Measuring with 10 records is one thing. Seeing what happens when you hit production volumes is another. We scaled the tests from 100 to 1M records:
Queries
| Records | @EntityGraph | @Transactional (shared refs) | @Transactional (unique refs) | batch=16 (unique) | DTO Projection |
|---|---|---|---|---|---|
| 100 | 1 | 7 | 301 | 22 | 1 |
| 1K | 1 | 7 | 3,001 | 190 | 1 |
| 10K | 1 | 7 | 30,001 | 1,876 | 1 |
| 100K | 1 | 7 | 300,001 | 18,751 | 1 |
| 1M | 1 | 7 | --- | --- | 1 |
@EntityGraph and DTO Projection stay at 1 query up to 1M. @Transactional with shared refs — the typical production case with catalogs — stays at 7 constant queries regardless of volume. With unique refs and no batch, it becomes impractical past 10K.
Timing (ms)
| Records | @EntityGraph | DTO Projection | @Transactional (shared) | batch=16 (shared) |
|---|---|---|---|---|
| 1K | 206 | 109 | 96 | 35 |
| 10K | 136 | 26 | 91 | 35 |
| 100K | 434 | 122 | 366 | 459 |
| 500K | 2,646 | 1,551 | 1,590 | 1,892 |
| 1M | 5,509 | 3,276 | 3,153 | 4,655 |
| 10M | OOM | OOM | OOM | OOM |
At 10M records, all solutions blow up with OutOfMemoryError (4GB heap). No findAll()-based solution is designed for millions of objects in a List<>. At that volume there are no shortcuts: pagination or streaming.
DTO Projection is consistently the fastest: at 1M records, 3.2 seconds vs 5.5s for @EntityGraph. The difference is all the persistence context overhead you skip — the 118 MB vs 59 MB we measured at 100K, multiplied by 10.
Benchmark limitations
These numbers are useful for comparing solutions against each other, not for predicting production times:
- Single-thread — all tests run on 1 thread. In production there are N concurrent threads competing for pool connections. Solutions that hold connections longer (
@Transactional) suffer more under load. - Testcontainers (Docker) — the database runs locally, network latency = 0. In production, network latency amplifies the difference between 1 query and 30,000 queries.
- Synthetic uniform data —
generate_seriesproduces perfectly distributed data. In production there’s skew (some departments with 5 employees, others with 5,000). This especially affects batch_fetch and SUBSELECT. - No L2 cache — no Ehcache or Caffeine configured. With L2 cache, batch_fetch and
@Transactionalcan be much faster because referenced entities are already cached. - PostgreSQL 16 only — query counts are universal, but timings are PostgreSQL-specific. MySQL doesn’t support SUBSELECT the same way, Oracle has different batch fetching optimizations.
What IS reliable: query counts are exact and environment-independent, scaling formulas (1+7N, 1+ceil(N/16)*7, constant 7) are universal, the memory ratio between solutions holds steady, and the failures (MultipleBagFetchException, OOM, in-memory pagination) are reproducible in any environment.
08 — What we found in writes
Everything up to this point was about reads. But when auditing the writes we found things we didn’t expect.
Dirty checking that masks bugs
We already told the story in Part 1 about the service that changed a field through the generic update pipeline, with a mapper that ignored it. With open-in-view=true it “worked” via dirty checking. With open-in-view=false the change was lost.
But the lesson goes beyond that specific bug: with OSIV enabled, any entity that gets accidentally modified gets persisted without anyone calling save(). The readOnly=true on read transactions prevents this. And for writes, the rule is simple: always explicit save() or saveAndFlush(). Never rely on “Hibernate will figure it out”.
For entities you know you’ll never modify — catalogs, views, historical data — Hibernate offers @Immutable. It marks the entity as read-only at the Hibernate level: zero dirty checking, no snapshots, modifications are silently ignored. It’s the lightest you can get without leaving JPA.
jdbc.batch_size doesn’t work with IDENTITY
We also discovered that setting hibernate.jdbc.batch_size=50 does not reduce statements when using @GeneratedValue(strategy = GenerationType.IDENTITY) — which is what we used throughout the project. Hibernate needs the generated ID back immediately after each INSERT, so it can’t batch them. Not only does it not help — with cascade persist, batch=50 is actually slower than no batch because it adds coordination overhead with no real benefit.
Bulk insert with median of 5 runs after 2 warmup:
| Volume | Without batch | With batch=50 |
|---|---|---|
| 1K orders | 953ms | 975ms |
| 5K orders | 4,738ms | 4,718ms |
Virtually identical times. For batching to actually work you need GenerationType.SEQUENCE with @SequenceGenerator(allocationSize = 50).
We also tried StatelessSession — Hibernate’s API without persistence context. With IDENTITY the difference is virtually zero (982ms vs 1,007ms at 1K orders) because the bottleneck is the round-trip per INSERT, not the persistence context. StatelessSession shines with SEQUENCE where it can pre-allocate IDs and batch inserts.
09 — Is this a JPA/Hibernate problem?
This was the question we asked ourselves at the end of the whole process. And the short answer is: yes. The OSIV problem only exists because Hibernate manages the session and lazy loading for you. If you use JdbcClient, jOOQ, or MyBatis, none of this happens — no proxies, no persistent session, no lazy loading, no accidental dirty checking.
| Tool | Lazy loading? | OSIV applies? | N+1 possible? | Dirty checking? |
|---|---|---|---|---|
| JPA/Hibernate | Yes | Yes | Yes | Yes |
| JdbcClient | No | No | No | No |
| jOOQ | No | No | No | No |
| MyBatis | Configurable | No | Configurable | No |
| Native query via JPA | No | Session still open | No | Yes (if you touch entities) |
Watch out for native queries via JPA: even though the SQL is native, you’re still inside the EntityManager. The session stays open and if you touch entities, dirty checking is still active.
Out of curiosity, we measured the same query returning the same DTO with JPA and JdbcClient:
| Records | JPA DTO Projection | JdbcClient |
|---|---|---|
| 1K | 96ms | 9ms |
| 100K | 159ms | 93ms |
| 500K | 1,437ms | 1,430ms |
At low record counts, JPA has query parser overhead. At high volume, the bottleneck is data transfer and the difference fades. But @EntityGraph (full entities with persistence context) vs JdbcClient (minimal DTO) is a different story: between 2x and 18x slower due to dirty checking snapshots.
This doesn’t mean “abandon JPA”. Cascade, dirty checking for writes, L1 cache, Spring Data repositories — all of that has value when you need to write and manage complex relations. But for read-only high-traffic endpoints, knowing you have lighter alternatives is useful.
10 — The decision tree we ended up with
After this whole process, here’s the full picture of each solution and its trade-offs:
| Solution | N+1? | LazyInit? | Cartesian? | Real pagination? | Memory (100K) | Holds connection? | Effort |
|---|---|---|---|---|---|---|---|
open-in-view=true | Yes (silent) | Hides it | No | Yes | Held entire req | Yes (entire request) | 0 (default) |
@EntityGraph | No (1 query) | No | Yes (multiple cols) | In-memory with cols | 118 MB | No | Annotation |
@Transactional(readOnly) | Yes (depends on cardinality) | No | No | Yes | 108 MB | Yes (entire method) | Annotation |
batch_fetch_size=16 | Reduced 16x | No | No | Yes | ~108 MB | Yes | 1 line yml |
@Fetch(SUBSELECT) | No (7 constant q) | No | No | Yes | ~108 MB | Yes | Annotation/col |
| Split Queries | No (controlled) | No | No | Yes | ~108 MB | Yes | Manual code |
DTO Projection (record) | No | No | No | Yes | 59 MB | Yes (brief) | Query + record |
| Interface Projection | No | No | No | Yes | 130 MB | Yes (brief) | Interface + @Query |
| JdbcClient | No | N/A | No | Yes | 56 MB | No | Manual SQL |
And this is the path we followed to choose in each case:
0. ALWAYS: configure batch_fetch_size=16 as a global baseline
1. Is it a WRITE operation?
YES → @Transactional + explicit save()/saveAndFlush()
For batch inserts: use SEQUENCE, not IDENTITY
NO → (continue)
2. Do you need the JPA entity or just flat data?
JUST DATA → DTO Projection with record (1 query, 0 entities, half the memory)
Avoid Interface Projection (dynamic proxies, more memory than @EntityGraph)
NEED ENTITY → Is it read-only?
YES → Consider @Immutable (no dirty checking)
NO → (continue)
3. Does the entity have multiple collections (>1 List/Set)?
YES → Loading 1 parent or many?
1 PARENT → Split Queries (1 JOIN FETCH per collection)
MANY → @Fetch(FetchMode.SUBSELECT)
(7 constant queries regardless of N)
NEVER @EntityGraph here
NO → (continue)
4. How many ManyToOne relations does it have?
1-3 ManyToOne → Paginating over collections?
YES → @Transactional(readOnly=true) + batch_fetch
(@EntityGraph paginates in memory with collections)
NO → @EntityGraph (1 JOIN query)
Many or deep → @Transactional(readOnly=true) + batch_fetch
ALWAYS on read methods: @Transactional(readOnly=true)
11 — In summary
The first thing we did when all of this was over was add a line to application.yml that should have been there from day one:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 16
Then we went entity by entity. @EntityGraph for the simple ones with few ManyToOne relations. @Fetch(SUBSELECT) on collections loaded in listings. Split Queries for loading a specific entity with all its collections. DTO Projection with record for read-only listings. @Immutable on catalog entities. @Transactional(readOnly=true) on every read method. And explicit save() on every write — never again trusting dirty checking.
It wasn’t a one-day change. But every LazyInitializationException we fixed was a place where the application had been running extra queries without us knowing, or relying on an implicit mechanism that under load would have brought us down.
The Spring Boot warning had been trying to tell us for years. Now we have the numbers to justify it.
All the lab code with tests backing every number in this article is at open-in-view-lab.