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
);
}
Fix 1: Use DTO projection (recommended)
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 equalmaximum-pool-sizehikaricp.connections.pending— consistently > 0 means pool is too smallhikaricp.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=falsein production? - Is
hibernate.default_batch_fetch_sizeset 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.
