Spring Data JPA N+1 Query Solutions: Fetch Join, EntityGraph and Hibernate 7.4
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. For a broader overview of JPA best practices, see the Spring Data JPA basics module.
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.
Hibernate 7 introduces a simplified text-based @NamedEntityGraph syntax that reduces annotation verbosity:
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
name = "Order.full",
graph = "customer, items(product)"
)
public class Order {
// Fields unchanged
}This single-line definition replaces the verbose nested @NamedAttributeNode and @NamedSubgraph annotations.
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.
Pagination with Fetch Join in Hibernate 7.4
A longstanding limitation of fetch joins was their incompatibility with pagination. Combining setMaxResults() with collection fetch joins forced Hibernate to load all matching rows, then apply limits in-memory. The warning firstResult/maxResults specified with collection fetch; applying in memory indicated this performance problem.
Hibernate 7.4 solves this issue. Pagination with collection fetch joins now works at the database level using nested queries. Hibernate first determines the limited set of parent entity identifiers, then fetches the associated collections for only those parent rows.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}The generated SQL now uses a subquery to paginate at the database level:
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 0
)This improvement dramatically reduces memory usage for paginated endpoints that display parent-child relationships. Spring Boot 4.1 includes Hibernate 7.4.5, making this optimization available by default.
For applications still on earlier Hibernate versions, the two-phase fetching pattern remains the safest approach: fetch parent IDs first with pagination, then load associations for those specific IDs.
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. This pattern is particularly relevant for read-heavy endpoints, as covered in the JPA queries module.
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 in Spring Boot 4.x provides improved EntityGraph management and query optimization. Understanding transaction propagation becomes essential when combining these fetch strategies with complex business logic.
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. For comprehensive testing strategies, see the Testcontainers guide for Spring Boot.
@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.
Sources
- Hibernate 7.4 What's New โ pagination with fetch joins now safe at database level
- Spring Boot 4.1 Release Notes โ ships with Hibernate 7.4.5.Final
- Hibernate ORM Documentation โ official reference for fetch strategies and EntityGraph
N+1 Prevention Checklist for Spring Data JPA
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. With Hibernate 7.4, pagination with fetch joins finally works at the database level.
What to apply in production:
- 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
- Upgrade to Spring Boot 4.1+ to benefit from Hibernate 7.4 pagination fixes
Can you spot the bug in Spring Boot?
One real snippet, one hidden bug, one attempt a day. No account needed to try.

Written by
Anthony Fillion-MailletFounder of SharpSkill
Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.
Updated on September 2, 2026
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 Observability in 2026: OpenTelemetry, Distributed Tracing and Interview Questions
Master Spring Boot observability with OpenTelemetry and Micrometer Tracing. Learn distributed tracing setup, the Observation API, OTLP export configuration, and prepare for technical interviews.