Introduction
The N+1 problem is one of the traps waiting for developers who use ORMs carelessly. Without awareness of its existence, it's easy to write code that executes dozens or even hundreds of unnecessary database queries, dramatically degrading application performance. In this post, I'll explain the N+1 problem, show when it appears, and how to avoid it.
When Does the N+1 Problem Appear
The classic example of the N+1 problem involves a one-to-many relationship, for example author → books. Imagine we have Author and Book tables and we want to fetch all authors along with their books.
With a naive approach we might execute the following queries:
-- Fetch all authors (1 query)
SELECT * FROM Author;
-- Result: Jan, Anna, Piotr
-- For each author fetch their books (N queries)
SELECT * FROM Book WHERE author_id = 1; -- Jan's books
SELECT * FROM Book WHERE author_id = 2; -- Anna's books
SELECT * FROM Book WHERE author_id = 3; -- Piotr's books
We executed 4 queries: one to fetch authors plus a separate query for each of the three authors. This is exactly the N+1 problem – one query plus N additional queries, where N is the number of fetched records.
Of course in plain SQL we would avoid this using JOIN:
SELECT a.*, b.*
FROM Author a
LEFT JOIN Book b ON a.id = b.author_id;
Now a single query returns all the needed data.
The N+1 Problem in Hibernate
Link to the repository with source code used in this post:
HibernatePlayground - blog/2025-12-20-n-plus-1
When working with an ORM like Hibernate, the N+1 problem appears unnoticed. Consider a simple piece of code:
var authors = session.createQuery("from Author", Author.class).list();
for (Author author : authors) {
System.out.println(author.getName() + " -> " + author.getBooks().size());
}
This happens because of the lazy loading mechanism – Hibernate does not load relations by default until they are needed. This saves memory, but if we then iterate over the collection and access lazy relations, each access causes an additional query to the database.
EAGER vs LAZY – Does It Solve the Problem?
Changing the fetch strategy to EAGER (e.g. @OneToMany(fetch = FetchType.EAGER)) does not solve the N+1 problem. Hibernate will still execute separate queries for each author – the only difference is that it does so immediately when fetching authors, rather than on first access to the collection. The N+1 problem remains, the queries just execute earlier.
This is visible in the Hibernate logs:
LAZY:
select a1_0.id,a1_0.name from Author a1_0
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Jan -> Book 1
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Anna -> Book 2
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Bob -> Book 3
EAGER:
select a1_0.id,a1_0.name from Author a1_0
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
select b1_0.author_id,b1_0.id,b1_0.title from Book b1_0 where b1_0.author_id=?
Jan -> Book 1
Anna -> Book 2
Bob -> Book 3
The only difference is the order, but still – if we had 100 authors, we would execute 101 queries instead of one, regardless of whether we use LAZY or EAGER.
How to Fix the N+1 Problem
JOIN FETCH – The Basic Solution
The simplest and most commonly used solution is to use JOIN FETCH in an HQL query:
var authors = session.createQuery(
"from Author a JOIN FETCH a.books",
Author.class
).list();
for (Author author : authors) {
System.out.println(author.getName() + " -> " + author.getBooks().size());
}
Now Hibernate will execute a single query with a JOIN, loading authors and their books at the same time:
select a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name
from Author a1_0
join Book b1_0 on a1_0.id=b1_0.author_id
Jan -> 1
Anna -> 1
Bob -> 1
A Potential Pitfall – Pagination with JOIN FETCH
Most of the time when we fetch data from the database, we want to apply pagination, meaning we only fetch a few records. A naive approach might look like this:
var authors = session.createQuery(
"from Author a JOIN FETCH a.books ORDER BY a.id",
Author.class
)
.setFirstResult(0)
.setMaxResults(3)
.list();
Output:
=== DEMO 3: JOIN FETCH with pagination (wrong) ===
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
select a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name
from Author a1_0
join Book b1_0 on a1_0.id=b1_0.author_id
order by a1_0.id
Fetched authors: 3
Jan -> 1
Anna -> 1
Bob -> 1
At first glance everything looks fine, but notice the HHH90003004 warning and the absence of LIMIT in the SQL query.
What actually happens:
- Hibernate fetches ALL rows from the database (all authors and all their books)
- Loads them into JVM memory
- Only then selects the first 3 authors in application memory
In this example with a small amount of data (3 authors) it's not a problem, but with a larger dataset it would be a disaster.
Correct Pagination – Two Queries
To fix the problem, the best approach is to split it into two queries:
- First fetch only IDs with pagination (in the database!)
- Then use those IDs with
JOIN FETCH
// 1. Fetch only IDs with pagination
List<Long> authorIds = session.createQuery(
"select a.id from Author a order by a.id",
Long.class
)
.setFirstResult(0)
.setMaxResults(3)
.list();
// 2. Fetch authors with books
var authors = session.createQuery(
"select distinct a from Author a join fetch a.books where a.id in :ids",
Author.class
)
.setParameter("ids", authorIds)
.list();
System.out.println("Authors: " + authors.size());
for (Author a : authors) {
System.out.println(a.getName() + " -> " + a.getBooks().size());
}
Output:
=== DEMO 4: pagination FIXED ===
select a1_0.id
from Author a1_0
order by a1_0.id
offset ? rows fetch first ? rows only
select distinct a1_0.id,b1_0.author_id,b1_0.id,b1_0.title,a1_0.name
from Author a1_0
join Book b1_0 on a1_0.id=b1_0.author_id
where a1_0.id in (?,?,?)
Authors: 3
Author 1 -> 10
Author 2 -> 10
Author 3 -> 10
Now:
LIMIT(in H2:fetch first ? rows only) works at the database level- We only fetch the needed data (3 authors + their books)
- Two efficient queries instead of loading everything into memory
Summary
To avoid the N+1 problem in Hibernate:
- Recognize situations where we iterate over a collection and access relations
- Use
JOIN FETCHto fetch all needed data in one go - With pagination, apply the two-step approach: IDs first, then
JOIN FETCH - Remember that switching from
LAZYtoEAGERdoes not solve the N+1 problem