Home / Spring Boot / Data and Transactions / Hibernate Internals: Persistence Context, Dirty Checking and Flush

Hibernate Internals: Persistence Context, Dirty Checking and Flush

Most JPA bugs come from not understanding the persistence context. This page explains entity states, the first-level cache, when SQL is really executed, and how Hibernate detects changes, so that transactions, lazy loading and batch processing stop being magic.

Entity states

            new Product()                          em.find() / query
                 │                                        │
            ┌────▼─────┐   persist()/save()          ┌────▼─────┐   remove()   ┌──────────┐
            │ TRANSIENT│ ───────────────────────────►│ MANAGED  │ ────────────►│ REMOVED  │
            │ (no id,  │                             │ (tracked │              │ (delete  │
            │ unknown) │◄──── garbage collected      │ by the   │◄─ persist ───│ at flush)│
            └──────────┘                             │ context) │              └──────────┘
                                                     └────┬─────┘
                                       detach(), clear(), │  ▲ merge()
                                       close, tx ends     ▼  │
                                                     ┌──────────┐
                                                     │ DETACHED │  has id, but changes are NOT tracked
                                                     └──────────┘

Persistence context = first-level cache

Inside a transaction, the EntityManager keeps a map of all loaded entities (key = entity type and id). If you ask for the same id again, Hibernate returns the same Java object without a new SQL query. It also keeps a snapshot of each entity as it was loaded, for dirty checking.

@Service
public class DemoService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void demo() {
        Product a = em.find(Product.class, 1L);      // SQL: select ... where id=1
        Product b = em.find(Product.class, 1L);      // NO SQL, from the persistence context
        System.out.println(a == b);                  // true (same object)

        a.setName("New name");                       // just change the object
        // no save() call. At commit Hibernate compares a with its snapshot,
        // sees the difference and runs: update products set name=? where id=?
    }
}

When is SQL executed? Flush

persist() or setters do not run SQL immediately. Hibernate collects changes in an action queue and sends them to the database at flush time. With FlushMode AUTO (default), a flush happens: (1) before a query that could be affected by pending changes, (2) before the transaction commits, and (3) when you call flush() yourself.

@Transactional method
   em.persist(order)          ─► queued INSERT (may run at once for IDENTITY ids)
   order.setStatus("PAID")    ─► change detected later
   em.createQuery("select o from Order o ...")   ─► AUTO FLUSH first, so the query sees the changes
   ...
   method returns ─► flush ─► COMMIT

Flush order inside Hibernate: inserts, updates, collection changes, deletes.
The database only makes changes visible to other transactions after COMMIT.

Dirty checking in detail

persist vs merge vs save

MethodBehavior
persist(entity)Makes a NEW entity managed. The same object becomes managed. Fails for detached entities
merge(entity)Copies the state of a detached (or new) object into a managed instance and RETURNS the managed instance. The passed object stays detached
save() of Spring DataCalls persist if the entity is new (id null or version null), otherwise merge. Always use the returned object
saveAndFlush()save + flush immediately

ID generation and batching

StrategyHowBatch inserts?
IDENTITYDatabase auto increment. The INSERT must run immediately to get the idNo. JDBC batching is disabled for inserts
SEQUENCEHibernate takes ids from a database sequence, in blocks (allocationSize, default 50)Yes. Recommended for PostgreSQL and Oracle
UUIDGenerated in the applicationYes, but random UUID keys hurt index locality
@Entity
public class Invoice {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "invoice_seq")
    @SequenceGenerator(name = "invoice_seq", sequenceName = "invoice_seq", allocationSize = 50)
    private Long id;
}
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
@Transactional
public void importInvoices(List<InvoiceDto> dtos) {
    int i = 0;
    for (InvoiceDto dto : dtos) {
        em.persist(new Invoice(dto));
        if (++i % 50 == 0) {
            em.flush();        // send the batch
            em.clear();        // detach everything so memory does not grow
        }
    }
}

Lazy loading and proxies

Second-level cache (optional)

The first-level cache lives for one transaction. A second-level cache is shared across sessions and needs a provider (Ehcache, Caffeine through JCache, Infinispan). It helps for read-mostly reference data such as countries or currencies. It adds complexity (invalidation, clusters), so use it only after measuring.

@Entity
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
@Cacheable
public class Country {
    @Id private String code;
    private String name;
}
// spring.jpa.properties.hibernate.cache.use_second_level_cache=true
// spring.jpa.properties.hibernate.cache.region.factory_class=jcache

Common mistake: Loading millions of rows in one transaction and calling save() in a loop. The persistence context keeps every entity (memory grows) and dirty checking becomes slower each time. Process data in chunks and call flush() and clear(), or use bulk queries and JDBC batching.

Interview questions

Why did my entity change get saved without calling save()?

The entity is managed inside the transaction. Hibernate detected the change by dirty checking and issued an UPDATE at flush time.

Why is my JDBC batch insert not batching?

Typical reasons: GenerationType.IDENTITY, hibernate.jdbc.batch_size not set, or entities with different types not ordered (set order_inserts=true).

What is the N+1 problem in terms of the persistence context?

Each lazy access on an unloaded association runs a separate SELECT because the data is not in the first-level cache yet. Loading it together with a fetch join or entity graph fixes it.