Spring Data JPA N+1 Query Solutions in 2026: Fetch Join and EntityGraph
Complete guide to detecting and fixing the N+1 problem in Spring Data JPA. Fetch join, @EntityGraph, batch fetching, and query performance strategies.

The N+1 problem represents one of the most common performance pitfalls in JPA. An innocent query to retrieve 100 orders can trigger 101 SQL queries: one for the orders, then one for each associated customer. This silent query multiplication degrades performance and overloads the database.
An endpoint returning 50 articles with their authors can jump from 10ms to 500ms due to N+1. Early detection prevents critical production issues.
Understanding the N+1 Problem in JPA
The N+1 problem occurs when JPA loads a collection of entities then executes an additional query for each entity to load its associations. This behavior stems from the default lazy loading of @OneToMany and @ManyToMany relationships.
Consider a classic model with orders and customers. Each order belongs to a customer, and this relationship defaults to lazy loading.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private LocalDateTime createdAt;
// ManyToOne relationship is lazy by default since JPA 2.0
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// OneToMany relationship lazy by default
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// Getters and setters omitted
}@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and setters omitted
}When a query retrieves orders and then accesses the customer name, Hibernate executes an additional query for each order.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// 1 query: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
// For each order, accessing customer triggers a query
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
// N queries: SELECT * FROM customers WHERE id = ?
order.getCustomer().getName()
))
.toList();
}
}For 100 orders, this code executes 101 SQL queries. Hibernate logs reveal this destructive pattern.
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o
-- Queries 2-101: fetch each customer
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3
-- ... 97 more identical queriesDetecting the N+1 Problem with Hibernate Logs
The first step involves enabling SQL logs to identify problematic queries. The following configuration displays each query executed by Hibernate.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
# Format SQL for better readability
format_sql: true
# Display session statistics (queries, time)
generate_statistics: true
logging:
level:
# Detailed SQL query logging
org.hibernate.SQL: DEBUG
# Display prepared statement parameters
org.hibernate.orm.jdbc.bind: TRACEHibernate statistics provide a valuable summary at the end of each transaction.
Session Metrics {
23421 nanoseconds spent acquiring 1 JDBC connection;
0 nanoseconds spent releasing 0 JDBC connections;
1254789 nanoseconds spent preparing 101 JDBC statements;
15478963 nanoseconds spent executing 101 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
}The 101 JDBC statement count for a simple order list clearly signals an N+1 problem.
SQL logs and statistics impact performance. These options should remain disabled in production and reserved for development and test environments.
Solution 1: Fetch Join with JPQL
Fetch join loads associations in a single SQL query through a join. This explicit approach solves N+1 by retrieving all necessary data at once.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Explicit fetch join to load customers
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Multiple fetch join for several associations
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"JOIN FETCH o.items i")
List<Order> findAllWithCustomerAndItems();
// Fetch join with WHERE condition
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"WHERE o.createdAt > :since")
List<Order> findRecentOrdersWithCustomer(
@Param("since") LocalDateTime since
);
}Fetch join transforms N+1 queries into a single optimized query.
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.idThe service now uses the optimized method without modifying business code.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// Single query with join
List<Order> orders = orderRepository.findAllWithCustomer();
// No additional queries
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
order.getCustomer().getName() // Already loaded
))
.toList();
}
}Ready to ace your Spring Boot interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Solution 2: @EntityGraph for Declarative Control
The @EntityGraph annotation offers a declarative alternative to fetch join. It defines which associations to load without writing custom JPQL.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Inline EntityGraph with attributePaths
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// EntityGraph with multiple attributes
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByCreatedAtAfter(LocalDateTime since);
// Named EntityGraph referencing entity definition
@EntityGraph(value = "Order.withCustomerAndItems")
List<Order> findByCustomerId(Long customerId);
// Combination with custom query
@EntityGraph(attributePaths = {"customer"})
@Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}Named EntityGraphs are defined directly on the entity for reuse across multiple repositories.
@Entity
@Table(name = "orders")
@NamedEntityGraph(
name = "Order.withCustomer",
attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
@NamedEntityGraph(
name = "Order.full",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = @NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product")
)
)
public class Order {
// Fields unchanged
}The subgraph enables loading nested associations. The example above loads orders, their items, and each item's product in a single query.
Solution 3: Batch Fetching for Collections
Batch fetching offers an alternative to fetch join for @OneToMany collections. Instead of loading each collection individually, Hibernate groups queries into batches.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Batch fetching on the collection
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<OrderItem> items = new ArrayList<>();
}With @BatchSize(size = 25), Hibernate loads items for 25 orders at a time instead of 1. For 100 orders, query count drops from 101 to 5.
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries
-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)Global batch size configuration applies to all collections in the application.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch fetching suits cases where fetch join generates an overly large Cartesian product. For an order with 10 items and 5 payments, fetch join returns 50 rows. Batch fetching executes 2 separate, more efficient queries.
Comparing Loading Strategies
Each strategy offers advantages depending on usage context. The following table summarizes optimal use cases.
| Strategy | Use Case | Advantages | Disadvantages |
|----------|----------|------------|---------------|
| Fetch Join | @ManyToOne relationships | Single SQL query | Cartesian product with collections |
| @EntityGraph | Declarative loading | Reusable, readable | Less flexible than JPQL |
| Batch Fetching | @OneToMany collections | Avoids Cartesian product | Multiple queries |
| Subselect | Rarely accessed collections | Loads only when needed | Correlated subquery |
The subselect strategy loads the entire collection on first access to any element.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)Avoiding N+1 with DTO Projections
DTO projections offer a radical but effective approach. By selecting only necessary columns, projections completely avoid entity loading and their associations.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}public interface OrderRepository extends JpaRepository<Order, Long> {
// DTO projection with constructor
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDto> findAllOrderSummaries();
// DTO projection with condition
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c " +
"WHERE o.createdAt > :since")
List<OrderSummaryDto> findRecentOrderSummaries(
@Param("since") LocalDateTime since
);
}This approach generates an optimal SQL query without entity mapping overhead.
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?Advanced Configuration with Spring Data JPA
Spring Data JPA 3.x introduces improvements for EntityGraph management and optimized queries.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Dynamic EntityGraph with Specification
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll(Specification<Order> spec);
// Pagination with EntityGraph
@EntityGraph(attributePaths = {"customer"})
Page<Order> findByCustomerNameContaining(
String name,
Pageable pageable
);
// Slice for efficient pagination
@EntityGraph(attributePaths = {"customer"})
Slice<Order> findByCreatedAtBefore(
LocalDateTime date,
Pageable pageable
);
}Conditional loading enables applying different strategies based on context.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final EntityManager entityManager;
public List<Order> getOrdersWithGraph(String graphName) {
// Dynamic EntityGraph retrieval
EntityGraph<?> graph = entityManager
.getEntityGraph(graphName);
return entityManager
.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
public List<Order> getOrdersForListing() {
// Minimal graph for listing
return getOrdersWithGraph("Order.withCustomer");
}
public List<Order> getOrdersForDetail() {
// Full graph for detail view
return getOrdersWithGraph("Order.full");
}
}Start practicing!
Test your knowledge with our interview simulators and technical tests.
Performance Tests to Detect N+1
Automated tests verify the absence of N+1 problems by counting executed SQL queries.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@PersistenceContext
private EntityManager em;
private Statistics statistics;
@BeforeEach
void setUp() {
// Enable Hibernate statistics
Session session = entityManager.unwrap(Session.class);
SessionFactory factory = session.getSessionFactory();
statistics = factory.getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
}
@Test
void findAllWithCustomer_shouldExecuteSingleQuery() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch with fetch join
List<Order> orders = orderRepository.findAllWithCustomer();
// Then: single query executed
assertThat(orders).hasSize(50);
assertThat(statistics.getQueryExecutionCount())
.as("Should execute only 1 query with fetch join")
.isEqualTo(1);
}
@Test
void findAll_withoutOptimization_triggersNPlus1() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch without optimization
List<Order> orders = orderRepository.findAll();
// Access customers to trigger lazy loading
orders.forEach(o -> o.getCustomer().getName());
// Then: N+1 queries (51 instead of 1)
assertThat(statistics.getQueryExecutionCount())
.as("Should detect N+1 problem")
.isGreaterThan(1);
}
private void createTestOrders(int count) {
for (int i = 0; i < count; i++) {
Customer customer = new Customer();
customer.setName("Customer " + i);
customer.setEmail("customer" + i + "@test.com");
entityManager.persist(customer);
Order order = new Order();
order.setOrderNumber("ORD-" + i);
order.setCustomer(customer);
order.setCreatedAt(LocalDateTime.now());
entityManager.persist(order);
}
entityManager.flush();
}
}The assertion on getQueryExecutionCount() ensures optimizations remain in place during code evolution.
Conclusion
The N+1 problem represents a major performance challenge in Spring Data JPA, but several effective solutions exist. Fetch join and @EntityGraph solve most cases by loading associations in a single query. Batch fetching offers an alternative for large collections where fetch join generates a Cartesian product.
N+1 Prevention Checklist:
- ✅ Enable SQL logs in development to detect multiple queries
- ✅ Use
JOIN FETCHfor frequently accessed@ManyToOnerelationships - ✅ Apply
@EntityGraphfor reusable declarative loading - ✅ Configure
@BatchSizefor large@OneToManycollections - ✅ Prefer DTO projections for read-only operations
- ✅ Write tests verifying the number of executed queries
- ✅ Disable SQL logs and statistics in production
- ✅ Regularly profile critical endpoints with Hibernate metrics
Tags
Share
Related articles

Spring GraphQL Interview: Resolvers, DataLoaders and N+1 Problem Solutions
Prepare for Spring GraphQL interviews with this complete guide. Resolvers, DataLoaders, N+1 problem handling, mutations, and best practices for technical questions.

Spring Boot 3.4 Virtual Threads: Interview Questions and Performance Benchmarks
Master Java 21 Virtual Threads with Spring Boot 3.4: 15 interview questions, performance benchmarks, and migration patterns to ace your technical interviews.

Spring Boot Logging in 2026: Structured Logs for Production with Logback and JSON
Complete guide to Spring Boot structured logging. Logback JSON configuration, MDC for tracing, production best practices and ELK Stack integration.