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.

Solving the N+1 problem with Spring Data JPA using fetch join and EntityGraph

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.

Real-World N+1 Impact

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.

Order.javajava
@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
}
Customer.javajava
@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.

OrderService.java - Problematic codejava
@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.

sql
-- 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 queries

Detecting 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.

yaml
# 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: TRACE

Hibernate statistics provide a valuable summary at the end of each transaction.

text
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.

Disable in Production

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.

OrderRepository.javajava
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.

sql
-- 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.id

The service now uses the optimized method without modifying business code.

OrderService.java - Optimized codejava
@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.

OrderRepository.javajava
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.

Order.javajava
@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:

Order.java - Hibernate 7 text-based syntaxjava
@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.

Order.javajava
@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.

sql
-- 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.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch vs Fetch Join

Batch 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.

OrderRepository.java - Safe pagination with Hibernate 7.4java
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:

sql
-- 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.

StrategyUse CaseAdvantagesDisadvantages
Fetch Join@ManyToOne relationshipsSingle SQL queryCartesian product with collections
@EntityGraphDeclarative loadingReusable, readableLess flexible than JPQL
Batch Fetching@OneToMany collectionsAvoids Cartesian productMultiple queries
SubselectRarely accessed collectionsLoads only when neededCorrelated subquery

The subselect strategy loads the entire collection on first access to any element.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();
sql
-- 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.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
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.

sql
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.

OrderRepository.javajava
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.

OrderService.javajava
@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.

OrderRepositoryPerformanceTest.javajava
@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

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 FETCH for frequently accessed @ManyToOne relationships
  • Apply @EntityGraph for reusable declarative loading
  • Configure @BatchSize for large @OneToMany collections
  • 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
Daily challenge

Can you spot the bug in Spring Boot?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on September 2, 2026

Tags

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

Share

Related articles