Introduction
The N+1 query problem occurs when JPA loads a list of N entities with one query, then issues N additional queries to load each entity's lazy associations. For a page showing 50 orders with their items and customers, this results in 1 + 50 + 50 = 101 queries instead of a single JOIN query. This is the most common performance issue in JPA applications and is often not visible in development (with small datasets) but becomes critical in production with thousands of records.
Symptoms
Hibernate: SELECT o FROM Order o -- 1 query
Hibernate: SELECT i FROM OrderItem i WHERE i.order_id = ? -- N queries
Hibernate: SELECT c FROM Customer c WHERE c.id = ? -- N queries
# Total: 1 + N + N queries for a single pageWith logging enabled:
2026-04-09 10:00:00 DEBUG SQL - select ... from orders (50 rows returned)
2026-04-09 10:00:01 DEBUG SQL - select ... from order_items where order_id=1
2026-04-09 10:00:01 DEBUG SQL - select ... from order_items where order_id=2
# ... 50 individual queriesCommon Causes
- Lazy associations accessed in loop: Accessing order.getItems() for each order
- EAGER fetch on @ManyToOne: Every order fetches customer individually
- No fetch strategy specified: Default lazy loading triggers N+1
- DTO projection not used: Returning entities instead of projections
- Open session in view hides the problem: OSIV masks N+1 in development
- Page size too large: Large pages amplify the N+1 effect
Step-by-Step Fix
Step 1: Use @EntityGraph for dynamic fetch plans
```java public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items", "customer"}) @Query("SELECT o FROM Order o WHERE o.status = :status") List<Order> findByStatusWithDetails(@Param("status") String status);
// Named EntityGraph @EntityGraph(value = "Order.withItemsAndCustomer") List<Order> findAll(); }
// On the entity @Entity @NamedEntityGraph( name = "Order.withItemsAndCustomer", attributeNodes = { @NamedAttributeNode("items"), @NamedAttributeNode("customer") } ) public class Order { // ... } ```
Step 2: Use JOIN FETCH in JPQL queries
```java public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o " + "LEFT JOIN FETCH o.items " + "LEFT JOIN FETCH o.customer " + "WHERE o.id = :id") Optional<Order> findByIdWithDetails(@Param("id") Long id);
// For lists, use DISTINCT to avoid duplicates from JOIN @Query("SELECT DISTINCT o FROM Order o " + "LEFT JOIN FETCH o.items " + "WHERE o.status = :status") List<Order> findByStatusWithItems(@Param("status") String status); } ```
Step 3: Use @BatchSize for secondary queries
```java @Entity public class Order { @OneToMany(mappedBy = "order") @BatchSize(size = 50) // Fetch items for 50 orders in one query private List<OrderItem> items = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY) @BatchSize(size = 50) // Fetch customers for 50 orders in one query private Customer customer; }
// Instead of 1 + N + N queries: // Now: 1 (orders) + 1 (all items) + 1 (all customers) = 3 queries ```
Prevention
- Enable SQL logging in development:
spring.jpa.show-sql=trueandlogging.level.org.hibernate.SQL=DEBUG - Use Hypersistence Optimizer or Blaze-Persistence to detect N+1 automatically
- Use @EntityGraph for read operations that need associated data
- Use JOIN FETCH for queries where you know exactly which associations are needed
- Apply @BatchSize as a fallback when EntityGraph is not applicable
- Use DTO projections to avoid loading entity associations entirely
- Add query count assertions to tests to catch N+1 regressions