Have you ever noticed that a simple list of entities in JPA takes seconds to load, and after enabling SQL logs you see dozens of identical queries executed for every parent row? Welcome to the N+1 problem — the most subtle and common performance killer in any Java application using Hibernate. We at Meteora Web have seen it in client projects where developers believed they wrote clean code — but were unknowingly paying the query bill.
This guide shows you where N+1 comes from, how to diagnose it with concrete tools, and most importantly how to fix it with the best practices we use in our Laravel and Spring Boot projects. Because an ORM is not magic: if you don't understand it, it will cost you performance and money.
What Causes the N+1 Problem in JPA and Hibernate
N+1 occurs when, to retrieve N child entities associated with a parent entity, Hibernate first executes one query to fetch the list of parents, and then N separate queries for each child. The result? Response times that grow linearly with data — and on real volumes become unmanageable.
Concrete example: Suppose we have Author and Book.
@Entity
public class Author {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;
}
@Entity
public class Book {
@Id
private Long id;
private String title;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
}
Now, when you call author.getBooks() for each author, Hibernate executes a query per author. With 100 authors you get 1 (list authors) + 100 (books per author) = 101 queries.
Sponsored Protocol
How to Diagnose N+1 with SQL Logs
Enable query logging in application.properties:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
If you see dozens of identical SELECTs for the same table after the main query, you have N+1. You can also use Hibernate Statistics or the JPA Buddy plugin in IntelliJ to count queries.
How to Fix N+1 Using Fetch Join in JPQL
The most direct solution is to use a fetch join in the JPQL query. Instead of lazy loading, you load everything in a single query with JOIN FETCH.
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
This query executes a single SELECT with an SQL join. Hibernate loads authors and books in one go. Zero extra queries. Be careful: if you need to avoid duplicates (when an author has many books), add DISTINCT:
Sponsored Protocol
@Query("SELECT DISTINCT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
When Not to Use Fetch Join
If you have more than one collection to load (e.g., books and articles), a single multiple join can cause a Cartesian product. In that case, prefer @EntityGraph or multiple queries with batch fetching.
What Is the Difference Between FetchType.LAZY and EAGER for N+1
FetchType.LAZY (default for @OneToMany) is the right choice: it loads children only when actually accessed. But if you access all children for every parent, N+1 kicks in.
FetchType.EAGER always loads everything, even when not needed. It doesn't solve N+1: it causes the same problem but always, even for pages that don't need children. Moreover, with EAGER on multiple relationships, Hibernate may execute extra queries or multiple joins that degrade performance.
Sponsored Protocol
The rule we follow: always use LAZY as default, then optimize with fetch join or @EntityGraph where needed.
How to Use @EntityGraph to Solve N+1 Without Writing JPQL
@EntityGraph is a Spring Data JPA annotation that lets you specify which associations to load eagerly for a specific query, without changing the global fetch type.
@EntityGraph(attributePaths = {"books"})
@Query("SELECT a FROM Author a")
List<Author> findAllWithBooks();
Spring Data JPA automatically generates a fetch join for the specified relationships. Less code, same result. It also works with derived query methods:
@EntityGraph(attributePaths = {"books"})
List<Author> findByNameContaining(String name);
Caution: attributePaths can include nested paths, e.g., "books.publisher". But avoid loading too many relationships in a single graph — you risk the Cartesian product again.
Batch Fetching: An Elegant Alternative to Reduce N+1 Without Changing Queries
If you cannot modify every query (e.g., using a generic repository), configure batch fetching. Hibernate collects primary keys of multiple entities into a single WHERE IN clause, reducing queries from N to N/batch.
Sponsored Protocol
In application.properties:
spring.jpa.properties.hibernate.default_batch_fetch_size=10
Or at entity level with @BatchSize:
@Entity
@BatchSize(size = 10)
public class Author {
// ...
}
With batch size 10, 100 authors generate 10 queries instead of 100. Not perfect like a single join, but a good compromise when you don't control the queries.
How to Avoid N+1 with Named Entity Graphs and Subclasses
For advanced use, Spring Data JPA supports @NamedEntityGraph at entity level. Define a reusable graph and apply it in multiple repositories.
@Entity
@NamedEntityGraph(name = "Author.books", attributeNodes = @NamedAttributeNode("books"))
public class Author {
// ...
}
// Repository
@EntityGraph("Author.books")
List<Author> findAll();
This technique centralizes loading logic and makes it more maintainable.
Tools to Detect N+1 in Production
Beyond logs, these tools can save your life:
- Hibernate Statistics: enable with
spring.jpa.properties.hibernate.generate_statistics=true. Shows query count, average time, and flushes. - JPA Inspector (IntelliJ plugin): highlights slow queries during development.
- Hypersistence Optimizer: analyzes code and suggests fixes.
- Spring Boot Actuator with Hibernate metrics.
We always use statistics in our staging environments before going to production. An app that loads 10 rows locally won't show the problem — with 10,000 rows it becomes obvious.
Sponsored Protocol
Best Practices for ORM Performance with JPA and Hibernate
- Never use
FetchType.EAGERon collection associations. Prefer LAZY and optimize at the point of use. - Use
JOIN FETCHor@EntityGraphfor every query that needs to load children. - Set a reasonable
default_batch_fetch_size(10-20) to cover unoptimized cases. - Log queries in development and use statistics in staging.
- Avoid loading two different collections in a single query — use
@BatchSizeor separate queries.
Remember: an ORM is not a magic wand. It's a powerful automation that needs to be controlled. If you let it loose, it will fill your logs with queries. If you guide it, it delivers performance comparable to hand-written SQL but with the flexibility of objects.
Want to dive deeper into performance management in a Spring Boot application? Check out our Java and JVM Modern pillar article.