Spring Boot Course · Lesson 9
Your paginated endpoints look fast and correct. But turn on the SQL log and GET /api/books fires eight queries for one page of seven books — one for the page, then one more for every single book to load its author. That's the N+1 problem: the most common performance trap in JPA, and a favourite interview question. This lesson makes it visible, makes your app refuse to hide it, then kills it with one annotation.
1 + N queries. Fail loud — turn off Open Session in View so a hidden lazy load throws instead of silently querying. Fix it — add @EntityGraph so the association loads in the same query: one JOIN instead of N+1 round-trips. All verified against real Hibernate output.
You run 1 query to fetch a list of parents. Then, for each of the N parents, the ORM quietly fires one more query to fetch a related child. One page of 20 books that each touch their author becomes 21 database round-trips — and it scales with your data, not your code. The query count is invisible in the JSON response; the only way to see it is to watch the SQL.
Hibernate can print every statement it runs. Switch it on (this is the logging-based alternative we flagged back in Lesson 5 — no show-sql needed):
src/main/resources/application.properties
# Log every SQL statement Hibernate executes
logging.level.org.hibernate.SQL=DEBUG Restart, then hit the books list and count the SQL lines:
curl -s "http://localhost:8080/api/books?size=20" # 7 seeded books, distinct authors -- 1: the page of books
select b1_0.id,b1_0.author_id,b1_0.title,b1_0.year from book b1_0 order by b1_0.title,b1_0.id offset ? rows fetch first ? rows only
-- then ONE of these per book, to load its author:
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
One query for the page, seven for the authors — 1 + 7 = 8. The same smell is on the authors endpoint, just mirrored: GET /api/authors runs one query for the page of authors, then one per author to load their books collection.
-- GET /api/authors?size=20 -> 1 + 7 again (the to-many direction)
select a1_0.id,a1_0.name from author a1_0 order by a1_0.name,a1_0.id offset ? rows fetch first ? rows only
select b1_0.author_id,b1_0.id,b1_0.title,b1_0.year from book b1_0 where b1_0.author_id=? -- x7 @ManyToOne(fetch = FetchType.LAZY) — the author isn't loaded with the book; it's a placeholder until something touches it. And something does: mapping each Book to a BookResponse calls book.getAuthor() to build the nested AuthorSummary. That first touch triggers a fresh SELECT. Seven books, seven touches, seven queries. Pagination (Lesson 8) caps the damage at page size rather than table size — but 1+20 per request is still a bug.
Those lazy loads happen during DTO mapping, in the controller — after the service returned. That should be impossible: a lazy association needs an open persistence session, and the transaction that loaded the books is long over. It works because of a Spring Boot default called Open Session in View (OSIV): it holds the Hibernate session open for the entire web request, straight through JSON rendering. Convenient — it's why entity-to-DTO mapping "just works" — but it's exactly what lets N+1 hide. The queries fire; you just never feel them.
Turn OSIV off. Now the session closes when the transaction that loaded the data ends — in our app, the repository call itself — and any lazy access during mapping has no session to run in:
src/main/resources/application.properties
# Close the session at the end of the service layer, not the end of the request
spring.jpa.open-in-view=false Restart and hit the books list again. The hidden N+1 becomes a loud, immediate failure:
HTTP 500
org.hibernate.LazyInitializationException:
Could not initialize proxy [com.example.library.author.Author#019f48…] - no session LazyInitializationException above. (In our app there's no @Transactional on the service, so the loading transaction is the repository call itself — the session closes the moment findAll returns, well before the controller maps.)
The switch comes down to who owns the session and how long it lives:
| OSIV on (Spring Boot default) | OSIV off | |
|---|---|---|
| Who owns the session | the request — opened at request start | each repository call |
| Session lifespan | the whole request, through JSON rendering | just the repository method |
| Entities after the service returns | managed (session still open) | detached (session closed) |
book.getAuthor() during mapping | initializes → fires a query silently | throws LazyInitializationException |
| Net result | works, but a hidden N+1 | a loud, immediate failure |
OSIV-on isn't "no boundary" — it's a much wider one (the whole request) that makes lazy loading always succeed. That convenience is exactly what hides the bug; narrowing the session back to the query that loaded the data is what surfaces it.
@EntityGraph
The book needs its author, so fetch them together. @EntityGraph tells Spring Data which associations to pull in the same query — redeclare the repository methods that feed the DTO mapping and annotate them:
src/main/java/com/example/library/book/BookRepository.java
public interface BookRepository extends JpaRepository<Book, UUID> {
@Override
@EntityGraph(attributePaths = "author")
Page<Book> findAll(Pageable pageable);
@Override
@EntityGraph(attributePaths = "author")
Optional<Book> findById(UUID id);
@EntityGraph(attributePaths = "author")
Page<Book> findByAuthorName(String name, Pageable pageable);
}
That's the whole fix for the books side. Restart, hit the list, and the eight queries collapse to one — a single LEFT JOIN that carries the author along:
select b1_0.id, a1_0.id, a1_0.name, b1_0.title, b1_0.year
from book b1_0
left join author a1_0 on a1_0.id = b1_0.author_id
order by b1_0.title, b1_0.id
offset ? rows fetch first ? rows only @ManyToOne join adds columns, not rows — each book still maps to exactly one row, author attached. So the database's LIMIT/OFFSET still slices books correctly, and pagination and fetch-joining compose cleanly. (That's specific to to-one associations; joining a to-many collection multiplies rows, which is the wrinkle in the next step.) @EntityGraph is a fetch hint, not a query rewrite — the method still means "find all," it just loads the graph you named alongside.
With OSIV off, the author endpoints are still failing, and they show a subtler truth: the right fix depends on the association's shape. The paginated list and the single-author detail want different things.
Why does a page of authors need to inline every author's complete book collection? It doesn't — that's a heavy payload and the source of the to-many N+1. Return the compact AuthorSummary (id + name) you already built in Lesson 6. It touches no lazy collection, so there's nothing to N+1, and the query is a clean single SELECT:
src/main/java/com/example/library/author/AuthorController.java — the list handler
@GetMapping // list: lightweight summary (id + name), no inline books
public Page<AuthorSummary> all(@PageableDefault(size = 20, sort = "name") Pageable pageable) {
return authors.findAll(Pageables.withIdTiebreaker(pageable)).map(AuthorSummary::from);
} -- GET /api/authors -> one query, no per-author book fetch
select a1_0.id, a1_0.name from author a1_0 order by a1_0.name, a1_0.id offset ? rows fetch first ? rows only @EntityGraph
A single author's page legitimately wants the full book list — and here there's no pagination on the query, so fetching the collection is a clean join. Add the graph to the author's findById:
src/main/java/com/example/library/author/AuthorRepository.java
public interface AuthorRepository extends JpaRepository<Author, UUID> {
@Override
@EntityGraph(attributePaths = "books")
Optional<Author> findById(UUID id);
} -- GET /api/authors/{id} -> one query, author + books joined
select a1_0.id, a1_0.name, b1_0.author_id, b1_0.id, b1_0.title, b1_0.year
from author a1_0
left join book b1_0 on a1_0.id = b1_0.author_id
where a1_0.id = ?
Every endpoint is now green again with one query each, and LazyInitializationException is gone — because every association a controller touches is fetched on purpose.
@EntityGraph the authors list too?
Because joining a collection multiplies rows — one author with M books becomes M rows — so a naive LIMIT counts book-rows, not authors, and would slice mid-collection. Modern Hibernate handles this correctly (it paginates the authors in a subquery first, then joins their books — leaning on the Lesson 8 id tiebreaker for a stable parent order), so the fetch-join would work. We still don't do it: a paged list shouldn't ship every author's entire library. Trimming to AuthorSummary is the lighter, better-designed choice — and it sidesteps the collection-pagination question entirely.
@EntityGraph is the everyday fix, but know the neighbours:
JOIN FETCH — the same idea written explicitly in JPQL (@Query("select b from Book b join fetch b.author")); use it when you need a custom query, not just a fetch hint.@BatchSize / hibernate.default_batch_fetch_size — when you keep associations lazy, this batches the N loads into a few IN (?, ?, …) queries instead of N singles. Turns 1 + N into roughly 1 + (N / batchSize).select new, or a Spring Data interface projection), skipping entity loading altogether. The lightest option for read-heavy endpoints.Same endpoints, same data, one annotation's difference:
| Endpoint | OSIV on (hidden) | OSIV off, no fix | OSIV off + @EntityGraph |
|---|---|---|---|
GET /api/books?size=20 | 1 + 7 = 8 queries | 500 — fails loud | 1 query (LEFT JOIN) |
GET /api/authors?size=20 | 1 + 7 = 8 queries | 500 — fails loud | 1 query (summary list) |
GET /api/authors/{id} | lazy loads | 500 — fails loud | 1 query (LEFT JOIN) |
@EntityGraph is on a method the request doesn't call. The list uses findAll(Pageable) / findByAuthorName; make sure the graph is on the exact overload being invoked.LazyInitializationException after the fix? Some association a DTO touches still isn't fetched. Trace what the mapping reads and add it to attributePaths, or trim the DTO so it doesn't reach for it.findAll(Pageable) won't compile? Match the inherited signature exactly and add @Override; import org.springframework.data.jpa.repository.EntityGraph.Answer from memory before revealing.
open-in-view off?@EntityGraph(attributePaths = "author") safe on a paginated books query?AuthorSummary instead of fetch-joining every author's books? Primary source: Vlad Mihalcea's N+1 query problem with JPA and Hibernate — the definitive explanation, from the author of High-Performance Java Persistence. Follow it with The Open Session in View anti-pattern for the OSIV argument in full, and Baeldung's JPA Entity Graph for more @EntityGraph patterns.
New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.