Backend EngineeringMay 22, 20257 min read

JPA N+1 Problem: How to Find and Fix It in Spring Boot

The N+1 query problem silently kills performance in Spring Boot apps. Here's how to detect it with Hibernate stats, and three strategies to eliminate it — JOIN FETCH, @EntityGraph, and projections.

JavaSpring BootJPAHibernatePostgreSQLPerformance

N+1 is the most common performance bug in Spring Boot apps. You load 100 users, and Hibernate fires 101 queries — one to get the list, then one per user to fetch their orders. In production with 10,000 users that's a disaster.

How to Detect It

Enable Hibernate statistics in application.yml and watch the query count in your logs.

application.yml
java
1spring:
2  jpa:
3    properties:
4      hibernate:
5        generate_statistics: true
6        format_sql: true
7    show-sql: true
8logging:
9  level:
10    org.hibernate.stat: DEBUG

Fix 1: JOIN FETCH in JPQL

UserRepository.java
java
1// BAD — triggers N+1 when you access user.getOrders()
2List<User> findAll();
3
4// GOOD — single query with LEFT JOIN FETCH
5@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")
6List<User> findAllWithOrders();

Fix 2: @EntityGraph

UserRepository.java
java
1@EntityGraph(attributePaths = {"orders", "orders.items"})
2List<User> findAll();
3
4// Or on a specific method
5@EntityGraph(attributePaths = "roles")
6Optional<User> findByEmail(String email);

JOIN FETCH with pagination (Pageable) throws a HibernateException. Use @EntityGraph or a two-query approach instead: first fetch IDs with pagination, then fetch entities by those IDs with JOIN FETCH.

Fix 3: Projections for Read-Heavy Endpoints

If you only need a subset of fields, use interface projections or DTOs. This generates a SELECT of only the columns you need — no lazy loading, no N+1 risk.

UserSummary.java
java
1// Interface projection
2public interface UserSummary {
3    Long getId();
4    String getName();
5    String getEmail();
6}
7
8// Repository
9List<UserSummary> findAllProjectedBy();

Batch Fetching as an Alternative

User.java
java
1@Entity
2@BatchSize(size = 25) // load 25 users' orders in one query instead of 25 separate queries
3public class User {
4
5    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
6    @BatchSize(size = 25)
7    private List<Order> orders;
8}

Batch fetching is ideal when you can't use JOIN FETCH due to multiple collection associations. If a User has orders AND reviews and you JOIN FETCH both, you get a cartesian product — 10 orders × 10 reviews = 100 rows returned per user. Batch fetching avoids this entirely.

The Cartesian Product Trap

Never JOIN FETCH two OneToMany collections on the same entity. Hibernate will warn you with 'HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory'. This means Hibernate loads ALL rows into memory, then paginates — a silent full table scan.

Rule of thumb: JOIN FETCH is safe for ManyToOne and OneToOne (no multiplication). For OneToMany, use @BatchSize or split into two queries. For read endpoints that need both parent and children, DTOs with a custom JPQL query are the cleanest solution.

Detect N+1 in Tests

N1DetectionTest.java
java
1@DataJpaTest
2class UserRepositoryTest {
3
4    @Autowired UserRepository repo;
5
6    // Use datasource-proxy or Hibernate statistics to assert query count
7    @Test
8    void findAllWithOrders_ShouldUseOneQuery() {
9        Statistics stats = entityManager.getEntityManagerFactory()
10            .unwrap(SessionFactory.class).getStatistics();
11        stats.setStatisticsEnabled(true);
12        stats.clear();
13
14        List<User> users = repo.findAllWithOrders();
15        users.forEach(u -> u.getOrders().size()); // trigger lazy load if any
16
17        assertThat(stats.getPrepareStatementCount()).isEqualTo(1); // fail if N+1
18    }
19}

More in Backend Engineering