JPA Lazy Loading and the N+1 Problem in Spring Boot

Introduction

This tutorial explains lazy loading in JPA and the performance problem that comes with it, the N+1 problem. We will start with what lazy and eager loading mean and the defaults of each relationship type. We will then see the LazyInitializationException that every JPA beginner meets, and watch lazy loading work inside a transaction. Next we will create the N+1 problem on purpose, count the SQL statements it produces with Hibernate's statistics, and fix it three ways: join fetch, an @EntityGraph, and batch fetching. Lastly, we will see why fetching a collection and paging do not mix, and what the open-in-view setting hides.

(This tutorial uses the publisher and book model from JPA Entity Relationships: OneToOne, OneToMany, ManyToManyClick to read tutorial: JPA Entity Relationships: OneToOne, OneToMany, ManyToMany, reduced to the one-to-many part.)

Lazy and eager loading

When you load a Publisher, should Hibernate also load its books? JPA lets each relationship decide -
  • Eager (FetchType.EAGER): the related data is loaded together with the owner, always.
  • Lazy (FetchType.LAZY): the related data is loaded on first access. Until then, the field holds a placeholder - a collection that fills itself when touched, or a proxy object standing in for a single entity.
The defaults are not the same for every relationship: @OneToMany and @ManyToMany are lazy; @ManyToOne and @OneToOne are eager. The lazy default for collections makes sense - a publisher could have thousands of books. The eager default for single-valued relationships is a frequent source of trouble, and the usual advice is to declare fetch = FetchType.LAZY on every @ManyToOne, as the previous tutorial did, and fetch explicitly what a use case needs.

The example

The application has publishers with books, and a statistics helper that counts how many SQL statements a piece of code runs. Counting statements - not guessing - is how you find N+1 problems. Spring Boot passes Hibernate settings through spring.jpa.properties.*; the statistics are switched on in the properties file -
application.properties - switching on Hibernate's statistics
spring.jpa.properties.hibernate.generate_statistics=true
Publisher.java - a lazy collection of books
//Publisher.java
package com.javabrahman.data.lazy;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Publisher {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String name;

  @OneToMany(mappedBy = "publisher", cascade = CascadeType.ALL)   // lazy by default
  private List<Book> books = new ArrayList<>();

  protected Publisher() {
  }

  public Publisher(String name) {
    this.name = name;
  }

  public void addBook(Book book) {
    books.add(book);
    book.setPublisher(this);
  }

  public String getName() { return name; }
  public List<Book> getBooks() { return books; }
}
Book.java - declared lazy on the many-to-one side too
//Book.java
package com.javabrahman.data.lazy;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
public class Book {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String title;

  @ManyToOne(fetch = FetchType.LAZY)
  private Publisher publisher;

  protected Book() {
  }

  public Book(String title) {
    this.title = title;
  }

  public String getTitle() { return title; }
  public Publisher getPublisher() { return publisher; }
  void setPublisher(Publisher publisher) { this.publisher = publisher; }
}
PublisherRepository.java - the plain method and the fixes
//PublisherRepository.java
package com.javabrahman.data.lazy;

import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface PublisherRepository extends JpaRepository<Publisher, Long> {

  // Fix 1: join fetch loads each publisher together with its books, in one query
  @Query("select distinct p from Publisher p join fetch p.books")
  List<Publisher> findAllWithBooks();

  // Fix 2: an entity graph says the same thing without touching the query
  @EntityGraph(attributePaths = "books")
  List<Publisher> findAllBy();

  // Fetching a collection and paging do not mix (see below)
  @Query(value = "select distinct p from Publisher p join fetch p.books", countQuery = "select count(p) from Publisher p")
  Page<Publisher> findPageWithBooks(Pageable pageable);
}
LazyApp.java - lazy loading, the N+1 problem and its fixes, with statement counts
//LazyApp.java
package com.javabrahman.data.lazy;

import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import java.util.function.Supplier;
import org.hibernate.LazyInitializationException;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.PageRequest;
import org.springframework.transaction.support.TransactionTemplate;

@SpringBootApplication
public class LazyApp {

  @Bean
  CommandLineRunner demo(PublisherRepository publishers, EntityManagerFactory emf, TransactionTemplate tx) {
    Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();

    return args -> {
      for (String name : List.of("Addison-Wesley", "Manning", "O'Reilly", "Pragmatic Bookshelf")) {
        Publisher p = new Publisher(name);
        for (int i = 1; i <= 3; i++) {
          p.addBook(new Book(name + " book " + i));
        }
        publishers.save(p);
      }

      System.out.println("== lazy loading outside a transaction");
      Publisher detached = publishers.findAll().get(0);
      try {
        System.out.println(detached.getBooks().size());
      } catch (LazyInitializationException e) {
        System.out.println("LazyInitializationException: " + e.getMessage());
      }

      System.out.println("== lazy loading inside a transaction");
      count(stats, "load one publisher and read its books", () -> tx.execute(status -> {
        Publisher p = publishers.findAll().get(0);
        return p.getBooks().size();
      }));

      System.out.println("== N+1 loop");
      count(stats, "N+1 loop: publishers and their books", () -> tx.execute(status -> {
        int books = 0;
        for (Publisher p : publishers.findAll()) {   // 1 query for the publishers ...
          books += p.getBooks().size();              // ... and 1 more for each publisher's books
        }
        return books;
      }));

      System.out.println("== fix 1: join fetch");
      count(stats, "join fetch", () -> tx.execute(status -> publishers.findAllWithBooks().stream().mapToInt(p -> p.getBooks().size()).sum()));

      System.out.println("== fix 2: entity graph");
      count(stats, "entity graph", () -> tx.execute(status -> publishers.findAllBy().stream().mapToInt(p -> p.getBooks().size()).sum()));

      System.out.println("== paging a fetched collection");
      count(stats, "page of publishers with join fetch", () -> tx.execute(status -> publishers.findPageWithBooks(PageRequest.of(0, 2)).getContent().size()));
    };
  }

  // Runs the work and prints how many SQL statements it prepared
  private static void count(Statistics stats, String label, Supplier<Object> work) {
    stats.clear();
    Object result = work.get();
    System.out.println(label + ": result " + result + ", SQL statements: " + stats.getPrepareStatementCount());
  }

  public static void main(String[] args) {
    SpringApplication.run(LazyApp.class, args);
  }
}
 OUTPUT of the above code
== lazy loading outside a transaction
LazyInitializationException: Cannot lazily initialize collection of role 'com.javabrahman.data.lazy.Publisher.books' with key '1' (no session)
== lazy loading inside a transaction
load one publisher and read its books: result 3, SQL statements: 2
== N+1 loop
N+1 loop: publishers and their books: result 12, SQL statements: 5
== fix 1: join fetch
join fetch: result 12, SQL statements: 1
== fix 2: entity graph
entity graph: result 12, SQL statements: 1
== paging a fetched collection
page of publishers with join fetch: result 4, SQL statements: 2

Explanation of the output

  • Outside a transaction, findAll() loads the publishers and the Hibernate session closes. The books collection was never loaded, so touching it fails with LazyInitializationException: there is no session left to load it with. This is the error everyone meets first, and it is Hibernate telling you the truth: the data is not there, and cannot be fetched from here.
  • Inside a transaction, the same access works, because the session is still open: Hibernate loaded the publisher with one statement, and the books with a second, at the moment getBooks().size() was called.

The N+1 problem

The loop looks harmless: take all publishers, count each one's books. But look at the statement count in the output. The first query loads the 4 publishers (1 query); then, for each of the N = 4 publishers, touching getBooks() triggers one more query for that publisher's books. That is N + 1 = 5 statements to read what two queries - or one - could return. With 4 rows nobody notices. With 10,000 publishers it is 10,001 round trips to the database, and the page that took 20 milliseconds in testing takes 20 seconds in production.

The statements themselves, taken from the same program with spring.jpa.show-sql=true - the publisher query followed by one query per publisher -  OUTPUT - the SQL of the N+1 loop
== N+1 loop
Hibernate: select p1_0.id,p1_0.name from publisher p1_0
Hibernate: select b1_0.publisher_id,b1_0.id,b1_0.title from book b1_0 where b1_0.publisher_id=?
Hibernate: select b1_0.publisher_id,b1_0.id,b1_0.title from book b1_0 where b1_0.publisher_id=?
Hibernate: select b1_0.publisher_id,b1_0.id,b1_0.title from book b1_0 where b1_0.publisher_id=?
Hibernate: select b1_0.publisher_id,b1_0.id,b1_0.title from book b1_0 where b1_0.publisher_id=?

Fixing it: tell Hibernate what you need

Every fix says the same thing in a different way: load the books together with the publishers.
  • join fetch (fix 1) adds the books to the JPQL query: select distinct p from Publisher p join fetch p.books. One statement loads publishers and books together, as a join. distinct removes the duplicate publishers that the join produces - one row per book.
  • @EntityGraph(attributePaths = "books") (fix 2) states the same on the repository method, without writing a query, so it also works on derived queries. It is the tidiest way to say "this method needs the books".
  • Both fixes brought the statement count down to 1, as the output shows, for the same result - 12 books.
The right fix depends on the use case: fetch the books in the method that needs them, and leave the plain findAll() lazy for the places that do not.

Fix 3: batch fetching

Sometimes you cannot know in advance which collections will be touched. Batch fetching keeps lazy loading but reduces the number of statements: when one lazy collection is loaded, Hibernate loads those of up to n other publishers in the same query, using an in list. It is a setting - spring.jpa.properties.hibernate.default_batch_fetch_size - or the @BatchSize annotation on one collection. Running the same program again with a batch size of 10, and showing only the N+1 loop's statement count -  OUTPUT with default_batch_fetch_size=10 - the N+1 loop's statement count
N+1 loop: publishers and their books: result 12, SQL statements: 2
The loop's code is unchanged - it still touches each publisher's books - but the 4 collections were loaded together: 2 statements instead of 5. With a batch size of 10 and 10,000 publishers it would be about 1,001 instead of 10,001. Batch fetching does not remove the extra queries, it divides them; use it as a safety net behind explicit fetching, rather than instead of it.

Fetching a collection and paging do not mix

It is natural to combine join fetch with Pageable, to fetch "the first two publishers with their books". The last query in the program does exactly that. The SQL limit applies to rows of the join, and a publisher with three books is three rows, so a limit of 2 would return the wrong publishers. Hibernate therefore cannot apply the page limit in the database, and the statement count of that query shows what happens in practice -  OUTPUT - paging with a fetched collection
== paging a fetched collection
Hibernate: select distinct p1_0.id,b1_0.publisher_id,b1_0.id,b1_0.title,p1_0.name from publisher p1_0 join book b1_0 on p1_0.id=b1_0.publisher_id
Hibernate: select count(p1_0.id) from publisher p1_0
page of publishers with join fetch: result 4, SQL statements: 2
Hibernate: drop table if exists book cascade
Hibernate: drop table if exists publisher cascade

Explanation of the paging output

  • Look at the first select: it has no limit clause (no fetch first or offset), although we asked for a page of 2. Hibernate loaded every publisher with all its books and cut the page in the application. The second statement is the count query that Page needs for its totals. Harmless with 4 publishers, a memory problem with 400,000.
  • The reliable pattern is two queries: first page through the ids or the owners alone (Page<Publisher> without any fetch), then load the books for just those owners with a second query, select p from Publisher p join fetch p.books where p in :publishers, or let batch fetching do it.
  • Fetching two collections at once in one query (join fetch p.books join fetch p.awards) multiplies the rows, and for List collections Hibernate refuses with a MultipleBagFetchException. Fetch one collection per query.

open-in-view: the setting that hides it all

In a web application, Spring Boot enables spring.jpa.open-in-view by default (and logs a warning about it at startup): the JPA session stays open for the whole web request, until the response is rendered. That is why a controller can call publisher.getBooks() on an entity it received from a repository without a LazyInitializationException - and why N+1 problems often first show up in the view or while serialising JSON, far from the code that caused them. It also holds a database connection for the entire request, including slow rendering and slow calls to other services.

Many teams set spring.jpa.open-in-view=false. Lazy loading outside a transaction then fails immediately, as in the first example of this tutorial, which is exactly what you want: it forces the service layer to load what the response needs - with a fetch join, an entity graph or a DTO projection (the subject of Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs) - and keeps entities out of controllers and JSON.

Finding N+1 problems before production

  • Count statements in tests with Hibernate's Statistics, as this program does, and fail a test that suddenly runs 500 statements instead of 3.
  • Read the SQL in development with spring.jpa.show-sql=true: a column list repeated dozens of times in the log is an N+1 problem.
  • Watch query counts in production: with Micrometer and the Actuator, Hibernate statistics can be exposed as metrics (module 11).
  • Prefer queries that fit the use case: for read-only screens, a query that returns exactly the needed columns avoids both lazy loading and over-fetching.

Summary

In this tutorial we first met lazy and eager loading, and the defaults: collections lazy, @ManyToOne and @OneToOne eager. We saw the LazyInitializationException when a lazy collection is touched after the session closed, and lazy loading working inside a transaction. We then created the N+1 problem - 5 statements for 4 publishers - counted it with Hibernate's statistics, and fixed it with join fetch and with an @EntityGraph (1 statement each), and softened it with batch fetching (2 statements). We looked at why a fetched collection and paging do not mix, and at what open-in-view hides, and listed ways to catch these problems early. The next tutorial covers how changes to entities are turned into SQL, and what happens when several changes must succeed or fail together: @Transactional, propagation and isolationClick to read tutorial: Spring @Transactional: Propagation and Isolation.