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

JPA makes database access simple. It also makes it dangerously easy to write code that fires 100 SQL queries to load 10 records. The N+1 problem alone has caused more production performance incidents than almost any other JPA issue.

This guide covers how to find and fix the five most common JPA performance problems: N+1 queries, LazyInitializationException, over-fetching, poor connection pool sizing, and Hibernate 6 breaking changes.


Enable SQL Logging First

Before optimizing anything, see exactly what queries are firing:

# application.properties (dev only — never in production)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

Or use datasource-proxy for cleaner output with query counts:

<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("DS-Proxy")
        .logQueryBySlf4j(SLF4JLogLevel.INFO)
        .countQuery()
        .build();
}

This logs every query with its parameters and a running count — invaluable for spotting N+1.


Problem 1: The N+1 Query

What it is

You load a list of Order entities. Each Order has a Customer. Hibernate fires one query for orders, then one query per order to load each customer. 10 orders = 11 queries. 1,000 orders = 1,001 queries.

// ENTITIES
@Entity
public class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)  // lazy by default for @ManyToOne
    private Customer customer;

    private BigDecimal total;
}

@Entity
public class Customer {
    @Id
    private Long id;
    private String name;
}
// SERVICE — triggers N+1
@Transactional(readOnly = true)
public List<OrderDto> getRecentOrders() {
    List<Order> orders = orderRepository.findAll(); // 1 query
    return orders.stream()
        .map(o -> new OrderDto(
            o.getId(),
            o.getTotal(),
            o.getCustomer().getName() // N queries — one per order
        ))
        .toList();
}

SQL log shows:

SELECT * FROM orders
SELECT * FROM customers WHERE id = 1
SELECT * FROM customers WHERE id = 2
SELECT * FROM customers WHERE id = 3
...

Fix 1: JOIN FETCH

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();
}

One query:

SELECT o.*, c.* FROM orders o INNER JOIN customers c ON o.customer_id = c.id

Limitation: JOIN FETCH with pagination (Pageable) causes Hibernate to load all results in memory and paginate in Java (because SQL LIMIT can’t be applied to a JOIN FETCH correctly). Watch for the HHH90003004 warning:

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

Fix 2: @EntityGraph

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findAll(Pageable pageable);  // pagination-safe
}

@EntityGraph uses a left outer join, which works correctly with pagination.

Fix 3: @BatchSize (fetch multiple in one query)

When you need lazy loading but want to reduce round trips:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @BatchSize(size = 25)
    private Customer customer;
}

Or globally for all collections:

spring.jpa.properties.hibernate.default_batch_fetch_size=25

Instead of N queries, Hibernate fires: SELECT * FROM customers WHERE id IN (1, 2, 3, ..., 25). Dramatically reduces query count while keeping lazy loading.

Fix 4: DTO Projection (most efficient)

If you only need a few fields, don’t load full entities at all:

// Interface projection
public interface OrderSummary {
    Long getId();
    BigDecimal getTotal();
    String getCustomerName();
}

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o.id AS id, o.total AS total, c.name AS customerName " +
           "FROM Order o JOIN o.customer c")
    List<OrderSummary> findOrderSummaries();
}

Hibernate generates one optimized query, selects only the three columns, and maps to the projection interface. No entity overhead.


Problem 2: LazyInitializationException

org.hibernate.LazyInitializationException: failed to lazily initialize a collection
of role: Order.items, could not initialize proxy - no Session

This happens when you access a lazy-loaded association outside a transaction — usually in a controller or service that received an entity from a method where the transaction already closed.

// TRANSACTION CLOSES HERE
@Transactional
public Order findOrder(long id) {
    return orderRepository.findById(id).orElseThrow();
}

// Session is gone by the time the controller calls this
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable long id) {
    Order order = orderService.findOrder(id);
    return new OrderDto(
        order.getId(),
        order.getItems().size() // BOOM — LazyInitializationException
    );
}

Convert inside the transaction:

@Transactional(readOnly = true)
public OrderDto findOrder(long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    return new OrderDto(
        order.getId(),
        order.getItems().size() // accessed while session is still open
    );
}

Fix 2: JOIN FETCH in the repository

@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") long id);

Fix 3: AVOID Open Session in View

Spring Boot enables OSIV (Open Session In View) by default — it keeps the Hibernate session open for the entire HTTP request, allowing lazy loading in the view layer. This is dangerous:

  • DB connections are held for the entire request (including view rendering time)
  • N+1 problems are hidden (they “work” but fire extra queries silently)
  • Memory usage increases
# Disable OSIV — forces you to be explicit about what you load
spring.jpa.open-in-view=false

With OSIV disabled, LazyInitializationException surfaces immediately, forcing you to use proper fetch strategies.


Problem 3: Over-fetching with SELECT *

Selecting full entities when you only need 2 fields wastes memory, bandwidth, and CPU time (Hibernate hydrates the entire entity graph).

Use DTO projections

// Record DTO
public record OrderListItem(Long id, String customerName, BigDecimal total, LocalDate createdAt) {}

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        SELECT new com.example.dto.OrderListItem(
            o.id, c.name, o.total, o.createdAt
        )
        FROM Order o JOIN o.customer c
        ORDER BY o.createdAt DESC
        """)
    Page<OrderListItem> findOrderList(Pageable pageable);
}

Hibernate generates: SELECT o.id, c.name, o.total, o.created_at FROM orders o JOIN customers c ...

Use Blaze-Persistence Entity Views for complex mappings

For complex projections across multiple joins, Blaze-Persistence Entity Views provides compile-time verified projections that are significantly faster than entity loading:

@EntityView(Order.class)
public interface OrderView {
    Long getId();
    BigDecimal getTotal();

    @Mapping("customer.name")
    String getCustomerName();
}

Problem 4: Connection Pool Sizing

HikariCP is Spring Boot’s default pool. Most apps use the default maximum-pool-size=10, which is often too small or too large.

The formula (from HikariCP docs)

pool_size = (cpu_cores * 2) + effective_spindle_count

For a modern SSD-backed DB server (0 spinning disks): pool_size = cpu_cores * 2

For an 8-core app server talking to a DB on SSDs:

spring.datasource.hikari.maximum-pool-size=16
spring.datasource.hikari.minimum-idle=4
spring.datasource.hikari.connection-timeout=30000   # 30s — fail fast
spring.datasource.hikari.idle-timeout=600000        # 10 min
spring.datasource.hikari.max-lifetime=1800000       # 30 min

Key metrics to watch

# Enable Hikari metrics (Actuator + Prometheus)
management.metrics.enable.hikaricp=true

Watch:

  • hikaricp.connections.active — should not consistently equal maximum-pool-size
  • hikaricp.connections.pending — consistently > 0 means pool is too small
  • hikaricp.connections.timeout.total — > 0 is bad; increase pool or fix slow queries

With Virtual Threads

Virtual Threads don’t change the connection pool math. 10,000 virtual threads can wait for a HikariCP connection without pinning carrier threads (HikariCP 5.0+ uses java.util.concurrent locks). Size the pool for what the DB can handle, not for your concurrency level.


Problem 5: Hibernate 6 Breaking Changes (Spring Boot 3+)

Spring Boot 3 ships with Hibernate 6. If you upgraded from Spring Boot 2.x, some mappings silently changed behavior.

UUID type change

// Spring Boot 2.x (Hibernate 5) — UUID stored as binary or varchar depending on DB
@Id
@GeneratedValue
private UUID id;

// Spring Boot 3.x (Hibernate 6) — UUID stored as UUID column type on PostgreSQL
// If your DB column is varchar, this breaks with a type mismatch error

Fix if your existing column is varchar:

@Id
@GeneratedValue
@JdbcTypeCode(Types.VARCHAR)
private UUID id;

@Any mapping changes

@Any type annotations changed package and behavior in Hibernate 6. If you use polymorphic associations:

// Hibernate 5
@Any(metaColumn = @Column(name = "payment_type"))

// Hibernate 6
@Any
@AnyDiscriminator(DiscriminatorType.STRING)
@Column(name = "payment_type")

Implicit join changes

Hibernate 6 changed how implicit joins in JPQL work. Some queries that worked in Hibernate 5 now require explicit JOINs:

// May fail in Hibernate 6 if customer is a lazy association
@Query("SELECT o FROM Order o WHERE o.customer.city = :city")

// Always safe — explicit join
@Query("SELECT o FROM Order o JOIN o.customer c WHERE c.city = :city")

Query Optimization Checklist

For every @Query in your repository, verify:

  • Are you only selecting the columns you actually use?
  • Is there a database index on every column in the WHERE clause?
  • For findAll() with associations — are you using JOIN FETCH or @EntityGraph?
  • Is pagination working at the DB level (not in memory)?
  • Is spring.jpa.open-in-view=false in production?
  • Is hibernate.default_batch_fetch_size set for lazy collections?

Add DB indexes via JPA

@Entity
@Table(
    name = "orders",
    indexes = {
        @Index(name = "idx_orders_customer_id", columnList = "customer_id"),
        @Index(name = "idx_orders_created_at", columnList = "created_at DESC"),
        @Index(name = "idx_orders_status_created", columnList = "status, created_at")
    }
)
public class Order {
    // ...
}

Quick Reference

// N+1 fix — JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();

// N+1 fix — @EntityGraph (pagination-safe)
@EntityGraph(attributePaths = {"customer"})
Page<Order> findAll(Pageable pageable);

// Batch fetch globally
spring.jpa.properties.hibernate.default_batch_fetch_size=25

// DTO projection — most efficient
@Query("SELECT new com.example.dto.OrderDto(o.id, c.name) FROM Order o JOIN o.customer c")
List<OrderDto> findOrderDtos();

// Disable OSIV (recommended)
spring.jpa.open-in-view=false

// Connection pool
spring.datasource.hikari.maximum-pool-size=16
spring.datasource.hikari.connection-timeout=30000

Summary

JPA performance problems are predictable and fixable. The N+1 problem kills performance at scale — solve it with JOIN FETCH for simple cases, @EntityGraph for pagination, and @BatchSize globally as a safety net. Turn off OSIV to force explicit fetch strategies. Use DTO projections to avoid loading full entity graphs when you only need a few fields. Check for Hibernate 6 UUID and mapping changes if you migrated from Spring Boot 2.x.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.