Performance Tuning & the N+1 Problem
The N+1 problem shown concretely, and fixing it with JOIN FETCH, @EntityGraph, and batch fetching.
The N+1 problem, concretely
Consider loading a list of orders and, for each one, reading its customer's email — a completely ordinary thing to do:
List<Order> orders = orderRepository.findAll(); // query #1
for (Order order : orders) {
System.out.println(order.getCustomer().getEmail()); // lazy -- triggers its OWN query, per order
}
With customer mapped FetchType.LAZY (the recommended default), the generated SQL looks like this for, say, 50 orders:
-- Query 1: load the orders
SELECT * FROM orders;
-- Query 2 through 51: one SEPARATE query per order, to load its customer
SELECT * FROM customers WHERE id = ?; -- for order #1's customer_id
SELECT * FROM customers WHERE id = ?; -- for order #2's customer_id
-- ...48 more, one per order
One query became 51 — "N+1," where N is the number of parent rows. Each individual query is fast, but round-tripping to the database N separate times, instead of once, is often the single biggest, most easily overlooked source of slowness in a JPA/Hibernate application — and it's completely invisible from reading the Java code above, which looks entirely ordinary.
Fixing it with JOIN FETCH
A JPQL query can eagerly load an association as part of the same query, in one round trip, using JOIN FETCH instead of a plain JOIN:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
}
SELECT o.*, c.*
FROM orders o
JOIN customers c ON o.customer_id = c.id;
One query, with the customer data already populated in memory — accessing order.getCustomer().getEmail() afterward triggers no additional query at all, because Hibernate already has the data from the join.
A plain JOIN (without FETCH) only affects the WHERE/filtering logic of the query — it does not populate the association, so order.getCustomer() would still lazily trigger a separate query afterward. JOIN FETCH is what actually tells Hibernate to load and attach the associated data.
Fixing it with @EntityGraph
@EntityGraph achieves the same result — declaring which associations to eagerly load for a specific query — without hand-writing JPQL:
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);
}
This is often preferable to JOIN FETCH when the base query is otherwise just a plain derived method or the inherited findAll() — the @EntityGraph layers "and also fetch these associations" onto the query Spring Data would have generated anyway, without needing a full custom @Query.
Batch fetching: when you can't avoid some extra queries
Sometimes a collection genuinely can't be one big join (a very wide fan-out, or multiple different collections on the same entity, which Hibernate can't safely JOIN FETCH together at once). Batch fetching doesn't eliminate the extra queries, but groups them — instead of one query per parent, it's one query per batch of parents:
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@BatchSize(size = 20)
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
}
-- Instead of one query per customer's orders, Hibernate batches lookups:
SELECT * FROM orders WHERE customer_id IN (?, ?, ?, ..., ?); -- up to 20 ids at once
The same effect can be set globally instead of per-entity:
spring.jpa.properties.hibernate.default_batch_fetch_size=20
Turning 50 individual customer-lookup queries into 3 batched queries (for a batch size of 20) is a smaller improvement than eliminating the extra round trips entirely with JOIN FETCH, but it's the right tool exactly when a genuine single join isn't practical.
Comparing the fixes
| Approach | Extra queries | Best for |
|---|---|---|
| Do nothing (plain lazy loading) | N (one per parent) | Never, once N is more than a handful — this is the bug |
JOIN FETCH |
0 — one single query | A specific query where you know up front you need the association |
@EntityGraph |
0 — one single query | Same as above, layered onto a derived/inherited query method instead of custom JPQL |
Batch fetching (@BatchSize) |
N / batch size | Collections that can't cleanly be one join (multiple collections at once, very large fan-outs) |
Finding N+1 in the first place
The problem is invisible in application code — it shows up only in the actual SQL Hibernate issues. Enabling SQL logging during development makes it visible immediately:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace
Seeing the same SELECT * FROM customers WHERE id = ? repeated dozens of times in the console, right after a single findAll(), is the unmistakable signature of N+1 in progress.
Common mistakes
- Only noticing N+1 in production once a table has grown large enough for the extra round trips to actually show up as real latency — it's present (just harmless) from the very first line of code that introduces it, and worth checking for in code review, not just profiling later.
- Defaulting associations to
FetchType.EAGERto "fix" N+1 — this just moves the same over-fetching problem to every query that touches the entity, whether that query needed the association or not, instead of fetching it deliberately where it's actually needed. - Using
JOIN FETCHwith more than one collection association in the same query — Hibernate can't cartesian-join multiple collections safely in one query (it throwsMultipleBagFetchExceptionforLists in this situation) — fetch one collection per query, or useSetinstead ofList, or batch-fetch the others instead. - Never turning on SQL logging during development — N+1 is easy to fix once you can see it and nearly impossible to notice by reading Java code alone.