Contents
- Understanding the N+1 Problem
- JOIN FETCH & @Query
- @EntityGraph — Declarative Eager Loading
- Hibernate Batch Fetching
- Interface & DTO Projections
- Specification API — Dynamic Queries
- Combining Specifications in a Search Service
- Diagnosing Queries with Logging
The N+1 problem is the most common JPA performance trap. It happens when loading a list of N entities triggers N additional queries to load each entity's lazily-fetched association. Load 100 orders and Hibernate fires 1 query for orders + 100 queries for each order's customer — 101 queries total instead of 1.
@Entity
public class Order {
@Id Long id;
String orderNumber;
@ManyToOne(fetch = FetchType.LAZY) // lazy — not loaded until accessed
Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
List<OrderItem> items;
}
// SERVICE — N+1 in action
List<Order> orders = orderRepository.findAll(); // 1 query: SELECT * FROM orders
for (Order o : orders) {
// Accessing o.getCustomer() triggers a SELECT per order — N queries!
System.out.println(o.getCustomer().getName()); // 100 extra SELECTs for 100 orders
}
// Total: 101 queries for 100 orders
Enable SQL logging to catch N+1 in development: set spring.jpa.show-sql=true and logging.level.org.hibernate.SQL=DEBUG. If you see the same query repeated N times with different ID parameters, you have an N+1 problem.
The most direct fix is a JPQL JOIN FETCH that loads the association in the same query. Add it to a @Query in your repository.
public interface OrderRepository extends JpaRepository<Order, Long> {
// JOIN FETCH loads customer in the same SELECT — 1 query total
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
// Fetch multiple associations — careful: results multiply with collection joins
@Query("""
SELECT DISTINCT o FROM Order o
JOIN FETCH o.customer
LEFT JOIN FETCH o.items
WHERE o.customer.id = :customerId
""")
List<Order> findByCustomerIdWithItems(@Param("customerId") Long customerId);
}
Avoid using JOIN FETCH on two @OneToMany collections in the same query. Hibernate must produce a Cartesian product of the result rows, causing a MultipleBagFetchException or silently returning duplicate rows. Fetch one collection at a time, or use @EntityGraph with batch fetching for the second.
@EntityGraph lets you specify which associations to eagerly load without writing JPQL. Define a named graph on the entity or use an ad-hoc attribute path directly on the repository method.
// Define named graphs on the entity
@Entity
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items.product")
},
subgraphs = {
@NamedSubgraph(name = "items.product",
attributeNodes = @NamedAttributeNode("product"))
}
)
public class Order {
@Id Long id;
@ManyToOne(fetch = FetchType.LAZY) Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY) List<OrderItem> items;
}
// Repository — reference the named graph
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph("Order.withCustomerAndItems")
List<Order> findByStatus(OrderStatus status);
// Ad-hoc graph — no @NamedEntityGraph needed
@EntityGraph(attributePaths = {"customer"})
Optional<Order> findWithCustomerById(Long id);
// Combine with @Query
@EntityGraph(attributePaths = {"customer", "items"})
@Query("SELECT o FROM Order o WHERE o.createdAt > :since")
List<Order> findRecentOrdersWithDetails(@Param("since") java.time.Instant since);
}
When you cannot use JOIN FETCH (e.g., paginated queries), Hibernate's batch fetching reduces N+1 to ⌈N/batchSize⌉+1 queries. Instead of one SELECT per association, Hibernate groups IDs and fetches them in batches using WHERE id IN (?, ?, ...).
// application.yml — global batch size
// spring:
// jpa:
// properties:
// hibernate.default_batch_fetch_size: 100
// Or per-association using @BatchSize
@Entity
public class Customer {
@Id Long id;
String name;
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
@org.hibernate.annotations.BatchSize(size = 50)
List<Order> orders;
}
// With batch fetch size = 100:
// Loading 200 customers + their orders = 1 + ⌈200/100⌉ = 3 queries
// vs without: 1 + 200 = 201 queries
Set hibernate.default_batch_fetch_size to a value between 25–100 globally. This single property dramatically reduces query count across all lazy associations in the entire application with zero code changes.
Loading full entities when you only need a few columns wastes memory and network bandwidth. Spring Data JPA supports two projection types: interface projections (Spring generates a proxy) and DTO (class-based) projections (you provide a record or class with a matching constructor).
// Interface projection — Spring generates a proxy at runtime
public interface OrderSummary {
Long getId();
String getOrderNumber();
String getStatus();
// Nested projection
CustomerInfo getCustomer();
interface CustomerInfo {
String getName();
String getEmail();
}
}
// DTO projection — use a record (Spring Boot 3 / JDK 16+)
public record OrderLineItem(Long orderId, String productName, int quantity, double price) {}
// Repository — return projections directly
public interface OrderRepository extends JpaRepository<Order, Long> {
// Interface projection — SELECT only the mapped columns
List<OrderSummary> findByStatus(OrderStatus status);
// DTO projection using JPQL constructor expression
@Query("""
SELECT new com.example.dto.OrderLineItem(
o.id, p.name, i.quantity, i.unitPrice)
FROM Order o
JOIN o.items i
JOIN i.product p
WHERE o.id = :orderId
""")
List<OrderLineItem> findLineItemsByOrderId(@Param("orderId") Long orderId);
// Open projection — computed expression using SpEL
interface OrderWithLabel {
String getOrderNumber();
@Value("#{target.status.name() + ' — ' + target.customer.name}")
String getLabel();
}
List<OrderWithLabel> findByCustomerId(Long customerId);
}
Interface projections that access nested associations still trigger lazy loading unless the associations are fetched. For closed projections (only scalar columns from the root entity), Hibernate generates a SELECT with only the projected columns — no entity tracking overhead either.
The Specification interface wraps a JPA Predicate and lets you compose dynamic filter conditions with and(), or(), and not(). Your repository extends JpaSpecificationExecutor to gain findAll(Specification) and findAll(Specification, Pageable).
import org.springframework.data.jpa.domain.Specification;
// Repository — extend JpaSpecificationExecutor
public interface ProductRepository
extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {}
// Specifications as reusable building blocks
public class ProductSpecs {
public static Specification<Product> hasCategory(String category) {
return (root, query, cb) ->
category == null ? cb.conjunction()
: cb.equal(root.get("category"), category);
}
public static Specification<Product> priceBetween(Double min, Double max) {
return (root, query, cb) -> {
if (min == null && max == null) return cb.conjunction();
if (min == null) return cb.lessThanOrEqualTo(root.get("price"), max);
if (max == null) return cb.greaterThanOrEqualTo(root.get("price"), min);
return cb.between(root.get("price"), min, max);
};
}
public static Specification<Product> nameContains(String keyword) {
return (root, query, cb) ->
keyword == null ? cb.conjunction()
: cb.like(cb.lower(root.get("name")),
"%" + keyword.toLowerCase() + "%");
}
public static Specification<Product> inStock() {
return (root, query, cb) ->
cb.greaterThan(root.get("stockQuantity"), 0);
}
}
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
@Service
public class ProductSearchService {
private final ProductRepository productRepository;
public ProductSearchService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Page<Product> search(ProductSearchRequest req, Pageable pageable) {
// Compose specs dynamically — null values are treated as "no filter"
Specification<Product> spec = Specification
.where(ProductSpecs.hasCategory(req.category()))
.and(ProductSpecs.priceBetween(req.minPrice(), req.maxPrice()))
.and(ProductSpecs.nameContains(req.keyword()));
if (Boolean.TRUE.equals(req.inStockOnly())) {
spec = spec.and(ProductSpecs.inStock());
}
return productRepository.findAll(spec, pageable);
}
}
// Controller
@GetMapping("/products/search")
public Page<Product> search(
@RequestParam(required = false) String category,
@RequestParam(required = false) Double minPrice,
@RequestParam(required = false) Double maxPrice,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Boolean inStockOnly,
Pageable pageable) {
return searchService.search(
new ProductSearchRequest(category, minPrice, maxPrice, keyword, inStockOnly),
pageable);
}
# application.yml — enable for development/staging only
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100 # reduce N+1 globally
generate_statistics: true # log query counts per session
logging:
level:
org.hibernate.SQL: DEBUG # print every SQL statement
org.hibernate.orm.jdbc.bind: TRACE # print bind parameter values
org.hibernate.stat: DEBUG # print session statistics
// Programmatic statistics — useful in integration tests
import org.hibernate.stat.Statistics;
import jakarta.persistence.EntityManagerFactory;
@Autowired EntityManagerFactory emf;
Statistics stats = emf.unwrap(org.hibernate.SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
// ... run your code ...
System.out.println("Query count: " + stats.getQueryExecutionCount());
System.out.println("2nd level cache hits: " + stats.getSecondLevelCacheHitCount());
// Assert in tests: assertEquals(1, stats.getQueryExecutionCount());
Add an assertion on query count in integration tests to prevent N+1 regressions: enable Hibernate statistics, run the code under test, and assert stats.getQueryExecutionCount() == expectedCount. This catches N+1 problems before they reach production.