Hook: Interviewers love this one because it reveals whether you can fix the N+1 query problem without turning every repository method into custom SQL.
Question: Explain @EntityGraph.
Answer: @EntityGraph is a Spring Data JPA annotation that tells JPA which related objects to fetch immediately for one repository query. Instead of loading an entity first and then triggering many lazy loads later, you ask for the needed associations up front. It changes the fetch plan, not the filter logic.
Interview-Ready Answer: “I use @EntityGraph when I want Spring Data JPA to fetch an entity plus specific relationships in one read operation. It helps me avoid N+1 queries and LazyInitializationException by defining a per-query fetch plan. Under the hood, Spring passes a JPA fetch graph or load graph hint to Hibernate, so I can keep my mappings simple and optimize only the repository method that needs it.”
@EntityGraph is a Spring Data JPA feature, not a core JPA annotation. A graph is just a list of attributes that should be initialized together with the main entity. This is useful when your entity mapping is intentionally lazy, but one read path needs more data right now.
@EntityGraph on a repository method that returns entities, such as findById, a derived query, or a custom @Query method.FETCH mode, it uses the jakarta.persistence.fetchgraph hint; in LOAD mode, it uses jakarta.persistence.loadgraph.Important detail: FETCH and LOAD are different. FETCH treats unspecified attributes as lazy for that query even if the entity mapping says otherwise. LOAD keeps the mapping defaults and only upgrades the listed paths.
| Option | Meaning | Main trade-off |
|---|---|---|
| @EntityGraph | Per-query fetch plan | Clean and reusable |
| JOIN FETCH | JPQL forces a join | Very explicit, less reusable |
| Default LAZY | Load on access | Simple, but N+1 risk |
Think in query count, not just syntax. If you load 20 orders and each order touches 5 line items, naive lazy access can become 21 queries or more. With an entity graph, that often becomes one fixed fetch plan, or a small number of SQL statements, depending on the provider. The downside is memory and row inflation: if you fetch a parent with a large collection, the SQL result set can grow fast, and multiple collection joins can multiply rows.
A good interview answer also mentions limits. @EntityGraph does not help DTO projections because DTOs bypass entity loading. It also does not magically make every query faster; a graph that is too big can be slower than a few well-batched lazy loads. If you have many small collections, batching with @BatchSize or hibernate.default_batch_fetch_size may be a better fit.
Memory model: @EntityGraph is like handing the database a shopping list before it walks into the warehouse: “Pick these shelves now, don’t make me come back for them one by one.”
Imagine a checkout service in an e-commerce app. The order page needs the order header, the line items, and maybe the customer summary. A developer leaves the mapping lazy and then serializes the order in the controller. In production, the page becomes slow because each order row triggers extra database hits, and sometimes it fails with LazyInitializationException when the session is already closed.
The symptoms are easy to spot: p95 latency climbs from around 80 ms to 700 ms or more, the database CPU spikes, and logs show repeated select ... from order_items where purchase_order_id = ?. Users see spinning loaders, and support tickets say the order page is “randomly broken.” The fix is often to put @EntityGraph(attributePaths = "items") on the repository method that loads the order detail view, so the page gets one consistent fetch plan instead of accidental lazy loading.
package com.example.entitygraph;
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.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Bean;
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.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.repository.query.Param;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@SpringBootApplication
@EntityScan(basePackageClasses = {PurchaseOrder.class, OrderItem.class})
@EnableJpaRepositories(basePackageClasses = PurchaseOrderRepository.class)
public class EntityGraphDemoApplication {
public static void main(String[] args) {
SpringApplication.run(EntityGraphDemoApplication.class, args);
}
@Bean
CommandLineRunner demo(PurchaseOrderRepository repo) {
return args -> {
// Save a parent plus children. Cascade makes the example small and realistic.
PurchaseOrder order = new PurchaseOrder("ORD-1001");
order.addItem(new OrderItem("Keyboard", 1));
order.addItem(new OrderItem("Mouse", 2));
PurchaseOrder saved = repo.save(order);
Long id = saved.getId();
System.out.println("Saved order id = " + id);
// Plain lookup returns a detached entity after the repository call ends.
// Accessing a LAZY collection here demonstrates the classic failure path.
PurchaseOrder plain = repo.findById(id).orElseThrow();
try {
System.out.println("Plain fetch item count = " + plain.getItems().size());
} catch (Exception ex) {
System.out.println("Expected lazy-loading failure: " + ex.getClass().getSimpleName());
System.out.println(ex.getMessage());
}
// The entity graph tells Hibernate to fetch items with this query.
// That means the collection is already initialized when the method returns.
PurchaseOrder graphLoaded = repo.findWithItemsById(id).orElseThrow();
System.out.println("EntityGraph fetch item count = " + graphLoaded.getItems().size());
for (OrderItem item : graphLoaded.getItems()) {
System.out.println(item.getProductName() + " x" + item.getQuantity());
}
};
}
}
@Entity
@Table(name = "purchase_orders")
class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@OneToMany(mappedBy = "purchaseOrder", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
protected PurchaseOrder() {
// JPA needs a no-args constructor.
}
PurchaseOrder(String orderNumber) {
this.orderNumber = orderNumber;
}
void addItem(OrderItem item) {
items.add(item);
item.setPurchaseOrder(this);
}
public Long getId() {
return id;
}
public String getOrderNumber() {
return orderNumber;
}
public List<OrderItem> getItems() {
return items;
}
}
@Entity
@Table(name = "order_items")
class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private int quantity;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "purchase_order_id")
private PurchaseOrder purchaseOrder;
protected OrderItem() {
// JPA needs a no-args constructor.
}
OrderItem(String productName, int quantity) {
this.productName = productName;
this.quantity = quantity;
}
void setPurchaseOrder(PurchaseOrder purchaseOrder) {
this.purchaseOrder = purchaseOrder;
}
public String getProductName() {
return productName;
}
public int getQuantity() {
return quantity;
}
}
interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
// A custom query plus @EntityGraph is a common Spring Data pattern.
// The graph changes how the entity is fetched; the JPQL still selects the same row.
@EntityGraph(attributePaths = "items")
@Query("select p from PurchaseOrder p where p.id = :id")
Optional<PurchaseOrder> findWithItemsById(@Param("id") Long id);
}
Follow-up & Tricky Questions:
FETCH different from LOAD? FETCH treats unspecified attributes as lazy for that query, even if the mapping says eager. LOAD keeps the mapping defaults and only adds the paths from the graph.@EntityGraph with findAll(Pageable)? @EntityGraph always produce one SQL statement? JOIN FETCH instead? JOIN FETCH when you want one very explicit JPQL query and you are comfortable owning that query text. Use @EntityGraph when you want the fetch plan attached to the repository method and keep JPQL simpler.items.product? Common Mistakes:
Memory Hook: Think of @EntityGraph as giving the warehouse a shopping list before delivery: “Pick these shelves now, not one trip at a time.”
Cheat Sheet:
FETCH is the default; LOAD preserves mapping defaults.@Query methods.Practice Tasks:
@EntityGraph to a user repository to load roles with the user.orders.items and inspect the SQL size.