N+1 Query Problem: The Silent Performance Killer in Spring Boot
by Eric Hanson, Backend Developer at Clean Systems Consulting
What N+1 actually means
N+1 describes a query pattern: one query loads a collection of N records, then N additional queries load an associated record for each. The total is N+1 queries where 1 or 2 would suffice.
SELECT * FROM orders WHERE status = 'PENDING' -- 1 query, returns 50 orders
SELECT * FROM users WHERE id = 1 -- query 2
SELECT * FROM users WHERE id = 2 -- query 3
SELECT * FROM users WHERE id = 3 -- query 4
...
SELECT * FROM users WHERE id = 50 -- query 51
51 queries. One was necessary. 50 were not.
The reason it's silent: each individual query succeeds. The response is correct. No exception is thrown. In development with a seeded database of 20 orders, 21 queries complete in 30ms and nobody notices. In production with 500 orders, 501 queries complete in 2.5 seconds under normal load and 15 seconds under peak load.
How N+1 forms in Hibernate
Hibernate's default fetch strategy for @ManyToOne and @OneToOne associations is EAGER. For @OneToMany and @ManyToMany, it's LAZY. The N+1 pattern forms differently depending on which type.
Lazy @OneToMany — N+1 on collection access:
@Entity
public class Order {
@OneToMany(mappedBy = "order") // LAZY by default
private List<LineItem> lineItems;
}
// Service code
List<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING);
orders.forEach(order -> {
// Each access to lineItems fires a SELECT
double total = order.getLineItems().stream()
.mapToDouble(li -> li.getUnitPrice() * li.getQuantity())
.sum();
});
order.getLineItems() on a lazily-loaded collection triggers SELECT * FROM line_items WHERE order_id = ?. For 100 orders, 100 extra queries.
Eager @ManyToOne — N+1 on the initial load:
@Entity
public class Order {
@ManyToOne // EAGER by default — JOINs user on every order query
private User user;
}
// Even this simple query joins users:
List<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING);
// SELECT o.*, u.* FROM orders o JOIN users u ON o.user_id = u.id WHERE o.status = ?
Eager loading joins the association into every query — even queries where the user is never accessed. For a wide User entity with many columns, this wastes bandwidth and memory on every order query.
N+1 from serializers and presenters
The most insidious N+1 in mature Spring Boot applications doesn't originate in service code — it comes from serialization:
// OrderSerializer accesses associations during serialization
public class OrderSerializer extends JsonSerializer<Order> {
@Override
public void serialize(Order order, JsonGenerator gen, SerializerProvider p)
throws IOException {
gen.writeStartObject();
gen.writeStringField("id", order.getId());
gen.writeStringField("customerEmail", order.getUser().getEmail()); // N+1 if user not loaded
gen.writeNumberField("itemCount", order.getLineItems().size()); // N+1 if items not loaded
gen.writeEndObject();
}
}
// Controller — builds the response
@GetMapping
public List<Order> getOrders() {
return orderRepository.findByStatus(OrderStatus.PENDING);
// Query loads orders — associations not fetched
// Jackson serializes each order — triggers N+1 for user and line items
}
The N+1 fires during serialization, after the query. The service looks clean; the repository query looks clean. The problem is invisible until you measure query count per request.
This pattern is extremely common with Jackson, Spring Data REST, and any ORM-aware serialization library. The serializer accesses associations that weren't fetched by the query, and Hibernate silently issues individual queries for each.
Detection — finding N+1 before production
SQL logging. Enable and watch the log:
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
An N+1 looks like repeated identical queries with incrementing IDs:
Hibernate: select user0_.id as id1_5_0_, user0_.email as email2_5_0_ from users user0_ where user0_.id=?
binding parameter [1] as [BIGINT] - [1]
Hibernate: select user0_.id as id1_5_0_, user0_.email as email2_5_0_ from users user0_ where user0_.id=?
binding parameter [1] as [BIGINT] - [2]
Hibernate: select user0_.id as id1_5_0_, user0_.email as email2_5_0_ from users user0_ where user0_.id=?
binding parameter [1] as [BIGINT] - [3]
Same query structure, different parameter values, repeated N times.
Query count assertions in tests. Add a test that fails when query count exceeds a threshold:
@DataJpaTest
class OrderRepositoryTest {
@Autowired OrderRepository orderRepository;
@PersistenceContext EntityManager entityManager;
@Test
void findByStatus_doesNotCauseNPlusOne() {
// Create 5 orders with users
for (int i = 0; i < 5; i++) {
User user = new User("user" + i + "@example.com");
entityManager.persist(user);
entityManager.persist(new Order(user, OrderStatus.PENDING));
}
entityManager.flush();
entityManager.clear();
SessionFactory sf = entityManager.getEntityManagerFactory()
.unwrap(SessionFactory.class);
sf.getStatistics().setStatisticsEnabled(true);
sf.getStatistics().clear();
List<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING);
orders.forEach(o -> o.getUser().getEmail()); // access association
long queryCount = sf.getStatistics().getQueryExecutionCount();
assertThat(queryCount)
.as("Expected ≤2 queries, got N+1")
.isLessThanOrEqualTo(2);
}
}
This test fails when an N+1 is introduced and catches regressions automatically.
Datasource proxy. The datasource-proxy library wraps your DataSource and logs every query with the call stack, making it trivial to identify where each query originates:
@Bean
@Primary
public DataSource dataSource(DataSourceProperties properties) {
DataSource originalDatasource = properties.initializeDataSourceBuilder().build();
return ProxyDataSourceBuilder.create(originalDatasource)
.logQueryBySlf4j(SLF4JLogLevel.INFO)
.countQuery()
.build();
}
In development, this logs every query with its origin. The origin stack trace shows whether a query came from a repository method, a serializer, or a service method.
Fix 1: JOIN FETCH for always-needed associations
When an association is needed for every use of a query result, JOIN FETCH loads it in the same SQL:
// Repository
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findByStatusWithUser(@Param("status") OrderStatus status);
// For multiple associations
@Query("SELECT DISTINCT o FROM Order o " +
"JOIN FETCH o.user " +
"LEFT JOIN FETCH o.lineItems " +
"WHERE o.status = :status")
List<Order> findByStatusWithDetails(@Param("status") OrderStatus status);
DISTINCT in JPQL deduplicates entities after the JOIN — a @OneToMany JOIN FETCH produces one row per child, and without DISTINCT, Hibernate returns duplicate parent entities.
Fix 2: @EntityGraph for declarative fetch paths
@EntityGraph(attributePaths = {"user", "lineItems"})
List<Order> findByStatus(OrderStatus status);
Equivalent to JOIN FETCH without modifying the JPQL. Cleaner for simple fetch paths.
Fix 3: batch fetching for large or conditional collections
For collections where JOIN FETCH causes excessive row duplication, batch fetching loads collections in IN clause batches:
// Global configuration — applies to all lazy associations
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 50
With default_batch_fetch_size: 50, accessing a lazy collection on 100 orders issues 2 queries instead of 100: WHERE order_id IN (1..50) and WHERE order_id IN (51..100). This single configuration change is often the highest-impact N+1 fix across an entire application.
Per-entity override:
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<LineItem> lineItems;
Fix 4: projections for read-only responses
When the query feeds a response that only needs specific fields, projections avoid loading associations entirely:
public interface OrderSummary {
String getId();
OrderStatus getStatus();
String getUserEmail(); // Spring Data joins user table automatically
}
List<OrderSummary> findByStatus(OrderStatus status);
// SELECT o.id, o.status, u.email FROM orders o JOIN users u ON ...
// One query, no association loading, no N+1 possible
The projection approach is both the N+1 fix and the performance optimization — it selects only the columns needed, performs no entity lifecycle management, and generates exactly one query.
The layer that owns the fix
The N+1 originates at the fetch strategy level (what Hibernate loads) but manifests at the consumption level (what serializers, services, and controllers access). The fix must happen at the fetch level — adding JOIN FETCH, @EntityGraph, or @BatchSize — not at the consumption level.
The wrong fix: adding fetch = FetchType.EAGER to the entity mapping to prevent the lazy load. This makes the N+1 happen on the initial query (as a JOIN) rather than on association access — it doesn't eliminate the work, just moves it. And it forces the JOIN on every query even when the association isn't needed.
The right fix: lazy associations everywhere, explicit eager loading per query via JOIN FETCH, @EntityGraph, or default_batch_fetch_size.
Every N+1 fix is one of three patterns:
- Change the query to fetch what's needed (
JOIN FETCH,@EntityGraph) - Configure batched lazy loading (
@BatchSize,default_batch_fetch_size) - Remove the need to load the association at all (projections)
The detection method determines which fix applies. If the N+1 fires in a serializer, fix the query that feeds the serializer. If it fires in service code, fix the query or add batch fetching. If the association is never needed for a given use case, use a projection that doesn't include it.