LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 2621 min read

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.

What You Will Learn
  • 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".

@Entity
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<>();
// Getters and setters
}
The problematic code
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.

Hibernate SQL Log

Click Run to see what this code prints.

The Tell

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");
}
Hibernate SQL Log

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<>();
}
Using the entity graph
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.

@Entity
public 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<>();
}
Hibernate SQL Log (30 departments, batch size 10)

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

FixBest When
JOIN FETCHYou know exactly which associations a specific query needs, and only one collection is joined
Entity graphsYou 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

Avoid These 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.

Next Lesson →

Common Mistakes & Best Practices