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.
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.
Enable Hibernate statistics in application.yml and watch the query count in your logs.
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: DEBUG1// 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();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.
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.
1// Interface projection
2public interface UserSummary {
3 Long getId();
4 String getName();
5 String getEmail();
6}
7
8// Repository
9List<UserSummary> findAllProjectedBy();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.
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.
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