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:

  1. Change the query to fetch what's needed (JOIN FETCH, @EntityGraph)
  2. Configure batched lazy loading (@BatchSize, default_batch_fetch_size)
  3. 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Designing APIs That Last — Principles From 10 Years of Breaking Things

An API is a contract. Breaking it breaks your users. The design decisions that seem minor at launch — naming, error shapes, pagination, versioning — are the ones that cost the most to change later. Here is what holds up and what doesn't.

Read more

JPA Query Optimization — What Hibernate Generates and How to Control It

Hibernate generates SQL from your entity model and query methods. The generated SQL is often correct but rarely optimal. Understanding what gets generated — and the specific patterns that override it — determines whether JPA is a productivity tool or a performance liability.

Read more

What Actually Happens When Spring Boot Starts Up

Spring Boot startup involves auto-configuration, bean registration, context refresh, and lifecycle callbacks — in a specific order that determines when your code runs and why some startup bugs are hard to diagnose.

Read more

When Hiring Freelancers Is the Right Decision

Freelancers often get a bad reputation in software projects. But used correctly, they can be one of the smartest decisions you make.

Read more