JPA performance: N+1, fetching and projections

Spring Boot · Advanced · 16 min

JPA hides SQL, and the queries it generates by default are the most common Spring performance problem.

The N+1 problem: you load N orders with one query, then touching order.getCustomer() for each triggers N more queries. It's invisible in code and painful in production. Make it visible first: enable spring.jpa.show-sql or, better, log org.hibernate.SQL at DEBUG and count queries in tests.

Fetching rules:

  • @ManyToOne and @OneToOne are eager by default in JPA; mark them fetch = FetchType.LAZY. Collections (@OneToMany) are lazy.
  • Load what a use case needs in one query with a fetch join, select o from Order o join fetch o.customer, or @EntityGraph(attributePaths = "customer") on a repository method.
  • hibernate.default_batch_fetch_size (e.g. 50) batches lazy loads into IN queries as a safety net.

Spring Boot enables open-in-view by default, keeping the persistence context open during view rendering so lazy loading works in controllers; it logs a warning at startup. Set spring.jpa.open-in-view=false so lazy loading outside transactions fails loudly instead of firing hidden queries.

For read-only screens, query projections instead of entities: an interface or record with just the needed columns (List<BookSummary> findByAuthor(String a)), avoiding dirty-checking overhead. Page large results with Pageable (findAll(PageRequest.of(0, 20, Sort.by("title")))); fetch-joining collections while paging forces in-memory pagination, so avoid that combination.

Example

@Entity
class Order {
    @Id @GeneratedValue Long id;
    @ManyToOne(fetch = FetchType.LAZY) Customer customer;   // default would be EAGER
    BigDecimal total;
}

record OrderSummary(Long id, String customerName, BigDecimal total) {}

interface OrderRepository extends JpaRepository<Order, Long> {
    // 1 query instead of 1 + N
    @EntityGraph(attributePaths = "customer")
    List<Order> findByTotalGreaterThan(BigDecimal min);

    // DTO projection: only the needed columns, no managed entities
    @Query("select new com.example.OrderSummary(o.id, c.name, o.total) " +
           "from Order o join o.customer c where o.total > :min")
    Page<OrderSummary> summaries(@Param("min") BigDecimal min, Pageable page);
}

// application.properties
// spring.jpa.open-in-view=false
// spring.jpa.properties.hibernate.default_batch_fetch_size=50
// logging.level.org.hibernate.SQL=DEBUG

Remember

  • N+1: one query for the list plus one per element; count queries to catch it
  • Make to-one associations LAZY and fetch per use case with join fetch or @EntityGraph
  • Disable open-in-view so hidden lazy loading fails fast
  • Use DTO projections and paging for read-heavy endpoints

Watch out for

  • Leaving @ManyToOne at its eager default, so every load of the entity pulls related rows you didn't need.

Check yourself

  1. Loading 100 orders and reading order.getCustomer().getName() in a loop issues 101 queries. What fixes it in one query?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html

Builds on

Unlocks

Nothing builds on this yet.