In Java Persistence API (JPA) and Hibernate, how and when related entities are loaded from the database is crucial for application performance. The FetchType strategy—specifically LAZY and EAGER loading—determines this behavior. Choosing the right fetch strategy is one of the most important decisions in JPA optimization, directly impacting your application's performance, memory usage, and efficiency.
This article explores the differences, use cases, and best practices for FetchType.LAZY and FetchType.EAGER in Java JPA applications.
Understanding the Fundamental Difference
FetchType.EAGER:
- Immediate loading of related entities
- Related data is loaded in the same database query or immediately after
- "Load everything now, you might need it later" approach
FetchType.LAZY:
- On-demand loading of related entities
- Related data is loaded only when explicitly accessed
- "Load only what you need, when you need it" approach
Default Fetch Types
JPA specifications define default fetch types for different relationships:
| Relationship Type | Default FetchType |
|---|---|
@OneToMany | LAZY |
@ManyToMany | LAZY |
@ManyToOne | EAGER |
@OneToOne | EAGER |
These defaults reflect common usage patterns, but can be overridden based on your specific needs.
Entity Examples and Code Demonstration
Let's examine both strategies with practical entity examples.
Entity Relationships:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
// EAGER by default for @OneToOne
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "profile_id")
private UserProfile profile;
// LAZY by default for @OneToMany
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
// EAGER by default for @ManyToOne
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "department_id")
private Department department;
// Constructors, getters, and setters
public User() {}
public User(String username, String email) {
this.username = username;
this.email = email;
}
// Getters and setters...
}
@Entity
@Table(name = "user_profiles")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String address;
@OneToOne(mappedBy = "profile")
private User user;
// Constructors, getters, and setters
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime orderDate;
private BigDecimal totalAmount;
@ManyToOne(fetch = FetchType.LAZY) // Override default EAGER
@JoinColumn(name = "user_id")
private User user;
// Constructors, getters, and setters
}
@Entity
@Table(name = "departments")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department")
private List<User> users = new ArrayList<>();
// Constructors, getters, and setters
}
Database Query Behavior
EAGER Loading Example:
// Repository method
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.id = :id")
User findUserWithEagerRelations(@Param("id") Long id);
}
// Service code - what happens when we call this?
User user = userRepository.findUserWithEagerRelations(1L);
// SQL Generated (simplified):
// SELECT u.*, p.*, d.* FROM users u
// LEFT JOIN user_profiles p ON u.profile_id = p.id
// LEFT JOIN departments d ON u.department_id = d.id
// WHERE u.id = 1
// The profile and department are loaded IMMEDIATELY
// even if we don't use them
System.out.println(user.getUsername()); // Already loaded
System.out.println(user.getProfile().getFirstName()); // Already loaded
LAZY Loading Example:
// Repository method
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.id = :id")
User findUserWithLazyRelations(@Param("id") Long id);
}
// Service code - what happens when we call this?
User user = userRepository.findUserWithLazyRelations(1L);
// Initial SQL Generated:
// SELECT u.* FROM users u WHERE u.id = 1
System.out.println(user.getUsername()); // Basic data loaded
// Only when we access the orders collection:
System.out.println("Accessing orders...");
for (Order order : user.getOrders()) { // TRIGGERS ADDITIONAL QUERY!
System.out.println(order.getTotalAmount());
}
// Additional SQL Generated when accessing orders:
// SELECT o.* FROM orders o WHERE o.user_id = 1
The N+1 Query Problem
The Danger of EAGER Loading:
// Scenario: Get all users and print their department names
List<User> users = userRepository.findAll();
// With @ManyToOne(fetch = EAGER) on Department:
// Query 1: SELECT * FROM users (gets 100 users)
// Query 2: SELECT * FROM departments WHERE id = ? (for user 1)
// Query 3: SELECT * FROM departments WHERE id = ? (for user 2)
// ...
// Query 101: SELECT * FROM departments WHERE id = ? (for user 100)
// This is the N+1 problem - 101 queries for 100 users!
for (User user : users) {
System.out.println(user.getDepartment().getName()); // Already loaded, but inefficiently
}
Solving with LAZY Loading and JOIN FETCH:
public interface UserRepository extends JpaRepository<User, Long> {
// Bad: Causes N+1
List<User> findAll();
// Good: Single query with join
@Query("SELECT u FROM User u JOIN FETCH u.department")
List<User> findAllWithDepartment();
// Even better: Multiple joins when needed
@Query("SELECT u FROM User u " +
"LEFT JOIN FETCH u.department " +
"LEFT JOIN FETCH u.profile")
List<User> findAllWithRelations();
}
// Usage - only 1 query executed:
List<User> users = userRepository.findAllWithDepartment();
for (User user : users) {
System.out.println(user.getDepartment().getName()); // No additional queries
}
Performance Comparison
Memory Usage:
// EAGER loading scenario
List<User> users = userRepository.findAll(); // Loads ALL relationships
// Memory: Users + Profiles + Departments + Orders (if accessed)
// High memory footprint immediately
// LAZY loading scenario
List<User> users = userRepository.findAll(); // Loads only Users
// Memory: Only Users initially
// Memory grows only as relationships are accessed
for (User user : users) {
// Only load what we actually use
if (user.needsDepartmentInfo()) {
System.out.println(user.getDepartment().getName()); // Loads on demand
}
}
Response Time Analysis:
// Test scenario: 1000 users, each with a department and profile
// EAGER Approach:
@Query("SELECT u FROM User u")
List<User> findAllEager(); // Loads everything in few queries but with large result sets
// LAZY Approach with selective fetching:
@Query("SELECT u FROM User u " +
"LEFT JOIN FETCH u.department " +
"WHERE u.department.name = :deptName")
List<User> findByDepartmentWithFetch(@Param("deptName") String deptName);
// Response times:
// - EAGER: Fast initial load, but high memory, potentially unused data
// - LAZY with JOIN FETCH: Optimized for specific use cases, efficient data transfer
Best Practices and Recommendations
Use LAZY Loading When:
- Collections are large (
@OneToMany,@ManyToMany) - Relationships are rarely used in your business logic
- Working with large datasets where memory is a concern
- Uncertain about relationship usage in different contexts
// GOOD: LAZY for large collections @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List<Order> orders = new ArrayList<>(); @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List<Address> addresses = new ArrayList<>(); @ManyToMany(fetch = FetchType.LAZY) private Set<Role> roles = new HashSet<>();
Use EAGER Loading When:
- Relationships are almost always needed together
- Small, bounded datasets that fit comfortably in memory
- The related entity is very small (configuration, settings)
- Performance testing shows better results with EAGER
// GOOD: EAGER for frequently used, small relationships @OneToOne(fetch = FetchType.EAGER) private UserSettings settings; @ManyToOne(fetch = FetchType.EAGER) private Category category; // Small lookup table
Common Pitfalls and Solutions
Pitfall 1: LazyInitializationException
// ERROR: Trying to access lazy collection outside transaction
@Transactional
public void processUserOrders(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
// This works inside @Transactional
for (Order order : user.getOrders()) { // LAZY collection accessed
processOrder(order);
}
}
// WITHOUT @Transactional - throws LazyInitializationException!
public void processUserOrdersError(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
// Session is closed, cannot lazy load!
for (Order order : user.getOrders()) { // EXCEPTION!
processOrder(order);
}
}
Solution: Use JOIN FETCH in Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
}
// Now no transaction needed for orders access
public void processUserOrdersFixed(Long userId) {
User user = userRepository.findByIdWithOrders(userId).orElseThrow();
for (Order order : user.getOrders()) { // Already loaded!
processOrder(order);
}
}
Pitfall 2: Over-fetching with EAGER
// BAD: Loading everything when we only need basic info
List<User> users = userRepository.findAll(); // Loads profiles, departments, etc.
// BETTER: Projection with only needed data
public interface UserBasicInfo {
String getUsername();
String getEmail();
}
@Query("SELECT u.username as username, u.email as email FROM User u")
List<UserBasicInfo> findAllBasicInfo();
// BEST: Use LAZY with specific fetches when needed
Advanced Fetch Strategies
Entity Graph Configuration:
@Entity
@NamedEntityGraphs({
@NamedEntityGraph(
name = "User.withDepartment",
attributeNodes = {
@NamedAttributeNode("department")
}
),
@NamedEntityGraph(
name = "User.withAllRelations",
attributeNodes = {
@NamedAttributeNode("department"),
@NamedAttributeNode("profile"),
@NamedAttributeNode(value = "orders", subgraph = "orders")
},
subgraphs = {
@Subgraph(name = "orders", attributeNodes = @NamedAttributeNode("items"))
}
)
})
public class User {
// entity definition
}
// Usage in Repository
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(value = "User.withDepartment", type = EntityGraphType.FETCH)
List<User> findByDepartmentName(String name);
@EntityGraph(value = "User.withAllRelations", type = EntityGraphType.FETCH)
Optional<User> findWithAllRelationsById(Long id);
}
Conditional Fetching:
// Sometimes you need different strategies for different use cases
public interface UserRepository extends JpaRepository<User, Long> {
// Basic info - no relations
Optional<User> findById(Long id);
// With profile only
@Query("SELECT u FROM User u JOIN FETCH u.profile WHERE u.id = :id")
Optional<User> findByIdWithProfile(@Param("id") Long id);
// With orders only
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
// Everything
@Query("SELECT u FROM User u " +
"LEFT JOIN FETCH u.profile " +
"LEFT JOIN FETCH u.department " +
"LEFT JOIN FETCH u.orders " +
"WHERE u.id = :id")
Optional<User> findByIdWithAllRelations(@Param("id") Long id);
}
Testing Fetch Strategies
Unit Test Example:
@DataJpaTest
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void whenFindById_thenLazyRelationsNotLoaded() {
// Given
User user = new User("john", "[email protected]");
UserProfile profile = new UserProfile("John", "Doe");
user.setProfile(profile);
entityManager.persistAndFlush(user);
// When
User found = userRepository.findById(user.getId()).orElseThrow();
// Then - profile should be a proxy (lazy)
assertTrue(entityManager.getEntityManager().getEntityManagerFactory()
.getPersistenceUnitUtil().isLoaded(found, "profile")); // Should be FALSE
}
@Test
void whenFindByIdWithProfile_thenEagerRelationLoaded() {
// Given
User user = new User("john", "[email protected]");
UserProfile profile = new UserProfile("John", "Doe");
user.setProfile(profile);
entityManager.persistAndFlush(user);
// When
User found = userRepository.findByIdWithProfile(user.getId()).orElseThrow();
// Then - profile should be loaded
assertTrue(entityManager.getEntityManager().getEntityManagerFactory()
.getPersistenceUnitUtil().isLoaded(found, "profile")); // Should be TRUE
}
}
Decision Framework
Ask these questions when choosing fetch strategy:
- How often is this relationship used?
- >80% usage: Consider EAGER or JOIN FETCH
- <20% usage: Definitely LAZY
- What's the data size?
- Large collections: LAZY
- Small reference data: EAGER might be acceptable
- What's the performance requirement?
- High throughput: LAZY with careful JOIN FETCH
- Lower volume: EAGER might be simpler
- How will the entity be used?
- Multiple use cases: LAZY with different fetch methods
- Single use case: Choose based on that specific need
Conclusion
LAZY Loading is generally preferred because:
- Better performance through selective loading
- Reduced memory consumption
- More flexible for different use cases
- Avoids the N+1 query problem when used properly
EAGER Loading has its place when:
- Relationships are almost always needed
- Working with small, bounded datasets
- Simplicity is more important than optimal performance
Key Takeaways:
- Default to LAZY for all relationships
- Use JOIN FETCH or EntityGraph when you need related data
- Avoid EAGER on collections (
@OneToMany,@ManyToMany) - Profile and test your actual query patterns
- Consider your specific use cases rather than following rigid rules
The right fetch strategy depends on your specific application needs, data patterns, and performance requirements. Always measure and test with realistic data volumes to make informed decisions.