Spring Data JPA Performance: Solving N+1, Fetching & Pagination

Spring Data JPA makes the data layer feel effortless — until a service that was fast with a thousand rows crawls with a million. Almost always the cause is not the database but how JPA talks to it: hidden N+1 query storms, eager associations dragging in half the schema, and pagination that quietly breaks. This deep dive shows how to find and fix the JPA performance problems that actually bite enterprise Java services.

TL;DR: Make associations LAZY by default, then load exactly what a use case needs with fetch joins or entity graphs. Kill N+1 by fetching collections deliberately, not by accident. Use DTO projections for read-heavy endpoints, batch your inserts and fetches, and never combine a collection fetch join with pagination — use a two-step ID query instead.
Tailor your resume to a backend / Java role →
flowchart TD
  subgraph NP["N+1: 1 + N queries"]
    Q1[Load N orders] --> L1["Per order: query its items"]
  end
  subgraph FJ["Fetch join: 1 query"]
    Q2[Load orders join fetch items]
  end
        
N+1 issues one query per row to load an association; a fetch join does it in a single query.

The N+1 problem, concretely

You load 100 orders, then loop and read order.getItems() on each. With lazy associations, JPA issues 1 query for the orders and then 1 more query per order to fetch its items — 101 queries to render one page. That is the N+1 select problem, and it is the single most common JPA performance bug. It is invisible in tests with three rows and catastrophic in production.

The first step is to see it. Log SQL and counts in non-prod, or use a tool that fails the test when a query budget is exceeded:

# See exactly what JPA executes
spring:
  jpa:
    show-sql: true
    properties:
      hibernate.format_sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
# In tests, datasource-proxy or QuickPerf can assert "<= 1 query".

Fix it: fetch joins and entity graphs

A fetch join tells JPA to load the association in the same query, turning N+1 into 1:

@Query("select o from Order o join fetch o.items where o.status = :status")
List<Order> findWithItems(@Param("status") OrderStatus status);

When you want to reuse a repository method but vary what’s fetched, an entity graph declares the associations to load without writing JPQL:

@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(OrderStatus status);

Both produce a join and eliminate the per-row queries. The difference is style: fetch joins live in the query, entity graphs are reusable named/ad-hoc fetch plans layered on derived queries.

Lazy vs eager: default to lazy

JPA defaults @ManyToOne/@OneToOne to EAGER and collections to LAZY. The EAGER default is a trap: it loads the association on every query that touches the entity, whether you need it or not, causing over-fetching and surprise N+1. The discipline used by experienced teams: make everything LAZY, then opt in to fetching per use case.

@ManyToOne(fetch = FetchType.LAZY)   // override the eager default
private Customer customer;

The flip side of lazy is the LazyInitializationException — accessing a lazy association after the persistence context closed. The right fix is to fetch what you need inside the transaction (fetch join / entity graph), not to re-enable open-in-view, which holds a DB connection for the whole request and hides these problems.

Don’t fetch entities you only read: projections

For read endpoints that return a slice of fields, loading full managed entities is wasteful — you pay for hydration, dirty checking, and associations you’ll never use. DTO projections select only the columns you need:

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

@Query("""
  select o.id as id, o.customer.name as customerName, o.total as total
  from Order o where o.status = :status
""")
List<OrderSummary> summaries(@Param("status") OrderStatus status);

Projections cut data transfer and remove entity overhead entirely — often the biggest single win for list and dashboard endpoints.

Batch your writes and reads

Saving 1,000 entities one INSERT at a time is 1,000 round trips. Enable JDBC batching so Hibernate groups them:

spring:
  jpa:
    properties:
      hibernate.jdbc.batch_size: 50
      hibernate.order_inserts: true
      hibernate.order_updates: true

For the read side, @BatchSize (or the global hibernate.default_batch_fetch_size) turns “one query per parent for its collection” into “one query per batch of parents” using an IN clause — a pragmatic N+1 mitigation when a fetch join doesn’t fit.

The pagination + collection-join trap

Combining a collection fetch join with pagination is a classic production incident. Because a join multiplies rows (one order with three items becomes three rows), the database can’t apply LIMIT correctly — so Hibernate fetches everything into memory and paginates in the JVM, logging a warning like “firstResult/maxResults specified with collection fetch; applying in memory.” On a large table that is an OOM waiting to happen.

The fix is a two-step query: page the IDs first (no join, so LIMIT works), then fetch the full entities with their collections for just those IDs.

// Step 1: page the IDs — LIMIT applied in SQL
@Query("select o.id from Order o where o.status = :status")
Page<Long> pageIds(@Param("status") OrderStatus status, Pageable pageable);

// Step 2: fetch those entities with items — no pagination here
@Query("select distinct o from Order o join fetch o.items where o.id in :ids")
List<Order> withItems(@Param("ids") List<Long> ids);

Paginating a scalar query (no collection join) is fine — this only applies to collection fetches.

Other high-value tactics

When JPA isn’t the right tool

JPA shines for transactional, entity-shaped work. For complex reporting queries, heavy aggregations, or window functions, drop to native SQL or a query builder like jOOQ rather than fighting JPQL — and use a read replica for analytics so reporting load doesn’t compete with your transactional traffic. Using the right tool per workload beats forcing everything through the ORM.

Takeaways

Fast Spring Data JPA is mostly about controlling what gets loaded and when: default to lazy, fetch deliberately with joins and entity graphs to kill N+1, project to DTOs for reads, batch writes and fetches, and never page a collection fetch join. Turn on SQL logging early and assert a query budget in tests so N+1 is caught in CI, not in a 2 a.m. page. The database is rarely the bottleneck — the conversation with it is.

Frequently asked questions

What is the N+1 select problem in JPA?
It happens when you load a list of N entities with one query, then accessing a lazy association on each triggers one extra query per entity — 1 + N queries instead of one. It is the most common JPA performance bug. Fix it with a fetch join, an entity graph, or batch fetching.

Should JPA associations be lazy or eager?
Default to LAZY for almost everything. EAGER loading fetches associations you may not need on every query and is a frequent cause of hidden N+1 and over-fetching. Load what a specific use case needs with a fetch join or entity graph instead.

Land your next Java role — tailor your resume with AI →