JPA Performance: Solving N+1, Lazy Loading, and Query Optimization

JPA makes data access easy — until it silently runs hundreds of queries to load what you think is a single query. This article covers how to find and fix the most common JPA performance problems.

Enable Query Logging First

You can’t fix what you can’t see. Enable SQL logging before optimizing:

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # log bind parameters (Spring Boot 3+)

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true   # log query count, cache hits, etc.

Or use a more targeted approach in production — P6Spy or Datasource-Proxy:

<dependency>
    <groupId>net.ttddyy</groupId>
    <artifactId>datasource-proxy</artifactId>
    <version>1.10</version>
</dependency>
@Bean
public DataSource dataSource(DataSourceProperties properties) {
    HikariDataSource ds = properties.initializeDataSourceBuilder()
        .type(HikariDataSource.class).build();

    return ProxyDataSourceBuilder.create(ds)
        .name("OrderDS")
        .logQueryBySlf4j(SLF4JLogLevel.DEBUG)
        .countQuery()
        .build();
}

The N+1 Problem

The most common JPA performance killer. You fetch N orders, then Hibernate fires N additional queries to fetch each order’s items.

// Entity
@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

// Service
public List<OrderSummary> getSummaries() {
    List<Order> orders = orderRepository.findAll();  // 1 query

    return orders.stream()
        .map(o -> new OrderSummary(
            o.getId(),
            o.getItems().size()   // ← N queries, one per order
        ))
        .toList();
}

With 100 orders: 1 + 100 = 101 queries. With 1000 orders: 1001 queries.

Fix 1: JOIN FETCH

@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {

    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
    List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
}

One query with a JOIN. Fetches everything in one round-trip.

Limitation: JOIN FETCH with DISTINCT and pagination (Pageable) causes a HHH90003004 warning — Hibernate applies pagination in memory, not in SQL. This is dangerous with large datasets.

Fix 2: @EntityGraph

@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {

    @EntityGraph(attributePaths = {"items", "items.product"})
    List<Order> findByCustomerId(UUID customerId);

    // Works with derived queries, @Query, and JpaSpecificationExecutor
    @EntityGraph(attributePaths = {"items"})
    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}

@EntityGraph instructs Hibernate to eagerly load the specified associations. Unlike JOIN FETCH, it works correctly with Pageable.

Fix 3: @BatchSize

When you can’t avoid N+1 (e.g., complex filtering), batch the queries:

@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 30)   // load items in batches of 30 instead of 1 at a time
    private List<OrderItem> items;
}

With @BatchSize(30): 100 orders → 1 (orders) + 4 (items in batches of 30) = 5 queries instead of 101.

Or set globally:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 30

Fix 4: Projections for Read-Only Views

Often you don’t need the entity at all — just some fields:

// Interface projection — Hibernate generates optimized SQL
public interface OrderSummary {
    UUID getId();
    LocalDateTime getCreatedAt();
    BigDecimal getTotalAmount();
    String getCustomerEmail();  // from JOIN
}

@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
    List<OrderSummary> findByStatus(OrderStatus status);
}

Hibernate generates: SELECT o.id, o.created_at, o.total_amount, c.email FROM orders o JOIN customers c ...

No items loaded. No full Order objects in memory.

// Record projection — DTO with constructor expression
public record OrderSummaryDto(UUID id, LocalDateTime createdAt, BigDecimal total) {}

@Query("SELECT new com.devopsmonk.order.OrderSummaryDto(o.id, o.createdAt, o.totalAmount) FROM Order o")
List<OrderSummaryDto> findOrderSummaries();

Lazy Loading Outside a Transaction

The open-in-view trap: Spring Boot enables spring.jpa.open-in-view=true by default, which keeps the Hibernate session open through the entire HTTP request — including the view rendering phase. This allows lazy loading in controllers and view templates, but it silently fires queries outside your intended transaction boundary.

spring:
  jpa:
    open-in-view: false   # always disable in production

With open-in-view: false, accessing a lazy collection outside a transaction throws LazyInitializationException — which makes the N+1 problem visible rather than hiding it.

// This fails with open-in-view=false:
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable UUID id) {
    Order order = orderService.findById(id);   // transaction ends here
    return new OrderResponse(
        order.getId(),
        order.getItems().size()   // ← LazyInitializationException!
    );
}

// Fix: load what you need inside the transaction
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable UUID id) {
    return orderService.findByIdWithItems(id);   // service returns a DTO
}

Pagination with Large Datasets

findAll(Pageable) with @OneToMany associations causes Hibernate to load everything into memory and paginate there. The log shows:

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

This is a memory bomb. Fix with a two-query approach:

@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {

    // Step 1: paginate IDs only
    @Query("SELECT o.id FROM Order o WHERE o.status = :status")
    Page<UUID> findIdsByStatus(@Param("status") OrderStatus status, Pageable pageable);

    // Step 2: fetch full entities for those IDs
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
    List<Order> findByIdsWithItems(@Param("ids") List<UUID> ids);
}

// Service
public Page<OrderResponse> findOrders(OrderStatus status, Pageable pageable) {
    Page<UUID> idPage = orderRepository.findIdsByStatus(status, pageable);
    List<Order> orders = orderRepository.findByIdsWithItems(idPage.getContent());

    return idPage.map(id -> {
        Order order = orders.stream().filter(o -> o.getId().equals(id)).findFirst().orElseThrow();
        return OrderResponse.from(order);
    });
}

Batch Inserts and Updates

By default, Hibernate sends one INSERT per entity. For bulk operations, enable JDBC batching:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 30
        order_inserts: true    # reorder inserts to maximize batching
        order_updates: true

Critical: JDBC batching doesn’t work with GenerationType.IDENTITY (auto-increment). Use SEQUENCE instead:

@Entity
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_item_seq")
    @SequenceGenerator(name = "order_item_seq", sequenceName = "order_item_id_seq",
                       allocationSize = 50)   // fetch 50 IDs at once
    private Long id;
}

allocationSize = 50 means Hibernate fetches 50 IDs from the database sequence in one call, then uses them locally — reducing sequence calls by 50x.

For UUID primary keys (recommended for distributed systems), batching works automatically.

Caching with the Second-Level Cache

The first-level cache is per-session (per-request). The second-level cache is shared across sessions — useful for reference data that rarely changes.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=1000,expireAfterWrite=10m
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class ProductCategory {
    // ... rarely changes — cache it
}

@Entity
public class Order {
    // ... changes frequently — don't cache
}

For collections:

@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    private List<OrderItem> items;
}

Measuring Performance

Use Hibernate statistics to measure before and after:

@Component
@Slf4j
public class HibernateStatisticsLogger {

    private final Statistics stats;

    public HibernateStatisticsLogger(EntityManagerFactory emf) {
        this.stats = emf.unwrap(SessionFactory.class).getStatistics();
        this.stats.setStatisticsEnabled(true);
    }

    @Scheduled(fixedDelay = 60000)
    public void logStats() {
        log.info("Hibernate stats: queries={}, queryTime={}ms, cacheHits={}, cacheMisses={}",
            stats.getQueryExecutionCount(),
            stats.getQueryExecutionMaxTime(),
            stats.getSecondLevelCacheHitCount(),
            stats.getSecondLevelCacheMissCount());
        stats.clear();
    }
}

Or assert query count in tests:

@DataJpaTest
class OrderRepositoryPerformanceTest {

    @Autowired OrderRepository repository;

    @PersistenceContext EntityManager em;

    @Test
    void findByStatusLoadsItemsInOneQuery() {
        // Given: 10 orders each with 5 items

        Statistics stats = em.getEntityManagerFactory()
            .unwrap(SessionFactory.class).getStatistics();
        stats.clear();

        // When
        List<Order> orders = repository.findByStatusWithItems(OrderStatus.PENDING);
        orders.forEach(o -> o.getItems().size());  // trigger lazy load if present

        // Then
        assertThat(stats.getPrepareStatementCount())
            .as("Should use 1 query with JOIN FETCH")
            .isEqualTo(1);
    }
}

Common Performance Checklist

  • spring.jpa.open-in-view: false — make lazy loading problems visible
  • SQL logging enabled in dev — watch for N+1
  • @EntityGraph or JOIN FETCH on any query that accesses associations
  • default_batch_fetch_size: 30 as a safety net for any remaining lazy loads
  • Projections (interface or DTO) for read-only list views — never load full entities for lists
  • Two-query approach for paginated queries with associations
  • GenerationType.SEQUENCE with allocationSize (not IDENTITY) for batch inserts
  • batch_size: 30, order_inserts: true, order_updates: true for bulk operations
  • Second-level cache for frequently-read, rarely-changed reference data

What You’ve Learned

  • N+1 is caused by lazy loading associations in a loop — fix with JOIN FETCH, @EntityGraph, or @BatchSize
  • open-in-view: false forces lazy loading problems to surface early as exceptions
  • Paginating with JOIN FETCH causes in-memory pagination — use the two-query (ID + fetch) approach instead
  • Interface projections generate optimized SELECT statements — use them for read-only list views
  • JDBC batching requires SEQUENCE generation, not IDENTITY
  • Hibernate statistics measure query count — assert them in tests to prevent regressions

Next: Article 39 — Caching with Caffeine and Redis — cache at the service layer to avoid hitting the database at all.