Hook: Interviewers love this question because one fetch choice can make the same endpoint either fast or painfully slow.
Question: What is lazy loading vs eager loading in Spring Data JPA?
Answer: Lazy loading means JPA postpones the database call until you actually touch the relationship. Eager loading means JPA fetches the relationship immediately when the parent entity is loaded. In Spring Boot, lazy is usually safer for collections and data you may never use, while eager is only a good fit for small relations that are always needed.
Interview-Ready Answer: “In Spring Data JPA, lazy loading delays the database hit until I access the relationship, while eager loading fetches it right away with the parent. I usually prefer lazy for collections because it avoids loading data I may never need, but I watch out for LazyInitializationException if I access it after the transaction ends. One important JPA detail is that @ManyToOne and @OneToOne are eager by default, while collection mappings like @OneToMany are lazy by default.”
Detailed Explanation: Think of a relation as a folder on a desk. Lazy loading keeps the folder closed and only opens it when you ask for the contents. Eager loading opens the folder immediately and puts everything on the desk, even if you never look at it. In JPA, a proxy is a stand-in object that acts like the real entity until Hibernate needs the real data, and the persistence context is the active unit of work where Hibernate can still talk to the database safely.
Customer.EntityManager is open and the transaction is active, the first access to that relation triggers a SQL query for the missing data.LazyInitializationException.JOIN FETCH or @EntityGraph when one screen needs extra data but other screens do not. That gives you control without changing the entity mapping globally.| Strategy | When SQL runs | Main win | Main risk |
|---|---|---|---|
| Lazy | On first access | Less data upfront | Lazy exception, N+1 |
| Eager | Immediately | Simple object graph | Too much data, heavy joins |
That table is the interview core: lazy saves work now, eager spends work now. The hidden trap is that eager loading is not automatically “better”; if you load 100 customers with a 20-row child collection, you can multiply rows and memory very quickly. A typical service with a pool of 10 database connections can still become slow if each request fires 1 parent query plus 20 child queries, because the bottleneck becomes round trips, not raw CPU.
O(1) SQL statements for the parent.O(N) queries and lead to the classic N+1 problem, meaning one query for the list plus one query per row.@EntityGraph, or batch fetching such as hibernate.default_batch_fetch_size instead of switching the whole mapping to eager.toString(), logging, or debugging watches can also touch lazy fields and fire SQL at the wrong time.@ManyToOne is eager by default in JPA, which surprises many people because they expect every relation to behave like a collection.@Basic(fetch = FetchType.LAZY) is only a hint for simple fields and is not something to rely on casually.Real-World Story: Imagine a checkout service in an e-commerce app. The Order endpoint returns an order summary, and each order has a collection of OrderItem rows. The developer marks the items as lazy, which is fine inside the service layer, but the controller returns the entity directly and Jackson tries to serialize the items after the transaction is already closed. In staging, everything looked fine because the session stayed open longer; in production, requests start failing with 500s and logs show failed to lazily initialize a collection of role. Users see a spinning checkout page, and support gets tickets about missing order details. The opposite bug is just as bad: someone makes the items eager, and every homepage request starts joining huge child tables, pushing latency from 120 ms to nearly a second.
// File: src/main/java/com/example/lazyloadingdemo/LazyLoadingDemoApplication.java
package com.example.lazyloadingdemo;
import org.hibernate.LazyInitializationException;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class LazyLoadingDemoApplication {
public static void main(String[] args) {
SpringApplication.run(LazyLoadingDemoApplication.class, args);
}
@Bean
CommandLineRunner demo(CustomerRepository customerRepository, CustomerService customerService) {
return args -> {
Customer alice = new Customer("Alice");
alice.addOrder(new PurchaseOrder("Keyboard"));
alice.addOrder(new PurchaseOrder("Mouse"));
Customer saved = customerRepository.save(alice);
Long id = saved.getId();
System.out.println("\n=== 1) LAZY works inside a transaction ===");
int countInsideTx = customerService.countOrdersInsideTransaction(id);
System.out.println("Orders counted safely inside tx: " + countInsideTx);
System.out.println("\n=== 2) LAZY fails after the session is closed ===");
Customer detached = customerService.loadDetachedCustomer(id);
try {
// The collection is still a proxy here; accessing it now forces a DB hit.
System.out.println("Detached order count: " + detached.getOrders().size());
} catch (LazyInitializationException ex) {
System.out.println("Expected failure: " + ex.getClass().getSimpleName());
System.out.println("Message: " + ex.getMessage());
}
System.out.println("\n=== 3) Eager-style fetch for one query using EntityGraph ===");
Customer withOrders = customerService.loadCustomerWithOrders(id);
System.out.println("Orders loaded with the query: " + withOrders.getOrders().size());
};
}
}
// File: src/main/java/com/example/lazyloadingdemo/CustomerService.java
package com.example.lazyloadingdemo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
public CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Transactional(readOnly = true)
public int countOrdersInsideTransaction(Long id) {
Customer customer = customerRepository.findById(id).orElseThrow();
// Safe here because the transaction is still open.
return customer.getOrders().size();
}
@Transactional(readOnly = true)
public Customer loadDetachedCustomer(Long id) {
// Returning the entity ends the transaction when the method returns.
// The caller gets a detached object, which is perfect for demonstrating the lazy failure.
return customerRepository.findById(id).orElseThrow();
}
@Transactional(readOnly = true)
public Customer loadCustomerWithOrders(Long id) {
// This does not change the mapping globally; it fetches eagerly only for this query.
return customerRepository.loadByIdWithOrders(id).orElseThrow();
}
}
// File: src/main/java/com/example/lazyloadingdemo/CustomerRepository.java
package com.example.lazyloadingdemo;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
@EntityGraph(attributePaths = "orders")
@Query("select c from Customer c where c.id = :id")
Optional<Customer> loadByIdWithOrders(@Param("id") Long id);
}
// File: src/main/java/com/example/lazyloadingdemo/Customer.java
package com.example.lazyloadingdemo;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// Collections are usually lazy so we do not load every child row on every request.
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<PurchaseOrder> orders = new ArrayList<>();
protected Customer() {
// JPA needs a no-arg constructor.
}
public Customer(String name) {
this.name = name;
}
public void addOrder(PurchaseOrder order) {
orders.add(order);
order.setCustomer(this);
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public List<PurchaseOrder> getOrders() {
return orders;
}
}
// File: src/main/java/com/example/lazyloadingdemo/PurchaseOrder.java
package com.example.lazyloadingdemo;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
// Many-to-one is EAGER by default in JPA, but LAZY is usually the safer choice for APIs.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
protected PurchaseOrder() {
// JPA needs a no-arg constructor.
}
public PurchaseOrder(String productName) {
this.productName = productName;
}
void setCustomer(Customer customer) {
this.customer = customer;
}
public Long getId() {
return id;
}
public String getProductName() {
return productName;
}
}
// File: src/main/resources/application.properties
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=falseFollow-up & Tricky Questions:
JOIN FETCH / @EntityGraph for the exact query that needs it. Do not rely on the controller layer to magically fix fetch timing.JOIN FETCH and @EntityGraph? Both are ways to fetch associated data eagerly for one query. JOIN FETCH is written in JPQL, while @EntityGraph is metadata you attach to the repository method.Common Mistakes:
@ManyToOne and @OneToOne are eager by default. Correction: state the defaults clearly in the interview.Memory Hook: Lazy is “bring the file only when I open the drawer”; eager is “dump the files on the desk before the meeting starts.”
Cheat Sheet:
@ManyToOne and @OneToOne are eager; collections are usually lazy.LazyInitializationException outside a transaction.JOIN FETCH or @EntityGraph over changing the global mapping.Practice Tasks:
PurchaseOrder.customer eager and observe the SQL difference.Customer and see how the row count changes with eager fetching.spring.jpa.open-in-view on and off in a web app, then compare when the lazy exception appears.