Contents

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.