DEV Community

Cover image for The N+1 Problem in Spring Data JPA: A Practical Guide
Nuno Silva
Nuno Silva

Posted on • Originally published at edge-case.hashnode.dev

The N+1 Problem in Spring Data JPA: A Practical Guide

Spring Data JPA solves a real problem. It lets you model your domain as an object graph and persist it to a relational store without hand-writing every SQL statement. For writes, this is largely a good trade. For reads, it can quietly destroy your application's performance in ways that are nearly invisible until you're already in production.

This guide explains why, with a specific focus on the N+1 query problem—the most common and costly consequence of naive JPA usage—and walks through the practical fixes available in the Spring ecosystem.


The Impedance Mismatch

The core tension between Hibernate and your database comes down to how each paradigm navigates data.

Object-oriented code thinks in graphs. An Order holds a reference to a Customer, which holds a collection of Address objects. You traverse the graph by following pointers: order.getCustomer().getBillingAddress().

Relational databases think in sets. Data lives in flat, normalized tables. You retrieve related data by joining those sets—a fundamentally different operation, executed in a single pass by the query planner.

Hibernate's job is to bridge these paradigms. The problem is that this mapping is lossy. When you write code that ignores the underlying execution model, Hibernate generates SQL that is technically correct but operationally catastrophic.


The N+1 Problem

Consider a realistic scenario: you're building an internal admin endpoint that returns a list of open support tickets, along with the name of the assigned agent and their current workload (total tickets assigned to them).

Your entities look like this:

@Entity
public class Ticket {
    @Id
    private Long id;
    private String subject;
    private String status;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "agent_id")
    private Agent assignedAgent;
}

@Entity
public class Agent {
    @Id
    private Long id;
    private String name;

    @OneToMany(mappedBy = "assignedAgent")
    private List<Ticket> tickets;
}
Enter fullscreen mode Exit fullscreen mode

And your service layer looks like this:

public List<TicketSummaryDTO> getOpenTicketSummaries() {
    List<Ticket> tickets = ticketRepository.findByStatus("OPEN");

    return tickets.stream()
        .map(ticket -> {
            Agent agent = ticket.getAssignedAgent();  // lazy load #1

            return new TicketSummaryDTO(
                ticket.getId(),
                ticket.getSubject(),
                agent.getName(),
                agent.getTickets().size()  // lazy load #2
            );
        })
        .toList();
}
Enter fullscreen mode Exit fullscreen mode

This code is readable and structurally sensible. But with FetchType.LAZY (the JPA default), here is what Hibernate actually executes against the database — assuming 200 open tickets assigned across 40 agents:

-- [Query 1] The initial fetch — the "1" in N+1.
-- Returns 200 rows. Hibernate now holds 200 Ticket proxies,
-- each with an uninitialized assignedAgent reference.
SELECT * FROM tickets WHERE status = 'OPEN';


-- [Queries 2–201] Hibernate fires one query per Ticket to resolve each assignedAgent proxy.
-- This happens inside the stream(), the moment agent.getName() is called.
SELECT * FROM agents WHERE id = 12;  -- ticket 1  → resolves agent 12
SELECT * FROM agents WHERE id = 47;  -- ticket 2  → resolves agent 47
SELECT * FROM agents WHERE id = 12;  -- ticket 3  → resolves agent 12 AGAIN
                                     -- The 1st-level cache would prevent this only if agent 12
                                     -- was already fully loaded before this proxy was accessed.
                                     -- In a stream(), access order depends on the data,
                                     -- so duplicate fetches are common.
-- ... repeated for all 200 tickets


-- [Queries 202–241] For each unique agent encountered, Hibernate loads their entire
-- ticket collection to satisfy the .size() call — transferring records you will
-- immediately discard, just to get a count.
-- Hibernate's @LazyCollection(LazyCollectionOption.EXTRA) can replace this with a
-- clean SELECT COUNT(...), but writing the native query (Solution #3) is the
-- superior architectural choice when you're building a DTO anyway.
SELECT * FROM tickets WHERE agent_id = 12;  -- loads every ticket for agent 12
SELECT * FROM tickets WHERE agent_id = 47;  -- loads every ticket for agent 47
-- ... repeated for all 40 unique agents


-- Grand total: 1 + 200 + 40 = 241 queries minimum.
-- In the worst case (no cache hits on agents): 1 + 200 + 200 = 401 queries.
Enter fullscreen mode Exit fullscreen mode

Why This Hurts More Than You Expect

The database itself is rarely the bottleneck here. A primary-key lookup with a good index is sub-millisecond. The damage comes from the network round-trip on each query.

Assume a conservative 1ms round-trip between your app server and database—realistic for services in the same VPC:

Scenario Queries Network Overhead
Naive lazy loading ~401 ~401ms
With Session-level deduplication ~241 ~241ms
Single optimized query 1 ~1ms

That 400ms is pure blocking wait—your application thread is parked while TCP packets traverse the wire. At low traffic, this is survivable. Under load, with dozens of concurrent requests hitting the same endpoint, you exhaust your thread pool and your HikariCP connection pool simultaneously. What looked like a 400ms endpoint becomes a 4,000ms one under modest concurrency.


When Lazy Loading Is the Right Default

The N+1 example above involves bulk reads, which makes it tempting to conclude that lazy loading is always wrong. It isn't. Lazy loading was designed for exactly this scenario: a single-entity fetch with conditional business logic.

Consider a ticket detail endpoint that checks whether the assigned agent is overloaded — but only if the ticket is high priority:

@Transactional(readOnly = true)
public TicketDetailDTO getTicketDetail(Long ticketId) {
    Ticket ticket = ticketRepository.findById(ticketId)
        .orElseThrow(() -> new EntityNotFoundException("Ticket not found"));

    if (ticket.getPriority() == Priority.HIGH && ticket.getStatus() != Status.RESOLVED) {
        // Only reached for high-priority tickets
        Agent agent = ticket.getAssignedAgent();   // 1 PK lookup
        int workload = agent.getTickets().size();  // 1 PK lookup

        if (workload > 20) {
            escalationService.flag(ticket);
        }
    }

    return mapToDTO(ticket);
}
Enter fullscreen mode Exit fullscreen mode

For any ticket that isn't high priority, this executes exactly one query. The lazy loads inside the if block never fire. For high-priority tickets, it executes three queries total: the ticket, the agent, and the agent's ticket collection — each a direct primary-key lookup against an indexed column, costing roughly 1ms each.

This is not the N+1 problem. N+1 occurs when the same lazy load fires repeatedly inside a loop over a collection. Here, each query fires at most once per request. Three indexed PK lookups at ~3ms total is not a performance issue — it's the system working correctly. You don't need a JOIN FETCH here. A heavy join on every single request would be a pessimisation, not an optimisation.

Why FetchType.EAGER Is Still the Wrong Reflex

A developer unfamiliar with the problem might look at the two lazy loads and reach for FetchType.EAGER to eliminate them:

@ManyToOne(fetch = FetchType.EAGER)  // ❌
@JoinColumn(name = "agent_id")
private Agent assignedAgent;
Enter fullscreen mode Exit fullscreen mode

This is a global change to the entity. It doesn't just affect this endpoint — it forces a join on every call to findById, findAll, findByStatus, and every other repository method in the application. Every ticket fetch now loads the agent and their entire ticket collection, regardless of whether the caller needs it. You've optimised for the rarest branch and penalised everything else.

The correct mental model is this: FetchType.LAZY is the right default for single-entity conditional access. JOIN FETCH and batch loading are the right tools for collections and loops. Let the lazy loads fire when they're cheap and conditional; reach for explicit fetch strategies only when you know you're operating at scale.

One boundary condition worth knowing: lazy loads require an active Hibernate Session. If a detached entity is passed across a layer boundary and a proxy is accessed outside the original @Transactional context, Hibernate will throw a LazyInitializationException. That exception is not a signal to add EAGER — it's a signal that your transaction boundary is in the wrong place.


Solutions, In Order of Preference

1. JPQL JOIN FETCH

If you know at query time that you'll need the relationship, tell Hibernate to fetch it in the initial query. The cleanest way to do this in Spring Data JPA is a @Query annotation with JOIN FETCH:

@Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {

    @Query("SELECT t FROM Ticket t JOIN FETCH t.assignedAgent WHERE t.status = :status")
    List<Ticket> findByStatusWithAgent(@Param("status") String status);
}
Enter fullscreen mode Exit fullscreen mode

This produces a single SQL join:

SELECT t.*, a.*
FROM tickets t
INNER JOIN agents a ON t.agent_id = a.id
WHERE t.status = 'OPEN';
Enter fullscreen mode Exit fullscreen mode

One round-trip. The tradeoff is result set size: a join duplicates the agent's columns across every ticket row they're assigned to. For OLTP workloads with reasonable cardinality, this is almost always the right trade.

Caveat: If you add a second JOIN FETCH on a collection in the same query (e.g., fetching both assignedAgent and some other @OneToMany), Hibernate will throw a MultipleBagFetchException. You can work around this by converting List to Set on your collections, but be aware this changes equality semantics and can cause subtle bugs. When you need multiple collections, batch loading is usually the better fit.

2. @BatchSize and @EntityGraph

When joins create unacceptable result set inflation, batch loading is a better fit. Hibernate's @BatchSize annotation tells it to replace individual SELECT ... WHERE id = ? queries with SELECT ... WHERE id IN (?, ?, ...) batches:

@Entity
public class Agent {
    @Id
    private Long id;

    @OneToMany(mappedBy = "assignedAgent")
    @BatchSize(size = 50)
    private List<Ticket> tickets;
}
Enter fullscreen mode Exit fullscreen mode

Instead of N queries, Hibernate issues ceil(N / batchSize) queries. For 40 agents with a batch size of 50, that's 1 query instead of 40.

Alternatively, @EntityGraph lets you declare fetch behavior at the query site without modifying the entity itself—useful when different callers need different fetch strategies on the same entity:

@Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {

    @EntityGraph(attributePaths = {"assignedAgent"})
    List<Ticket> findByStatus(String status);
}
Enter fullscreen mode Exit fullscreen mode

This generates a left outer join under the hood, similar to JOIN FETCH, but without requiring a custom JPQL query.

Note: For our specific DTO example, this only solves the first N+1 (Tickets → Agents). Calling .size() on the agent's tickets will still trigger lazy queries unless you also include "assignedAgent.tickets" in the graph — which risks a Cartesian product and likely defeats the purpose.

3. Write the Query Yourself

For read-heavy endpoints that aggregate data or return partial projections, bypass Hibernate's entity model entirely. You're building a response DTO—you don't need dirty tracking, optimistic locking, or a managed entity lifecycle. You're paying for all of that overhead and throwing it away.

Spring Data JPA supports projecting directly into a DTO via constructor expressions in JPQL:

@Query("""
    SELECT new com.yourapp.dto.TicketSummaryDTO(
        t.id,
        t.subject,
        a.name,
        COUNT(all_t.id)
    )
    FROM Ticket t
    JOIN t.assignedAgent a
    LEFT JOIN Ticket all_t ON all_t.assignedAgent = a
    WHERE t.status = :status
    GROUP BY t.id, t.subject, a.id, a.name
""")
List<TicketSummaryDTO> findOpenTicketSummaries(@Param("status") String status);
Enter fullscreen mode Exit fullscreen mode

Or drop to JdbcTemplate entirely for anything complex enough that JPQL becomes harder to read than SQL:

@Repository
public class TicketQueryRepository {

    private final JdbcTemplate jdbc;

    public List<TicketSummaryDTO> findOpenTicketSummaries() {
        String sql = """
            SELECT
                t.id,
                t.subject,
                a.name            AS agent_name,
                COUNT(all_t.id)   AS agent_workload
            FROM tickets t
            JOIN agents a ON t.agent_id = a.id
            LEFT JOIN tickets all_t ON all_t.agent_id = a.id
            WHERE t.status = 'OPEN'
            GROUP BY t.id, t.subject, a.id, a.name
        """;

        return jdbc.query(sql, (rs, rowNum) -> new TicketSummaryDTO(
            rs.getLong("id"),
            rs.getString("subject"),
            rs.getString("agent_name"),
            rs.getInt("agent_workload")
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

This pushes aggregation into the database where it belongs, transfers only the columns you need, and involves zero Hibernate machinery.


Diagnosing Your Own Codebase

Enable Hibernate's SQL logging and exercise your endpoints:

# application.properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE  # shows bind parameters
Enter fullscreen mode Exit fullscreen mode

Look for sequences of structurally identical queries differing only in a bind parameter value. That's the N+1 pattern. In a busy system it's immediately obvious—you'll see the same SELECT repeated dozens or hundreds of times in a single request trace.

For production diagnostics, pg_stat_statements (Postgres) or the slow query log (MySQL) will surface high-call-count queries that look cheap individually but dominate aggregate database load. A query that takes 0.5ms but executes 50,000 times per minute is a far bigger problem than a slow query that runs once.


When to Use JPA vs. When to Write SQL

The useful mental model isn't "Hibernate is bad"—it's that Hibernate has a domain where it excels and a domain where it actively works against you.

Use JPA for transactional writes. Loading an entity, applying business logic, and persisting changes is exactly what Hibernate was designed for. It handles dirty checking, optimistic locking via @Version, and transaction demarcation cleanly.

Use SQL for reads that aggregate, project, or span multiple tables. The object-graph abstraction is a poor fit for set-oriented retrieval. Forcing it results in either N+1 queries or increasingly complex fetch annotations that are just obfuscated SQL with more failure modes.

This maps to a pattern the CQRS literature has formalized: your read model and your write model have different requirements. You don't need to adopt full CQRS to internalize that lesson. Even within a standard Spring layered application, being intentional about when you lean on JPA and when you reach for JdbcTemplate will significantly improve both performance and maintainability.


Summary

The N+1 problem isn't a Hibernate bug—it's a consequence of using an abstraction without understanding its execution model.

  • Audit read-heavy endpoints with SQL logging before they reach production; treat N+1 as a build-breaking issue, not something to revisit later.
  • Use JOIN FETCH or @EntityGraph when you know a relationship will be traversed at query time.
  • Use @BatchSize when joins produce excessive result set inflation or when fetching multiple collections.
  • Use JPQL constructor expressions or JdbcTemplate for aggregations and reporting queries—don't hydrate entities you're going to immediately project away.
  • In production, watch query count alongside query duration; a fast query executed 500 times per request is still a catastrophic query.

The next layer of this problem is understanding what your database actually does with the SQL Hibernate generates. EXPLAIN ANALYZE in Postgres—or EXPLAIN FORMAT=JSON in MySQL—will show you whether your joins are using indexes, what the estimated vs. actual row counts look like, and where the query planner is making bad decisions. That's where the real tuning happens.

Top comments (0)