Load an entity, call a setter, end the transaction, and the row in the database changes. No save(), no update(). The mechanism behind that automatic UPDATE is dirty checking. The core of it is how Hibernate knows which fields changed and what it compares against.

The snapshot

The baseline for the comparison is the snapshot. The moment an entity enters the persistence context, Hibernate copies all of its field values and stores them separately. That copy is the initial state that matches the database. Internally it’s keyed by the entity identifier, with an Object array holding each field value in order as the value.

A snapshot is created at two points: when an entity is loaded from the database via find() or JPQL, and when a new entity is made persistent with persist(). merge() behaves differently. It copies the detached entity’s values into a managed entity, creates a snapshot for that managed instance, and returns the managed instance. The original detached object stays detached.

When flush runs, Hibernate walks every entity in the persistence context and compares the current value against the snapshot field by field. The comparison does not use Java’s equals(). It uses Hibernate’s own type-specific logic: == for primitives, a null check followed by equals() for object types. If anything differs, the entity gets a dirty flag and an UPDATE is queued in the write-behind SQL store.

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

User user = em.find(User.class, 1L); // snapshot stored: {id=1, name="Alice", email="[email protected]"}
user.setName("Bob");                 // memory only; nothing in the DB yet

em.getTransaction().commit();        // commit triggers flush → compare vs snapshot → UPDATE

Just before commit, flush compares the current {name="Bob"} with the snapshot {name="Alice"}, sees that name changed, and builds the UPDATE. No SQL was written by hand.

What flush does

Flush does not empty the persistence context. It does two things: find changed entities through dirty checking, and send the INSERT/UPDATE/DELETE statements collected in the write-behind store to the database. The entities stay managed, and the real commit happens when the transaction ends.

Flush fires automatically just before transaction commit, just before a JPQL or Criteria query executes, and on a direct em.flush() call. The pre-query flush is needed because pending changes must hit the database first for the query to see them.

User user = em.find(User.class, 1L);
user.setName("Changed");

// auto-flush right before this JPQL runs → the change above lands in the DB first
List<User> users = em.createQuery("SELECT u FROM User u", User.class).getResultList();
// so the user in the result reflects "Changed"

The default FlushModeType is AUTO. Set it to COMMIT and flush only happens at commit time; the pre-JPQL auto-flush is skipped.

Changing one field updates every column

Change only name on a ten-column entity and Hibernate still emits this:

update users set
    name=?, email=?, status=?, age=?, created_at=?,
    updated_at=?, address=?, phone=?, bio=?, role=?
where id=?

Unchanged columns like email and status are all in the SET clause. It’s deliberate. Because the UPDATE shape is always identical, the application builds the PreparedStatement once and caches it, and the database reuses the execution plan for that exact query instead of re-parsing every time.

@DynamicUpdate

When a full-column UPDATE is a problem, Hibernate’s @DynamicUpdate compares against the snapshot on each flush and builds an UPDATE containing only the changed columns.

@Entity
@DynamicUpdate
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @Lob
    private String content; // large column

    private LocalDateTime updatedAt;
}

Change only the title and the SQL shrinks:

update article set title=?, updated_at=? where id=?

The large content column drops out of the SET clause. There’s a cost. The query string changes each time, so PreparedStatement caching is lost, and detecting changed columns plus building SQL dynamically adds runtime overhead. @DynamicUpdate is for entities with dozens of columns where only a few change often, for TEXT/BLOB columns that make full-row writes expensive, or for reducing column-level lock contention in the database. It is not a default to put on every entity.

It works only in the managed state

Dirty checking works only on entities in the managed state inside the persistence context. Miss this and you get the bug where a setter call leaves the database unchanged.

User user = em.find(User.class, 1L); // managed
em.detach(user);                     // now detached

user.setName("Changed"); // Dirty Checking does nothing → not written to the DB

User merged = em.merge(user); // returns a new managed entity
merged.setName("Changed again"); // change detection works from here

An entity becomes detached when detach() is called, when clear() resets the context, or when the EntityManager is closed. A transient entity created with new but never persist()-ed has no link to the context either. To make a detached entity managed again, use merge(), and note that the returned object is the managed one.

Bulk updates

To modify ten thousand rows, dirty checking is the wrong tool. It builds an individual UPDATE per entity, so ten thousand UPDATEs go out. The more entities pile up in the context, the more snapshot comparison and memory cost at flush time.

Use a JPQL bulk operation instead. A single UPDATE statement modifies every matching row at once.

@Modifying
@Query("UPDATE User u SET u.status = :status WHERE u.lastLoginAt < :date")
int bulkUpdateStatus(@Param("status") UserStatus status, @Param("date") LocalDateTime date);
int count = userRepository.bulkUpdateStatus(UserStatus.INACTIVE, LocalDateTime.now().minusYears(1));
em.clear(); // a bulk op bypasses the context, so clear it

A bulk operation hits the database directly, bypassing the persistence context. After it runs, the entities still in the context are out of sync with the database, so you must clear() the context or re-query the entities you need.

To keep working with individual entities while cutting round-trips, use JDBC batching. Set hibernate.jdbc.batch_size and turn on hibernate.order_updates to group identical UPDATEs into a single network round-trip. With @Version optimistic locking, you also need hibernate.jdbc.batch_versioned_data=true for batching to work correctly.