The persistence context and lazy loading

First-level cache, dirty checking, flush, detached entities, open-in-view, and the N+1 query you can see in the logs.

8 min read🗃️ Spring Data JPA and Hibernate

The previous lesson said Hibernate watches your entities. This lesson is what does the watching, where it keeps them, and every surprise that follows from not knowing it is there.

That thing is the persistence context. In Spring it is one EntityManager, bound to one transaction, and it is the single most useful mental object in this whole course: almost every JPA question turns into an easy one once you ask what is in the persistence context right now, and is it still open?

Four states, and only one of them is watched

An entity is in exactly one of four states, and the transitions are what your code is actually doing:

  • Transient — you made it with new. Hibernate has never seen it. No id, no row.
  • Managed — it is in the persistence context. Changes to it will be written.
  • Detached — it was managed, and the context closed. It still has data and an id, and nothing is watching it any more.
  • Removed — scheduled for deletion at the next flush.
new Author(..)em.persist(a)a.setName(..)commitem.close()
statetransientin the context?nochanges tracked?no

new Author(..). An ordinary object. Hibernate does not know it exists, and nothing you do to it matters yet. This is the only state in which you can change an id safely.

1 / 5

Dirty checking: the UPDATE you did not write

Read this code and look for the save:

java
em.getTransaction().begin();
Author a = em.createQuery("select a from Author a where a.name = 'Bloch'", Author.class)
             .getSingleResult();
a.setName("Joshua Bloch");
em.getTransaction().commit();
plaintext
### about to rename, with no save() call anywhere
### committing
Hibernate: update Author set name=? where id=?

There is no save(), no update(), no merge(). The context kept a copy of the row as it was loaded, compared it at flush, found one field different, and wrote it.

This is the feature people find magical and then find frightening, and both reactions are right. It is why @Transactional(readOnly = true) is worth writing on read paths: it tells Hibernate not to keep those snapshots, which saves memory and makes an accidental write impossible.

Flush is not commit

Flush is sending the SQL. Commit is making it permanent. They usually happen together at the end, and the times they do not are worth knowing:

  • Before a query that overlaps your pending changes, so the query sees them.
  • When you call em.flush() yourself.
  • At commit.

The consequence that catches people: a constraint violation surfaces at flush, not at the line that caused it. You set a field on line 20 and get the exception at line 60, where the transaction ends. This is why a stack trace from a flush is nearly useless for locating the cause and why em.flush() during debugging is a legitimate tool — it moves the error to the line you suspect.

The first-level cache caches objects, not queries

Every managed entity sits in a map keyed by its id, and that map is the first-level cache. It is always on and there is no way to turn it off.

plaintext
### find(1) the first time
Hibernate: select author0_.id, author0_.name from Author author0_ where author0_.id=?
### find(1) again, same entity manager
### same object? true
### now a JPQL query for the same row
Hibernate: select author0_.id, author0_.name from Author author0_ where author0_.id=1
### same object as before? true

Two things happened there and only one of them is the thing people remember.

find() twice produced one query. The second call never reached the database.

But the JPQL query did reach the database — the SQL is right there — and still returned the same object. That is the rule worth carrying: a query always runs; the cache decides which object you get back, not whether the SQL is sent. Hibernate ran the select, saw that row 1 was already managed, threw the freshly loaded values away and handed you the instance you already had.

Which has a sharp edge. If another transaction changed that row, your query brought the new values back from the database and then discarded them, because a managed instance always wins. Inside one transaction you see a consistent picture, which is usually what you want and is occasionally not what you expected.

Lazy loading, and the exception that is a design signal

A lazy relationship is a proxy: a generated subclass that holds an id and fetches on first use. That works while the context is open. Afterwards:

java
Author detached = em.createQuery(...).getSingleResult();
em.close();
detached.getBooks().size();
plaintext
### entity manager closed; now touching books
### org.hibernate.LazyInitializationException
### failed to lazily initialize a collection of role: demo.Author.books,
    could not initialize proxy - no Session

The usual reaction is to make the relationship eager, or to enable open-in-view so the context stays open until the response is rendered. Both make the symptom stop and both are the wrong move.

open-in-view is on by default in Spring Boot, and what it does is hold a database connection for the whole request — including the time spent serialising JSON — and let your view layer trigger queries. Queries then come from template rendering, where no one is looking for them, and the connection pool becomes the bottleneck under load.

The exception is telling you something true: this code is reading data that the transaction did not decide to load. The fix is to decide — fetch it in the query, or map to a DTO inside the transaction and let the detached object be a value rather than a live handle.

The N+1 problem, seen rather than described

Here is the shape, in a log. Four authors, two books each:

plaintext
### the query you wrote
Hibernate: select author0_.id, author0_.name from Author author0_
### now touching each author's books
Hibernate: select ... from Book books0_ where books0_.author_id=?
Hibernate: select ... from Book books0_ where books0_.author_id=?
Hibernate: select ... from Book books0_ where books0_.author_id=?
Hibernate: select ... from Book books0_ where books0_.author_id=?
### total books: 8

One query you wrote, four you did not, identical except for the parameter. That is the 1 and the N.

With four authors it is invisible. With five hundred it is five hundred and one round trips, each one a network hop, and the page takes eleven seconds for reasons the code does not show — because nowhere in the code is there a loop over queries. There is a loop over objects, and the queries are a consequence.

The four ways out, and what each one costs

1. A fetch join. Say it in the query:

java
select distinct a from Author a join fetch a.books
plaintext
Hibernate: select distinct ... from Author author0_ inner join Book books1_ on author0_.id=books1_.author_id
### books counted: 8 (authors: 4)

One query. And now the trap, which is why this section exists at all. Add an author with no books and run the same query:

plaintext
### authors in the table: [Bloch, Newcomer]
### join fetch returns:   [Bloch]
### LEFT join fetch:      [Bloch, Newcomer]

join fetch is an inner join. The author with no books silently disappears from your results — no error, no warning, a correctness bug introduced by a performance fix. Write left join fetch unless you specifically mean to require children.

2. An entity graph. The same fetch, declared outside the query, so a repository method can stay a derived query:

java
EntityGraph<Author> g = em.createEntityGraph(Author.class);
g.addAttributeNodes("books");
plaintext
Hibernate: select distinct ... from Author author0_ left outer join Book books1_ on ...

Note that Hibernate chose a left outer join here — so this one does not drop the childless author. In Spring Data it is an annotation on the repository method, @EntityGraph(attributePaths = "books"), which is the usual way you will meet it.

3. Batch fetching. @BatchSize(size = 25) on the collection turns N queries into N/25 queries using an in (?, ?, ?, ...). It does not eliminate the extra round trips, it makes them logarithmically fewer — which is the right answer when you genuinely need the children of many parents and a join would multiply rows badly.

4. Do not load entities at all. If the endpoint returns a summary, query a projection or a DTO directly. The fastest way to avoid the cost of loading an object graph is not to build one.

Progress is saved on this device and to your account when signed in.