Lazy loading and the N+1 problem
What a lazy association really is, why a loop over twenty orders can send twenty-one queries, and the fixes — fetch joins, entity graphs and batch fetching.
After this lesson you can
- State the default fetch type of each JPA association and what "lazy" loads
- Count the queries an N+1 loop sends and recognise it in a SQL log
- Fix it with a fetch join, an entity graph or batch fetching, and know each one's cost
An association can be loaded eagerly (with the owning entity) or
lazily (only when the code first touches it). JPA's defaults:
@ManyToOne and @OneToOne are EAGER; @OneToMany and
@ManyToMany are LAZY. A lazy to-one association is a proxy — an
object of a generated subclass that holds only the id — and a lazy
collection is a wrapper that has not been filled. The first call that
needs real data (order.getCustomer().getName(), order.getItems().size())
makes Hibernate send a SELECT.
N+1
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order o : orders) {
total += o.getCustomer().getCreditLimit(); // 1 query per customer
}
One query for the list, then one more per association the loop
touches: N+1. The persistence context helps a little — a customer
already loaded is not loaded twice — but with N different customers it
is N extra round trips, each small and fast in a test and slow and
numerous in production. Making the association EAGER does not fix it:
for a JPQL query such as findAll(), Hibernate still loads eager
associations with separate selects after the main query.
import java.util.*; public class Solution { record Order(int id, int customerId) {} static List<String> lazyLoop(List<Order> orders) { List<String> sql = new ArrayList<>(); sql.add("select * from orders"); Set<Integer> persistenceContext = new HashSet<>(); for (Order o : orders) { // o.getCustomer().getName(): the proxy initialises itself if needed if (persistenceContext.add(o.customerId())) { sql.add("select * from customer where id=" + o.customerId()); } } return sql; } static List<String> fetchJoin(List<Order> orders) { return List.of("select o.*, c.* from orders o left join customer c on c.id = o.customer_id"); } public static Map<String, Object> run() { List<Order> orders = List.of(new Order(1, 10), new Order(2, 11), new Order(3, 10), new Order(4, 12)); Map<String, Object> out = new LinkedHashMap<>(); List<String> lazy = lazyLoop(orders); out.put("lazy", lazy); out.put("lazy statements", lazy.size()); out.put("fetch join statements", fetchJoin(orders).size()); return out; }}The fixes
- Fetch join —
select o from Order o join fetch o.customer. One query; the customers come back in the same rows. Useleft join fetchif the association can be null, or orders without one disappear. - Entity graph —
@EntityGraph(attributePaths = "customer")on a repository method gives the same fetch join to a derived query. - Batch fetching —
spring.jpa.properties.hibernate.default_batch_fetch_size=50(or@BatchSizeon the association). Touching one uninitialised proxy loads up to 50 of the pending ones with a single query that selects them by a list of ids, so N customers cost aboutceil(N / 50)extra queries instead of N. - DTO projection — select exactly the columns the screen needs; no entities, no proxies, no lazy loading.
Fetch-joining a collection multiplies rows (one per item), which is
why combining it with pagination is a trap: Hibernate cannot apply the
LIMIT in SQL, so it warns and pages in memory after loading every
row. Page the parent ids first, then fetch their collections.
Fetch-joining two collections at once multiplies them: an order
with 10 items and 10 payments comes back as 100 rows. If both
collections are bags (a List without @OrderColumn), Hibernate
refuses the query with MultipleBagFetchException. Making one of them
a Set makes the query legal but keeps the multiplication; fetching
the second collection in its own query avoids both.
import java.util.*; public class Solution { static final class Session { boolean open = true; final List<String> sql = new ArrayList<>(); } static final class CustomerProxy { final long id; final Session session; String name; // only the id is known up front CustomerProxy(long id, Session s) { this.id = id; this.session = s; } String getName() { if (name == null) { if (!session.open) throw new IllegalStateException( "LazyInitializationException: could not initialize proxy [Customer#" + id + "] - no Session"); session.sql.add("select * from customer where id=" + id); name = "Customer " + id; } return name; } } public static List<String> run() { List<String> out = new ArrayList<>(); Session tx = new Session(); CustomerProxy loadedInside = new CustomerProxy(7, tx); CustomerProxy touchedLater = new CustomerProxy(8, tx); out.add("inside the transaction: " + loadedInside.getName()); tx.open = false; // the service method returned out.add("after it, already loaded: " + loadedInside.getName()); try { out.add("after it, never loaded: " + touchedLater.getName()); } catch (IllegalStateException e) { out.add(e.getMessage()); } out.add("queries sent: " + tx.sql); return out; }}Spring Boot hides this by default with open session in view
(spring.jpa.open-in-view=true, announced by a warning at startup):
the persistence context stays open for the whole web request, so lazy
loads in the controller or during serialisation silently succeed —
each one a query outside any transaction, often an N+1 nobody sees.
Many teams set it to false and fetch what each use case needs inside
the service.
Common mistakes
- "Fixing"
LazyInitializationExceptionby making associationsEAGER, which loads them everywhere and still issues N+1 selects for JPQL queries. - Using an inner
join fetchon an optional association and silently losing the parents that have none. - Never looking at the SQL: enable
logging.level.org.hibernate.SQL=debugin development and count the statements a page sends.
What interviewers ask
- What is the N+1 problem and how do you detect it? — one query for the parents plus one per lazy association touched; visible in the SQL log or Hibernate statistics.
- How do you fix it? — fetch join or entity graph for the use case, batch fetching as a global safety net, or a DTO projection.
- What causes
LazyInitializationException, and what does open-in-view have to do with it? — touching a proxy after its persistence context closed; open-in-view keeps it open for the whole request, hiding the problem at the cost of hidden queries.
Exercise · Pro
Two collections, one query
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade