How to Fix "failed to lazily initialize a collection" in Spring Data JPA and Hibernate
When you annotate a Spring / Hibernate entity with @OneToMany or @ManyToMany, reading the related records through a plain getter seems almost magical. Then one day the getter throws:
org.hibernate.LazyInitializationException: failed to lazily initialize a collection
This article explains why Hibernate throws this exception, how lazy vs. eager fetching actually works, and the two ways to fix it — the recommended @Transactional approach and the FetchType.EAGER alternative.
Why this happens
JPA lets you annotate entity fields with @OneToOne, @OneToMany, @ManyToOne, or @ManyToMany to tell the persistence layer that the field maps to a foreign key. That part is easy. The problem shows up when you try to traverse one of those relations at the wrong time.
Each mapping annotation has a default fetch type:
FetchType.EAGER— the related records are loaded in the same query as the parent entity.FetchType.LAZY— the related records are loaded only when you actually call the getter (a second query, using a proxy).
Lazy loading only works while the Hibernate session (and the database connection it owns) is still open. If you hold an entity reference and call the getter after the session has closed — typically outside the service / repository method where the entity was fetched — Hibernate has nowhere to run the second query, and it throws LazyInitializationException.
This usually happens when you return an entity from a DAO / repository and then access a lazy relation later in the controller, a template, or some helper that lives outside the original Spring bean.
Default fetch types by annotation:
| Annotation | Default fetch type |
|---|---|
@OneToOne | FetchType.EAGER |
@ManyToOne | FetchType.EAGER |
@OneToMany | FetchType.LAZY |
@ManyToMany | FetchType.LAZY |
So the exception almost always comes from @OneToMany or @ManyToMany — the two defaults that are lazy.
The fix: @Transactional
Annotate the method that accesses the lazy collection with @Transactional. Spring keeps a Hibernate session open for the entire method, so the getter’s second query runs successfully. If the method only reads data, mark it readOnly = true for a small performance win.
package com.example.blog;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BlogPostService {
@Autowired
private BlogPostRepository postRepository;
@Transactional(readOnly = true)
public void doSomething() {
postRepository.findById(...);
// Access lazy collections here — the session is still open.
}
}
One gotcha: @Transactional is implemented with a Spring proxy, so it only kicks in when the method is called from outside the class. If you call doSomething() from another method inside BlogPostService, the call bypasses the proxy and no transaction is started.
Alternative: FetchType.EAGER
You can also force eager loading on the entity side:
package com.example.blog;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.OneToMany;
import java.util.List;
@Entity
public class BlogPost {
@OneToMany(fetch = FetchType.EAGER)
private List<BlogTag> tags;
}
This sidesteps the exception because the related records are loaded in the initial query — the session being closed later no longer matters.
The cost is performance. Every time you load a BlogPost, Hibernate also pulls every associated BlogTag, even if the caller doesn’t need them. For small, always-accessed collections this is fine. For anything that can grow unbounded (comments, views, audit log entries), keep it lazy and fix the access pattern with @Transactional.
javax.* vs jakarta.*: the namespace change
Code in this article uses javax.persistence and javax.transaction, which matches the original article and Spring Boot 2.x projects. If you’re on Spring Boot 3.x or a recent Jakarta EE 9+ runtime, every import in this article has to be rewritten with jakarta instead of javax.
The short version of the history:
- Java EE → Jakarta EE. Oracle transferred Java EE to the Eclipse Foundation in 2017. For licensing reasons, the new foundation couldn’t keep the
javax.*package names. - Jakarta EE 9 (released September 2020) is the renaming release. All of the old
javax.*packages were mechanically renamed tojakarta.*— same APIs, new namespace. No functional changes. - Spring Boot 2.x stays on
javax.*(targets Jakarta EE 8 / Java EE 8, Java 8 baseline). - Spring Boot 3.x (released November 2022) moved to
jakarta.*and raised the baseline to Java 17 and Jakarta EE 9+.
So for most readers the practical boundary is Spring Boot 2 vs Spring Boot 3:
| Project baseline | Persistence / transaction imports |
|---|---|
| Spring Boot 2.x, older Spring / Jakarta EE 8 | javax.persistence.*, javax.transaction.* |
| Spring Boot 3.x, Jakarta EE 9+ | jakarta.persistence.*, jakarta.transaction.* |
The annotations, parameters, and semantics are identical in both namespaces. For most migrations it’s a search-and-replace from javax to jakarta.
Spring’s @Transactional vs JPA’s
Modern Spring recognizes both of these annotations and will start a transaction for either (substitute jakarta.transaction on Spring Boot 3.x):
javax.transaction.Transactionalorg.springframework.transaction.annotation.Transactional
Spring’s own version has extra attributes that the JPA one lacks. For example, readOnly = true used in the fix above only exists on Spring’s. Spring’s rollbackFor / noRollbackFor also accept class-name strings, while the JPA version only accepts Class<?> literals.
Attributes unique to Spring’s @Transactional:
boolean readOnlyint timeoutString timeoutStringString[] rollbackForClassNameString[] noRollbackForClassName
When in doubt, prefer org.springframework.transaction.annotation.Transactional in Spring codebases.