Spring Boot path · Data and production · Topic 9 of 12

Spring Data JPA, lazy loading and N+1

Not started

Spring Data JPA turns an interface into a working repository. At start-up it reads each method's name or @Query, generates the JPQL and creates a proxy bean that runs it through Hibernate, the default JPA provider in Spring Boot. The convenience is real, and so is the cost: the SQL is out of sight, and entities with associations can fire queries you never wrote, one per row.

Why it matters: A page that runs 1 query in development can run 101 in production; you only notice when the database is the bottleneck.

  1. 1Interfaces become beans

    Spring Data generates a repository implementation at start-up from method names, @Query annotations and the entity type.

  2. 2Associations load lazily

    A lazy collection is a placeholder that runs a query the first time you touch it, which needs an open persistence context.

  3. 3N+1 is one query per row

    Loading N parents and then touching each one's lazy children runs one extra query per parent.

  4. 4Fetch what the use case needs

    Use a fetch join, an @EntityGraph or a DTO projection to load it all in one query.

1

How it works

The explanation, with examples you can run.

Repositories

@Entityclass Order {  @Id @GeneratedValue Long id;  @ManyToOne(fetch = FetchType.LAZY) Customer customer;  @OneToMany(mappedBy = "order") List<OrderItem> items = new ArrayList<>();  Instant placedAt;} interface OrderRepository extends JpaRepository<Order, Long> {  List<Order> findByCustomerIdAndPlacedAtAfter(Long customerId, Instant since);   @Query("select o from Order o join fetch o.items where o.customer.id = :id")  List<Order> findWithItems(@Param("id") Long customerId);   @EntityGraph(attributePaths = {"items", "customer"})  List<Order> findTop20ByOrderByPlacedAtDesc();}

Derived queries are parsed from the method name; a typo is a start-up error, not a runtime one. The methods inherited from JpaRepository (save, findById, ...) are transactional, read-only for reads; query methods you declare yourself get no transaction settings unless you annotate them or the interface. Either way, your service should own the transaction that spans several calls.

Lazy loading

JPA's defaults: @OneToMany and @ManyToMany are lazy; @ManyToOne and @OneToOne are eager unless you say fetch = FetchType.LAZY, which is the safer habit. A lazy association is a proxy or a wrapper collection. Touching it runs a SELECT through the persistence context, which must still be open, or you get a LazyInitializationException.

Spring Boot enables open session in view by default (spring.jpa.open-in-view=true) and logs a warning about it at start-up. It keeps the EntityManager open until the response is written, so lazy loading from the controller or the JSON serialiser "just works". The cost is that a database connection can be held for the whole request and queries happen far from the code that should own them. Many teams turn it off and fetch deliberately.

N+1

// 1 query for the orders...List<Order> orders = repo.findByCustomerIdAndPlacedAtAfter(id, since);// ...plus 1 per order, the first time items is touchedorders.forEach(o -> o.getItems().size());

Fixes, chosen per use case:

  • Fetch join in a @Query (join fetch o.items) loads parents and children in one statement. Combined with pagination on a collection, Hibernate paginates in memory and warns, so page the parent ids first.
  • @EntityGraph names the associations to load for one repository method.
  • Batch fetching (spring.jpa.properties.hibernate.default_batch_fetch_size=50) turns N extra queries into N/50.
  • DTO projections (select new com.example.OrderRow(o.id, o.placedAt) ... or an interface projection) skip entities entirely for read-only lists.

Seeing the SQL

spring.jpa.show-sql=truelogging.level.org.hibernate.SQL=debuglogging.level.org.hibernate.orm.jdbc.bind=tracespring.jpa.properties.hibernate.generate_statistics=true

Turn these on in a test or locally, call the endpoint once and count the statements. A repeated select ... from order_item where order_id=? is N+1, every time.

2

Follow the flow

Play it or step through. Pause and tap a box to see what it does.

findByCustomerIdAndPlacedAtAfterrun the derived JPQLselect ... from orders: 1 query50 Orders, items not loaded yetorder 1: getItems().size()select ... from order_item where order_id=?orders 2 to 50: the same select, once eachafter commit: getItems()LazyInitializationExceptionfindWithItems(customerId)select o, i ... join fetch o.items: 1 queryorders with items filled inOrderServiceOrderRepositoryPersistence contextPostgreSQL

Paused · tap a box to explore it

Step 1 of 10

The service method is @Transactional, so a persistence context, Hibernate's session, is open for as long as it runs. Every entity loaded inside it is tracked there.

3

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.How many SQL queries does this run for 50 orders, if Order.items is a lazy @OneToMany?
    List<Order> orders = orderRepository.findAll();for (Order o : orders) {  total += o.getItems().size();}
  2. 2.A controller returns an entity whose lazy collection is serialised to JSON after the service's transaction has ended. With spring.jpa.open-in-view=false, what happens?
  3. 3.Which is the usual fix for N+1 on a list endpoint?

0 of 3 right so far. Get all three to finish this topic.