A page that ran instantly in testing takes four seconds in production. The database CPU is fine, the query log is a blur, and the code looks perfectly reasonable. Nine times out of ten this is the N+1 query problem, and it is worth understanding in depth because it is the single most common performance bug in JPA applications — and one of the easiest to write by accident.
How one loop becomes two hundred queries
Say you load a list of orders and, for each one, print the customer’s name. The code is a loop over orders, reading order.getCustomer().getName(). It reads like plain object navigation. Underneath, it is a query per iteration.
List<Order> orders = orderRepository.findAll(); // 1 query: SELECT * FROM orders
for (Order o : orders) {
// Each call lazy-loads the customer: SELECT * FROM customers WHERE id = ?
System.out.println(o.getCustomer().getName());
}
One query to fetch the N orders, then N more to fetch each order’s customer. A hundred orders means a hundred and one queries. That is the “N+1”: one initial query plus one per row.
The reason it hides is lazy loading. A @ManyToOne or @OneToMany association is a proxy by default, and touching it triggers a query the moment your code reads it — often far from where the entity was loaded, inside a service method or even a template. Nothing at the call site looks like a database access, which is exactly why nobody spots it in review.
It also explains the testing-versus-production gap. With ten rows in your dev database, eleven queries finish before you notice. With ten thousand rows and real network latency to the database, each of those round trips costs a millisecond or two, and the page falls off a cliff.
See it before you fix it
You cannot fix what you cannot see, and the whole nature of this bug is invisibility. The first move is always to make Hibernate show its work.
Do not just turn on SQL logging — the volume drowns you. Instead, count queries per request. Hibernate’s statistics expose exactly that, and a library like datasource-proxy can assert a query-count ceiling in a test so the bug can never regress silently.
# See the SQL and, more usefully, the count
spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG
# 'HikariPool ... 143 queries' after a single request is your smoking gun
The habit worth building: for any endpoint that loads a collection, glance at the query count. A list endpoint should issue a small constant number of queries regardless of how many rows it returns. If the count scales with the row count, you have an N+1, full stop. Turning this into a test assertion is the single highest-value thing you can do, because it converts an invisible regression into a red build.
Fix 1: A join fetch
The most direct fix is to tell JPA to load the association in the same query, using a JOIN FETCH.
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
Now the customers arrive alongside the orders in one query. This is the right tool when you always need the association and it is a to-one relationship. It is precise and it is fast.
The trap appears when you fetch collections. Join-fetching a @OneToMany multiplies rows — an order with five lines produces five result rows — and fetching two collections at once produces a cartesian product that can be catastrophic. Hibernate will even warn you about applying pagination in memory when you fetch a collection, which is its polite way of saying it gave up doing it in SQL. For a single collection it is fine; for two, reach for a different fix.
Fix 2: An entity graph
When you want the same entity fetched eagerly in some queries and lazily in others, an entity graph lets you specify the fetch plan per query without a custom JPQL string.
@EntityGraph(attributePaths = {"customer", "shippingAddress"})
List<Order> findByStatus(OrderStatus status);
This is my usual default for Spring Data repositories, because it keeps the fetch decision next to the query that needs it and composes cleanly with derived query methods. It solves the same to-one case as a join fetch, with less ceremony and the same underlying SQL.
Fix 3: Batch fetching
Sometimes you genuinely want lazy loading — the association is used only occasionally — but when you do touch it across a list, you want to avoid the N round trips. Batch fetching is the answer, and it is underused.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 50) // load lazy customers 50 at a time, not 1 at a time
private Customer customer;
}
With this, the first time you touch a customer, Hibernate loads up to fifty of the pending ones in a single WHERE id IN (?, ?, ...). A hundred orders becomes one query plus two, not one plus a hundred. Setting hibernate.default_batch_fetch_size globally is a low-risk, high-value default that quietly fixes a whole class of accidental N+1s across an application.
Fix 4: Do not load entities at all
The fix people reach for last is often the best: if you only need a few fields for a read, do not hydrate the full entity graph. Project directly into a DTO.
public interface OrderSummary {
Long getId();
String getCustomerName(); // resolved by the query, no lazy proxy anywhere
}
@Query("SELECT o.id AS id, o.customer.name AS customerName FROM Order o")
List<OrderSummary> findSummaries();
A projection sidesteps the entire problem. There is no lazy association to trip over because there is no entity — just the columns you asked for, in one query. For read-heavy list and report endpoints this is usually both the fastest and the simplest option, and it has the pleasant side effect of not dragging the persistence context into work it was never needed for. When the read model diverges enough from the write model, this is the doorway to fuller separation; the reasoning is the same one behind an event-driven read model.
Choosing between them
There is no universal answer, and reaching for the same fix every time is how you trade one problem for another. Use a join fetch or entity graph for a to-one association you always need. Use batch fetching when you want laziness but occasionally traverse a list. Use a projection when you are reading, not mutating. And avoid join-fetching more than one collection in a single query — that is the cartesian-product footgun.
The meta-point is that lazy loading is not the villain. It is a reasonable default that becomes a bug only when code touches a lazy association inside a loop, far from where the entity was loaded. Once you can see the query count, the fix is usually obvious; the hard part was always the seeing. If you want to watch the actual query plan Hibernate produces after you apply a fix, our guide to reading a Postgres query plan pairs directly with this, and profiling the JVM side with Java Flight Recorder will confirm whether the time is really in the database or somewhere else entirely.
The N+1 that fires after the transaction closes
There is a nastier variant worth its own section, because it survives all the fixes above and confuses people badly. In a typical Spring MVC application, the pattern known as open-session-in-view keeps the persistence context open until the HTTP response is rendered. That sounds convenient, and it hides a trap: lazy associations get loaded during serialization, when your JSON library walks the object graph to turn it into a response.
So you fix the N+1 in your repository, the query count in your service-layer test looks perfect, and the endpoint is still slow — because the extra queries now fire later, while Jackson serializes a list of entities and touches each one’s lazy customer to write it into the JSON. The bug moved downstream of where you were looking, which is exactly why it is so often missed. The query-counting habit still catches it, but only if you count queries across the whole request including rendering, not just the service call.
The cleaner structural answer is to not serialize entities at all. Map to a DTO explicitly inside the transaction, deciding deliberately what to load, and return the DTO. Then serialization touches plain objects with no database behind them, and there is nothing left to lazy-load at the wrong moment. This is the same discipline as the projection fix above, extended to the response boundary: the entity is a persistence concern and should not leak into the view layer where its laziness becomes a performance bug nobody can see in a unit test. Turning off open-session-in-view makes the problem loud — you get a LazyInitializationException instead of a silent N+1 — which is uncomfortable but far better than shipping the hidden version.
The one habit that prevents it
Assert query counts in tests for any endpoint that returns a collection. That is it. It is a two-line assertion that fails the moment someone adds an innocent getCustomer() inside a loop, and it turns the most common performance bug in JPA from a production incident into a code-review comment. Everything above is how to fix an N+1; this is how to never ship one again.