Performance Tips (The N+1 Problem)
Learn what causes the N+1 query problem in Hibernate, how to spot it in your SQL logs, and how to fix it with JOIN FETCH and other strategies.
Introduction
The N+1 problem is the single most common Hibernate performance issue, and it is a direct consequence of two features you already learned: lazy fetching and how associations are loaded. This lesson shows exactly how it happens, how to catch it before it reaches production, and the standard ways to fix it.
- Exactly what causes the N+1 query problem
- How to spot it by reading SQL logs
- Fixing it with JOIN FETCH
- Fixing it with entity graphs and batch fetching
- How to choose between the available fixes
What Is the N+1 Problem?
It happens when you run one query to fetch a list of N entities, and then, because an association on each of them is lazily loaded, Hibernate runs one additional query per entity to fetch that association — N extra queries on top of the original 1, hence "N+1".
@Entitypublic class Department { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY) private List<Employee> employees = new ArrayList<>();
// Getters and setters}List<Department> departments = session .createQuery("FROM Department", Department.class) .getResultList();
for (Department dept : departments) { System.out.println(dept.getName() + ": " + dept.getEmployees().size() + " employees");}Spotting It in the Logs
With SQL logging enabled (from the previous lesson), the N+1 problem is unmistakable: one initial query, followed by a burst of near-identical queries — one per row.
Click Run to see what this code prints.
One query fetching a list, followed by many repeats of the exact same query shape with only the bound parameter changing, is the signature of N+1. With 3 departments you get 4 queries; with 300 departments you get 301.
Fix 1: JOIN FETCH
The most direct fix is to eagerly fetch the association in the same query, using HQL's JOIN FETCH, so Hibernate retrieves departments and their employees in a single round trip.
List<Department> departments = session.createQuery( "SELECT DISTINCT d FROM Department d JOIN FETCH d.employees", Department.class) .getResultList();
for (Department dept : departments) { System.out.println(dept.getName() + ": " + dept.getEmployees().size() + " employees");}Click Run to see what this code prints.
One query instead of N+1. The DISTINCT keyword matters here — without it, a department with three employees would appear three times in the result list, once per joined row.
Fix 2: Entity Graphs
When you are using the standard JPA EntityManager (common in Spring applications), an @NamedEntityGraph describes which associations to fetch eagerly for a specific query, without changing the entity's default fetch type globally.
import javax.persistence.*;
@Entity@NamedEntityGraph( name = "Department.withEmployees", attributeNodes = @NamedAttributeNode("employees"))public class Department { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY) private List<Employee> employees = new ArrayList<>();}EntityGraph<?> graph = entityManager.getEntityGraph("Department.withEmployees");
List<Department> departments = entityManager.createQuery( "SELECT d FROM Department d", Department.class) .setHint("javax.persistence.fetchgraph", graph) .getResultList();Fix 3: Batch Fetching
When eagerly joining is not appropriate — for example, when several different collections would need joining and that would multiply row counts badly — batch fetching reduces N+1 to a small, fixed number of queries instead of eliminating extra queries entirely.
@Entitypublic class Department { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY) @org.hibernate.annotations.BatchSize(size = 10) private List<Employee> employees = new ArrayList<>();}Click Run to see what this code prints.
Instead of 30 individual queries, batching groups the lazy loads into 3 IN-clause queries of 10 ids each — a large reduction with no change to how the code that reads employees is written.
Choosing a Fix
| Fix | Best When |
|---|---|
| JOIN FETCH | You know exactly which associations a specific query needs, and only one collection is joined |
| Entity graphs | You are using JPA/Spring Data JPA and want per-query control without HQL string changes |
| Batch fetching (@BatchSize) | Multiple different collections would need joining, or JOIN FETCH would cause row-count multiplication |
Common Mistakes
- Switching every association to FetchType.EAGER as a blanket fix, which just moves the N+1 cost earlier and adds overfetching everywhere.
- Using JOIN FETCH on more than one collection association in the same query, causing a cartesian-product row explosion.
- Forgetting DISTINCT with JOIN FETCH on a collection, leading to duplicate rows in the result list.
- Never turning on SQL logging, so N+1 goes unnoticed until it shows up as a production slowdown.
- Fixing N+1 in a demo with 3 rows and never testing with a realistic data volume where it actually hurts.
Best Practices
- Default associations to FetchType.LAZY, and fetch eagerly only per-query, only where actually needed.
- Enable SQL logging in development and watch for repeated query shapes as a routine check, not just when performance complaints arrive.
- Reach for JOIN FETCH for a single needed collection, and batch fetching (@BatchSize) when multiple collections are involved.
- Test with realistic data volumes — N+1 is invisible with 3 rows and painful with 3,000.
- Consider a tool like Hibernate's statistics API or a query-count assertion in tests to catch regressions automatically.
Frequently Asked Questions
No, it also happens with lazy @ManyToOne/@OneToOne associations — accessing a lazy Employee.department for each of N employees triggers N extra queries the same way.
It removes the symptom in that one spot but fetches the association on every load of that entity everywhere in the application, which usually causes more harm than it fixes.
Yes — it is built on Hibernate, so any lazy association accessed in a loop after a findAll() has exactly the same N+1 risk.
Even a handful is worth fixing on a hot path, but the real danger is that N scales with data volume — a fix that seems unnecessary at 10 rows can become critical at 10,000.
Key Takeaways
- N+1 happens when a list query is followed by one lazy-load query per row.
- It is visible in SQL logs as one query followed by many near-identical repeats.
- JOIN FETCH solves it for a single needed association in one query.
- Entity graphs give per-query eager fetching control in JPA/Spring applications.
- Batch fetching (@BatchSize) is the right tool when multiple collections would otherwise need separate joins.
Summary
The N+1 problem is the direct cost of lazy loading used carelessly in a loop. Once you know to look for the pattern in your SQL logs — one query, then a burst of repeats — JOIN FETCH, entity graphs, and batch fetching give you three targeted ways to collapse it back down to a small, predictable number of queries.