JPA N+1 Problem and Fetch Strategies
The JPA N+1 problem happens when your code runs one query to load a list of entities, then one extra query for each of them to load related data. A page of 50 orders quietly becomes 51 database round trips. It is easy to miss in development, where tables are small, and it is one of the most common reasons a Spring Boot API slows down in production.
This tutorial shows how the N+1 problem appears in the SQL log, why lazy loading causes it and why eager loading doesn't fix it. It then walks through every fetch strategy that does fix it: JOIN FETCH, @EntityGraph, batch fetching, subselect fetching and DTO projections, with the actual query counts measured on Spring Boot 4.1 with Hibernate 7. It finishes with a complete example, the pitfalls of each fix, and a test that counts queries so the problem cannot come back.
1. What Is the N+1 Problem in JPA?
The name describes the query count. 1 query loads the main list, and then N more queries load something related, one for each row in that list. Each query is fast on its own. The problem is the number of trips to the database.
In this tutorial's catalog, a product belongs to a category and has reviews. Showing 6 products with their category names should need 1 query. With the N+1 problem it needs up to 7, and with 500 products it needs up to 501.
The JPA and Hibernate tutorial explains entities and relationships if you need a refresher. This page focuses on fetching: when and how Hibernate loads related data.
2. Why the N+1 Problem Matters
- Latency adds up. Every query is a network round trip. At 2 ms per trip, 500 extra queries add a full second to one API call.
- It hides in development. With ten rows on a local database, 11 queries feel instant. The same code against production data is slow.
- It grows with your data. The query count follows the number of rows, so an endpoint gets slower as the business grows, with no code change.
- It loads the database. Hundreds of small queries per request use connections and CPU that other requests need.
3. How Lazy Loading Causes N+1 Queries
Relationships marked FetchType.LAZY are not loaded with the entity. Instead, Hibernate puts a proxy in the field: a stand-in object that knows only the related entity's id. The first time your code calls a method on the proxy, such as product.getCategory().getName(), Hibernate runs a query to load the real data. Lazy collections, such as product.getReviews(), work the same way.
That is fine for one product. In a loop over many products, each first access is another query. Two details affect the exact count:
- The persistence context, Hibernate's per-transaction cache of loaded entities, loads each category only once. Six products in three categories cause 3 extra queries, not 6.
- Collections are loaded per owner, so six products' reviews cause 6 extra queries.
4. Setup: Make Every Query Visible
You cannot fix what you cannot see. The example uses Spring Data JPA, Spring MVC and an H2 in-memory database:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Turn on SQL logging in development so every statement appears in the console:
spring.application.name=catalog
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.defer-datasource-initialization=true
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.SQL=debug prints each SQL statement through your logger. spring.jpa.open-in-view=false stops the database session from staying open while JSON is written, which section 9.4 shows is important. The sample data (data.sql in section 7) has 3 categories, 6 products and 8 reviews.
spring-boot-starter-webmvc. Everything on this page also works on Spring Boot 3.x (Hibernate 6.6) except the pagination behaviour in section 9.2, which changed in Hibernate 7.5. How to Spot the N+1 Problem in the SQL Log
This looks like harmless service code:
@Transactional(readOnly = true)
public List<String> productLabels() {
return productRepository.findAll().stream()
.map(p -> p.getName() + " (" + p.getCategory().getName() + ")")
.toList();
}
The SQL log tells a different story. One query loads the products, then one query per distinct category:
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
Reading each product's reviews is worse, because every collection is its own query:
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id=?
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id=?
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id=?
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id=?
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id=?
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id=?
The pattern to look for is the same statement repeated with a different id=?, right after a query that returned a list.
6. Fetch Strategies That Fix the N+1 Problem
Each strategy below was measured on the same data (6 products, 3 categories, 8 reviews) with Hibernate's statistics.
6.1 FetchType.LAZY vs FetchType.EAGER
Switching a relationship to FetchType.EAGER looks like a fix, but it isn't. With an eager @ManyToOne, findAll() still runs a separate query per category. It does so immediately, even if your code never reads the category:
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
A JPQL query such as findAll() loads only the products, and Hibernate then fetches eager relationships with extra queries. So keep relationships LAZY, as in this tutorial's entities, and choose what to load per query with the strategies below. Note that @ManyToOne and @OneToOne are eager by default, so you must set fetch = FetchType.LAZY on them yourself.
6.2 JOIN FETCH in JPQL
A fetch join tells Hibernate to load the related entity in the same SQL query and fill in the relationship, instead of leaving a proxy:
@Query("select p from Product p join fetch p.category order by p.sku")
List<Product> findAllWithCategory();
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id order by p1_0.sku
One query instead of four. Use join fetch for single-valued relationships (@ManyToOne, @OneToOne) freely. Fetch-joining collections needs more care (sections 9.1 and 9.5).
6.3 @EntityGraph
An entity graph lists the relationships to load with a query, without writing JPQL. Spring Data applies it to any repository method, including derived queries:
@EntityGraph(attributePaths = "category")
List<Product> findAllByOrderBySkuAsc();
@EntityGraph(attributePaths = {"category", "reviews"})
Optional<Product> findBySku(String sku);
The first method produces exactly the same single SQL statement as the fetch join above. The second loads a product, its category and its reviews in one query, which is ideal for a detail page. Use entity graphs when you want to keep using derived query methods, or to reuse one query with different fetch plans.
6.4 Batch Fetching with @BatchSize and default_batch_fetch_size
Batch fetching keeps lazy loading but loads many proxies or collections at once with an IN list. The first access loads the related data for up to batch size owners in one query. You can enable it globally or per mapping:
// Globally, in application.properties:
// spring.jpa.properties.hibernate.default_batch_fetch_size=25
// Or per mapping, with org.hibernate.annotations.BatchSize:
@OneToMany(mappedBy = "product")
@BatchSize(size = 25)
private List<Review> reviews = new ArrayList<>();
@Entity
@BatchSize(size = 25) // batches loading of Category proxies
@Table(name = "categories")
public class Category { ... }
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0
DEBUG org.hibernate.SQL : select c1_0.id,c1_0.name from categories c1_0 where c1_0.id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Loading the categories and the reviews for all 6 products now takes 2 queries each, whatever the loop does. Batch fetching doesn't reach 1 query, but it needs no query changes. That makes it a good safety net for code paths you haven't optimised yet.
6.5 FetchMode.SUBSELECT
For collections, Hibernate's @Fetch(FetchMode.SUBSELECT) loads the collections for all owners loaded by the previous query in one go, by repeating that query as a subquery:
@OneToMany(mappedBy = "product")
@Fetch(FetchMode.SUBSELECT) // org.hibernate.annotations.Fetch and FetchMode
private List<Review> reviews = new ArrayList<>();
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0
DEBUG org.hibernate.SQL : select r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating from reviews r1_0 where r1_0.product_id in (select p1_0.id from products p1_0)
It is always 2 queries, no matter how many products there are. Because it re-runs the original query, it works best when that query is cheap.
6.6 DTO Projections
When a page only displays data, you often don't need entities at all. A DTO projection selects just the columns the page shows, straight into a record. It even aggregates the reviews in the database:
@Query("""
select new com.javabytechie.catalog.product.ProductSummary(
p.sku, p.name, p.price, c.name, count(r), avg(r.rating))
from Product p
join p.category c
left join p.reviews r
group by p.sku, p.name, p.price, c.name
order by p.sku
""")
List<ProductSummary> findSummaries();
DEBUG org.hibernate.SQL : select p1_0.sku,p1_0.name,p1_0.price,c1_0.name,count(r1_0.id),avg(cast(r1_0.rating as float(53))) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id left join reviews r1_0 on p1_0.id=r1_0.product_id group by p1_0.sku,p1_0.name,p1_0.price,c1_0.name order by p1_0.sku
One query, no proxies, and no lazy-loading surprises later. Projections are the best choice for read-only list pages and reports. Use entities when you need to modify the data.
6.7 Strategy Comparison
| Strategy | Queries for 6 products | Best for |
|---|---|---|
| Lazy loading in a loop | 4 (categories) / 7 (reviews) | Single-entity screens only |
FetchType.EAGER | 4, even when unused | Almost never |
JOIN FETCH | 1 | A known use case with single-valued relationships |
@EntityGraph | 1 | Derived query methods, detail pages |
| Batch fetching (size 10 to 25) | 2 | A global safety net, large or varied graphs |
FetchMode.SUBSELECT | 2 | Collections on a cheap parent query |
| DTO projection | 1 | Read-only lists, reports, aggregates |
7. A Complete Example: Product Catalog API
The catalog API has two endpoints. GET /api/products lists products with their category, review count and average rating, and GET /api/products/{sku} shows one product with its reviews. Each endpoint runs exactly one SQL query. The code runs on Spring Boot 4.1 with Java 21.
7.1 The Entities
Every relationship is lazy. The fetch plan is chosen per query in the repository.
package com.javabytechie.catalog.product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
protected Category() {
}
public Category(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
package com.javabytechie.catalog.product;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id")
private Category category;
@OneToMany(mappedBy = "product")
private List<Review> reviews = new ArrayList<>();
protected Product() {
}
public Product(String sku, String name, BigDecimal price, Category category) {
this.sku = sku;
this.name = name;
this.price = price;
this.category = category;
}
public void addReview(Review review) {
reviews.add(review);
review.setProduct(this);
}
public Long getId() { return id; }
public String getSku() { return sku; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public Category getCategory() { return category; }
public List<Review> getReviews() { return List.copyOf(reviews); }
}
package com.javabytechie.catalog.product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "reviews")
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private int rating;
private String comment;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id")
private Product product;
protected Review() {
}
public Review(int rating, String comment) {
this.rating = rating;
this.comment = comment;
}
void setProduct(Product product) {
this.product = product;
}
public Long getId() { return id; }
public int getRating() { return rating; }
public String getComment() { return comment; }
public Product getProduct() { return product; }
}
7.2 Repository and DTOs
package com.javabytechie.catalog.product;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Fix 1: a fetch join loads each product's category in the same query
@Query("select p from Product p join fetch p.category order by p.sku")
List<Product> findAllWithCategory();
// Fix 2: the same result, described with an entity graph instead of JPQL
@EntityGraph(attributePaths = "category")
List<Product> findAllByOrderBySkuAsc();
// One product with its category and reviews, for the detail page
@EntityGraph(attributePaths = {"category", "reviews"})
Optional<Product> findBySku(String sku);
// Fix 3: a DTO projection selects only the columns the list page needs
@Query("""
select new com.javabytechie.catalog.product.ProductSummary(
p.sku, p.name, p.price, c.name, count(r), avg(r.rating))
from Product p
join p.category c
left join p.reviews r
group by p.sku, p.name, p.price, c.name
order by p.sku
""")
List<ProductSummary> findSummaries();
}
package com.javabytechie.catalog.product;
import java.math.BigDecimal;
public record ProductSummary(String sku, String name, BigDecimal price, String category,
long reviewCount, Double averageRating) {
}
package com.javabytechie.catalog.product;
import java.math.BigDecimal;
import java.util.List;
public record ProductDetails(String sku, String name, BigDecimal price, String category,
List<ReviewView> reviews) {
public record ReviewView(int rating, String comment) {
}
static ProductDetails from(Product product) {
List<ReviewView> reviews = product.getReviews().stream()
.map(r -> new ReviewView(r.getRating(), r.getComment()))
.toList();
return new ProductDetails(product.getSku(), product.getName(), product.getPrice(),
product.getCategory().getName(), reviews);
}
}
7.3 Service, Controller and Sample Data
The service builds DTOs inside read-only transactions, so no lazy data is touched after the session closes. The transaction management tutorial explains @Transactional(readOnly = true).
package com.javabytechie.catalog.product;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductCatalogService {
private static final Logger log = LoggerFactory.getLogger(ProductCatalogService.class);
private final ProductRepository productRepository;
public ProductCatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// List page: one query, only the columns the page shows
@Transactional(readOnly = true)
public List<ProductSummary> listProducts() {
List<ProductSummary> summaries = productRepository.findSummaries();
log.info("Listed {} products", summaries.size());
return summaries;
}
// Detail page: one query for the product, its category and its reviews
@Transactional(readOnly = true)
public ProductDetails getProduct(String sku) {
return productRepository.findBySku(sku)
.map(ProductDetails::from)
.orElseThrow(() -> new ProductNotFoundException(sku));
}
}
package com.javabytechie.catalog.product;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductCatalogService catalogService;
public ProductController(ProductCatalogService catalogService) {
this.catalogService = catalogService;
}
@GetMapping
public List<ProductSummary> listProducts() {
return catalogService.listProducts();
}
@GetMapping("/{sku}")
public ProductDetails getProduct(@PathVariable String sku) {
return catalogService.getProduct(sku);
}
}
package com.javabytechie.catalog.product;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(String sku) {
super("Product not found: " + sku);
}
}
package com.javabytechie.catalog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CatalogApplication {
public static void main(String[] args) {
SpringApplication.run(CatalogApplication.class, args);
}
}
INSERT INTO categories (name) VALUES ('Keyboards'), ('Mice'), ('Monitors');
INSERT INTO products (sku, name, price, category_id) VALUES
('KB-100', 'Mechanical Keyboard', 89.00, (SELECT id FROM categories WHERE name = 'Keyboards')),
('KB-200', 'Low-Profile Keyboard', 59.00, (SELECT id FROM categories WHERE name = 'Keyboards')),
('MS-100', 'Wireless Mouse', 25.50, (SELECT id FROM categories WHERE name = 'Mice')),
('MS-200', 'Gaming Mouse', 49.00, (SELECT id FROM categories WHERE name = 'Mice')),
('MN-100', '24-inch Monitor', 159.00, (SELECT id FROM categories WHERE name = 'Monitors')),
('MN-200', '27-inch Monitor', 219.00, (SELECT id FROM categories WHERE name = 'Monitors'));
INSERT INTO reviews (rating, comment, product_id) VALUES
(5, 'Great feel', (SELECT id FROM products WHERE sku = 'KB-100')),
(4, 'A bit loud', (SELECT id FROM products WHERE sku = 'KB-100')),
(4, 'Slim and quiet', (SELECT id FROM products WHERE sku = 'KB-200')),
(5, 'Battery lasts weeks', (SELECT id FROM products WHERE sku = 'MS-100')),
(3, 'Too light for me', (SELECT id FROM products WHERE sku = 'MS-200')),
(5, 'Sharp picture', (SELECT id FROM products WHERE sku = 'MN-100')),
(4, 'Good value', (SELECT id FROM products WHERE sku = 'MN-200')),
(5, 'Perfect for work', (SELECT id FROM products WHERE sku = 'MN-200'));
7.4 Run It
Start the app with ./mvnw spring-boot:run and call the list endpoint:
curl http://localhost:8080/api/products
[{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00,"category":"Keyboards","reviewCount":2,"averageRating":4.5},
{"sku":"KB-200","name":"Low-Profile Keyboard","price":59.00,"category":"Keyboards","reviewCount":1,"averageRating":4.0},
{"sku":"MN-100","name":"24-inch Monitor","price":159.00,"category":"Monitors","reviewCount":1,"averageRating":5.0},
{"sku":"MN-200","name":"27-inch Monitor","price":219.00,"category":"Monitors","reviewCount":2,"averageRating":4.5},
{"sku":"MS-100","name":"Wireless Mouse","price":25.50,"category":"Mice","reviewCount":1,"averageRating":5.0},
{"sku":"MS-200","name":"Gaming Mouse","price":49.00,"category":"Mice","reviewCount":1,"averageRating":3.0}]
DEBUG org.hibernate.SQL : select p1_0.sku,p1_0.name,p1_0.price,c1_0.name,count(r1_0.id),avg(cast(r1_0.rating as float(53))) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id left join reviews r1_0 on p1_0.id=r1_0.product_id group by p1_0.sku,p1_0.name,p1_0.price,c1_0.name order by p1_0.sku
INFO c.j.c.product.ProductCatalogService : Listed 6 products
The detail endpoint returns the product with its reviews, again from a single query:
curl http://localhost:8080/api/products/MN-200
{"sku":"MN-200","name":"27-inch Monitor","price":219.00,"category":"Monitors","reviews":[{"rating":4,"comment":"Good value"},{"rating":5,"comment":"Perfect for work"}]}
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating,p1_0.sku from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id left join reviews r1_0 on p1_0.id=r1_0.product_id where p1_0.sku=?
An unknown SKU returns 404 Not Found through ProductNotFoundException:
curl -i http://localhost:8080/api/products/XX-1
HTTP/1.1 404
{"timestamp":"2026-10-24T18:21:20.054Z","status":404,"error":"Not Found","path":"/api/products/XX-1"}
8. Configuration Options
| Property | Default | What it does |
|---|---|---|
logging.level.org.hibernate.SQL=debug | Off | Logs every SQL statement. Use it in development and tests |
spring.jpa.properties.hibernate.generate_statistics | false | Collects query counts and timings. Turn it on in tests, and in production only for short investigations |
spring.jpa.properties.hibernate.default_batch_fetch_size | Not set (no batching) | Enables batch fetching for every lazy relationship and collection |
spring.jpa.open-in-view | true (with a startup warning) | Keeps the session open during the web request. Set it to false so lazy loading cannot hide in controllers and JSON serialization |
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch | false | Throws an error instead of paging in memory when a query cannot page in SQL (section 9.2) |
9. Common Pitfalls and How to Fix Them
9.1 MultipleBagFetchException
A List collection without an index column is a "bag" in Hibernate terms. Fetch-joining two bags in one query fails, because the combined rows can't be split back into two lists reliably. If Product also had a List<String> imageUrls collection:
@Query("select p from Product p left join fetch p.reviews left join fetch p.imageUrls")
List<Product> findAllWithReviewsAndImages();
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.javabytechie.catalog.product.Product.imageUrls, com.javabytechie.catalog.product.Product.reviews]
How to fix it: fetch one collection with the query and let batch fetching or SUBSELECT load the other, or run two queries (one per collection) in the same transaction. Changing one collection to a Set also works, but see section 9.5 first.
9.2 Pagination with a Collection Fetch
Paging a query that fetches a collection is tricky, because a SQL LIMIT counts joined rows, not products. Hibernate's behaviour here changed between versions. With an entity graph on reviews and PageRequest.of(0, 2):
WARN HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating,p1_0.sku from products p1_0 left join reviews r1_0 on p1_0.id=r1_0.product_id
DEBUG org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,r1_0.product_id,r1_0.id,r1_0.comment,r1_0.rating,p1_0.sku from (select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 offset ? rows fetch first ? rows only) p1_0(id,category_id,name,price,sku) left join reviews r1_0 on p1_0.id=r1_0.product_id
Hibernate 6 (Spring Boot 3.x) loads every row and pages in memory, which can exhaust memory on large tables. Hibernate 7 (Spring Boot 4.x) pushed the limit into a subquery in this test. How to fix it: page the parent entities first (or page a DTO projection), then fetch the collections for that page with batch fetching or a second query. Set hibernate.query.fail_on_pagination_over_collection_fetch=true so any in-memory paging fails loudly instead of slowing down silently. The pagination and sorting tutorial covers Pageable in general.
9.3 Switching Relationships to EAGER
As section 6.1 showed, EAGER moves the extra queries earlier but doesn't remove them, and they now run even when the data isn't needed. How to fix it: keep every relationship LAZY and fetch per use case.
9.4 Open Session in View Hides the Problem
With spring.jpa.open-in-view=true (the default), a controller or the JSON serializer can trigger lazy loading after your service returns. The N+1 queries then run where you don't look for them. How to fix it: set it to false, return DTOs from services, and treat a LazyInitializationException as a sign that a fetch plan is missing.
9.5 Fetching Large Collections with Joins
Fetch-joining a collection multiplies rows: each product row is repeated once per review. Fetching two collections at once (for example by turning both into Sets) multiplies them again, a Cartesian product that can return far more rows than entities. Hibernate 6 and later remove duplicate parent entities from the result automatically, so distinct is no longer needed for that, but the database still sends every row. How to fix it: fetch-join at most one collection per query, and use batch fetching or a second query for the others.
10. Testing: Count Queries to Catch N+1 Early
The most reliable guard against the N+1 problem is a test that asserts the number of SQL statements. Hibernate's Statistics object counts them once hibernate.generate_statistics is on. The test below measures the same work before and after the fix:
package com.javabytechie.catalog.product;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.test.context.TestPropertySource;
import jakarta.persistence.EntityManagerFactory;
@DataJpaTest
@TestPropertySource(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
class ProductQueryCountTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Autowired
private EntityManagerFactory entityManagerFactory;
private Statistics statistics;
@BeforeEach
void resetCounters() {
statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
entityManager.clear(); // start with an empty persistence context
statistics.clear();
}
@Test
void fetchJoinTurnsNPlusOneIntoOneQuery() {
// Before: findAll() plus a lazy category per product
List<String> naive = productRepository.findAll().stream()
.map(p -> p.getCategory().getName())
.toList();
long naiveQueries = statistics.getPrepareStatementCount();
entityManager.clear();
statistics.clear();
// After: the categories arrive with the products
List<String> fetched = productRepository.findAllWithCategory().stream()
.map(p -> p.getCategory().getName())
.toList();
long fetchedQueries = statistics.getPrepareStatementCount();
assertThat(naive).hasSize(6);
assertThat(naiveQueries).isEqualTo(4); // 1 for products + 1 per distinct category (3)
assertThat(fetched).containsExactlyInAnyOrderElementsOf(naive);
assertThat(fetchedQueries).isEqualTo(1);
}
@Test
void summaryProjectionNeedsOneQuery() {
List<ProductSummary> summaries = productRepository.findSummaries();
assertThat(statistics.getPrepareStatementCount()).isEqualTo(1);
assertThat(summaries).hasSize(6);
assertThat(summaries.getFirst().sku()).isEqualTo("KB-100");
assertThat(summaries.getFirst().reviewCount()).isEqualTo(2);
assertThat(summaries.getFirst().averageRating()).isEqualTo(4.5);
}
@Test
void detailGraphLoadsReviewsInTheSameQuery() {
Product monitor = productRepository.findBySku("MN-200").orElseThrow();
int reviewCount = monitor.getReviews().size();
assertThat(reviewCount).isEqualTo(2);
assertThat(monitor.getCategory().getName()).isEqualTo("Monitors");
assertThat(statistics.getPrepareStatementCount()).isEqualTo(1);
}
}
entityManager.clear()empties the persistence context, so entities cached by an earlier step can't hide queries.getPrepareStatementCount()counts every SQL statement Hibernate prepared sincestatistics.clear().- If someone later changes
findAllWithCategory()or the detail graph, the count assertions fail immediately.
See the Spring Boot testing tutorial for more on test slices.
@DataJpaTest is in org.springframework.boot.data.jpa.test.autoconfigure and needs spring-boot-starter-data-jpa-test, and TestEntityManager is in org.springframework.boot.jpa.test.autoconfigure. In Spring Boot 3.x, both are in org.springframework.boot.test.autoconfigure.orm.jpa.11. Choosing a Fetch Strategy
| Situation | Good choice | Poor choice |
|---|---|---|
| Read-only list page or report | DTO projection | Loading full entities and their collections |
| Detail page for one entity | @EntityGraph or JOIN FETCH | Lazy loading each part separately |
List with a @ManyToOne shown per row | JOIN FETCH or @EntityGraph | FetchType.EAGER |
| List that needs several collections | Batch fetching or SUBSELECT | Fetch-joining all collections at once |
| Paged list with a collection | Page the parents, then batch-load the collection | Collection fetch join plus Pageable |
| Code paths you haven't tuned yet | A global default_batch_fetch_size | Making relationships eager "just in case" |
12. Best Practices
- Make every relationship lazy, including
@ManyToOneand@OneToOne, which are eager by default. - Decide the fetch plan per use case in the repository, with
JOIN FETCH,@EntityGraphor a projection, instead of in the entity mapping. - Use DTO projections for read-only lists, so pages load only the columns they show.
- Fetch-join at most one collection per query, and load other collections with batch fetching or a second query.
- Set a global
default_batch_fetch_size(for example 16 to 50) as a safety net for code paths you haven't tuned. - Turn off
spring.jpa.open-in-viewand build DTOs inside@Transactional(readOnly = true)service methods. - Watch the SQL log during development, and look for the same statement repeated with different ids.
- Assert query counts in tests with Hibernate statistics, so a fixed N+1 problem cannot return unnoticed.
- Page parents, not joined rows, and enable
fail_on_pagination_over_collection_fetchto catch in-memory paging.
13. Summary
The JPA N+1 problem appears when lazy relationships are read in a loop: one query loads the list, then one more runs for each row's related data. FetchType.EAGER doesn't fix it. It only runs the same extra queries sooner. Keep every relationship lazy and choose a fetch strategy per use case: JOIN FETCH or @EntityGraph to load single-valued relationships and one collection in one query, batch fetching or SUBSELECT to load collections in a fixed number of queries, and DTO projections for read-only pages. Turn off open session in view, watch the SQL log, and assert query counts in tests. The main recommendation: use projections for lists and entity graphs for detail pages, and keep a global batch size as a safety net for everything else.
Frequently Asked Questions (FAQ)
Q1. JOIN FETCH vs @EntityGraph: which should I use?
Both load the related data in the same SQL query, and for the same relationships they produce the same SQL. Use JOIN FETCH when you already write a JPQL query and need filters or ordering on the joined entity. Use @EntityGraph when you want to keep Spring Data derived methods or reuse a query with different fetch plans.
Q2. Does FetchType.EAGER fix the N+1 problem?
No. With an eager @ManyToOne, a query such as findAll() still loads the related entities with separate queries, and it does so even when your code never uses them. Keep relationships lazy and fetch what each use case needs with a fetch join, an entity graph or batch fetching.
Q3. How do I fix MultipleBagFetchException?
It appears when one query fetch-joins two List collections that have no index column. Fetch one collection in the query and load the other with batch fetching, @Fetch(FetchMode.SUBSELECT) or a second query in the same transaction. Turning the collections into Sets removes the error but can return a very large Cartesian product of rows.
Q4. What batch size should I use for hibernate.default_batch_fetch_size?
Values between about 16 and 50 work well for most applications. A larger batch means fewer queries but longer IN lists, and Hibernate uses a placeholder for every slot in the batch. Measure with your real data and your database's limits on parameters per statement.
Q5. How can I count SQL queries in a Spring Boot test?
Set spring.jpa.properties.hibernate.generate_statistics=true for the test, unwrap the SessionFactory from the EntityManagerFactory, and read getStatistics().getPrepareStatementCount(). Call statistics.clear() and entityManager.clear() before the code you measure, then assert the expected number.
Q6. Does pagination work with JOIN FETCH on a collection?
It depends on the Hibernate version and the query. Hibernate 6 (Spring Boot 3.x) logs HHH90003004 and pages in memory after loading every row, while Hibernate 7 (Spring Boot 4.x) pushed the limit into a subquery in our test. The safest pattern on any version is to page the parent entities first, then load their collections with batch fetching or a second query.
Q7. Why do I still see N+1 queries after adding @EntityGraph?
An entity graph only applies to the repository method it annotates, so other methods that load the same entities still use lazy loading. Also check that the graph names every relationship the code reads, including nested paths written with dots, such as reviews.product. Finally, open session in view can trigger lazy loading in the controller or serializer, outside the method you optimised.
Q8. Can open session in view cause N+1 queries?
Yes. With spring.jpa.open-in-view=true, which is the default, lazy relationships can still load while the controller or JSON serializer runs, so N+1 queries happen outside your service code. Set it to false, build DTOs inside transactional service methods, and fix any LazyInitializationException by adding a fetch plan.