Skip to content
QSWEQB
mediumConceptScenarioMidSeniorStaff

Nobody called save, but the UPDATE still went to the database. Explain how that happened.

A loaded entity is managed by the persistence context, which kept a snapshot of it. At flush the provider compares the entity against that snapshot and writes an UPDATE for whatever differs, so mutating a managed object inside a transaction is itself the instruction to persist.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate name the four lifecycle states and place the entity in one of them before explaining anything
  • Whether dirty checking is described as a comparison against a load-time snapshot rather than as change interception
  • That the flush points are enumerated, including the query-triggered one, instead of only commit
  • Can they explain what merge returns and why mutating its argument afterwards achieves nothing
  • Whether the risk of an unintended write on a read path is recognised, and readOnly named as the guard

Answer

Four states, and the one you are in

Every entity instance is in exactly one of four states, and almost every confusing JPA behaviour resolves the moment you say which. A transient instance is one you constructed with new; the provider has never seen it and knows nothing about it. A managed instance is attached to an open persistence context, which is tracking it. A detached instance has an identity and once was managed, but its context has closed. A removed instance is scheduled for deletion at the next flush.

Anything a repository finder returns is managed. That is the answer to the question: repository.findById(id) did not hand you a data-transfer object, it enlisted the row in the current unit of work. The persistence context holds it in an identity map keyed by entity type and primary key — which is also why loading the same row twice in one transaction returns the same object reference, and why == works between them where it would not across transactions.

Dirty checking is a snapshot comparison

When an entity becomes managed, Hibernate takes a copy of its loaded state — the hydrated values of every mapped property. It does not intercept your setters and it does not require you to declare an intention. At flush time it walks the managed entities, compares each one field by field against its snapshot, and emits an UPDATE for each entity where something differs. There is no save in this story because there is nothing for save to do.

import jakarta.persistence.*;

@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;

    private String status;

    @Version
    private long version;        // incremented on every flush that writes this row

    public void setStatus(String status) { this.status = status; }
    public String getStatus() { return status; }
}
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void cancel(long id) {
        Order order = repository.findById(id).orElseThrow();
        order.setStatus("CANCELLED");
        // No save call. The entity is managed, so the snapshot no longer matches
        // and commit flushes: update order set status=?, version=? where id=? and version=?
    }
}

Note that the annotations are jakarta.persistence. Spring Boot 3 moved from javax.persistence to jakarta.persistence with Jakarta EE 9, and getting that package wrong in a code sample is the kind of small tell an interviewer registers.

The mirror image of this is the risk. An accidental mutation anywhere inside a transactional read path is a real UPDATE in production — a defaulting setter called during mapping, a @PostLoad that normalises a string, a getter with a side effect. @Transactional(readOnly = true) is the guard, and it works on two levels worth distinguishing: Hibernate sets the flush mode to manual so automatic flushing stops happening, and Spring also marks the JDBC connection read-only, so on a database that honours that hint an accidental write fails rather than succeeding quietly.

When a flush actually happens

Three occasions, under the default FlushModeType.AUTO. At transaction commit, always. Before a JPQL, Criteria or Spring Data query whose results could be affected by pending changes, so that you never read stale data you yourself wrote. And whenever you call flush() yourself.

The second one is the interesting one, and there are two edges to know. A native SQL query is not analysed by the provider, so pending changes are not necessarily flushed before it runs, and a native query can read a row your uncommitted work has already changed in memory. And find() by primary key does not trigger a flush at all, because it is served from the identity map or fetched directly.

Flushing is also not the same as committing. A flush pushes SQL to the database inside the open transaction; nothing is visible to other sessions and nothing is durable until commit. Candidates who use the two words interchangeably tend to be asked next what happens if the transaction rolls back after a flush, and the answer — everything the flush wrote is undone, but your in-memory entities still hold the new values — is a good check on whether the distinction was real.

Statement order is the provider's, not yours

At flush, Hibernate does not execute the operations in the order you performed them. It groups them by type and executes inserts first, then updates, then collection operations, and deletes last. This lets it batch statements of the same shape, and it produces one memorable failure: deleting a row and inserting a replacement with the same unique key in one transaction fails on the constraint, because the insert is attempted while the old row is still there.

@Transactional
public void replaceTag(Order order, Tag oldTag, Tag newTag) {
    order.getTags().remove(oldTag);
    entityManager.flush();          // force the delete out before the insert
    order.getTags().add(newTag);    // same unique key, now free
}

An explicit flush() between the two halves is the ordinary fix. Reaching for it is fine; not knowing why you needed it is not.

Detached entities, and what merge gives back

merge copies the state of a detached instance onto a managed one — loading the row if the context does not already hold it — and returns that managed instance. Your argument stays detached. This is the single most common misuse: code calls entityManager.merge(order) and then continues to modify order, and none of those later changes are ever written, because the object being dirty-checked is the copy the method returned. Assign the result and work with it.

persist on a detached instance is a different contract and throws, because persist means "this is new". The distinction matters for the pattern where a request body is deserialised straight into an entity and merged: every field the client omitted is now null on the detached object, and merge will faithfully write those nulls over good data. Load the entity and apply the fields you accept.

@Version is what makes detached state safe rather than merely convenient. The version column is included in the WHERE clause of every update, so an update whose row has moved on affects zero rows and the provider raises an optimistic-lock failure instead of overwriting somebody's edit. For that to work, the version has to travel out to the client and come back, which is a design decision about your API and not something the ORM can do on your behalf.

There is no save step in JPA. The transaction is the unit of work, being managed is the subscription, and the SQL is a consequence of the difference between an entity and the snapshot taken when it was loaded.

Likely follow-ups

  • Why does deleting a row and inserting one with the same unique key in a single transaction fail?
  • What exactly does @Transactional(readOnly = true) change, in Hibernate and in JDBC?
  • Why does calling persist on a detached entity throw, when merge does not?
  • How does @Version turn a stale detached entity into a failure rather than a lost update?

Related questions

Further reading

jpahibernatepersistence-contextdirty-checkingoptimistic-locking