You call EntityManager.merge with an entity whose @Version field is older than the value now stored in the row. Walk through what Hibernate does internally: where the two versions are compared, at which point the failure surfaces, and what happens instead if that version field is null or the row was deleted meanwhile.
answer
- merge SELECTs first, then compares versions
- Older detached version → throws inside merge, no UPDATE emitted
- Versions equal → zero-row versioned UPDATE fails at flush
- Null version → looks transient → INSERT
- Deleted row → merge resurrects it
basics
~20 smerge fetches the current row, compares your carried version with it, and fails immediately with an optimistic-lock error if yours is older. If versions match, the flush still issues a version-qualified UPDATE that fails on a zero-row result. A null version makes the object look new, so Hibernate tries an INSERT; a deleted row is re-created rather than reported.
solid answer
~50 s`merge` first materialises the current database state: it looks for the managed instance by identifier, and SELECTs the row if it is not already in the persistence context. It then compares the version attribute of your detached copy against that freshly-read state. An older detached version means you edited a snapshot someone has since superseded, and Hibernate raises `StaleObjectStateException`, surfaced through JPA as `OptimisticLockException`, **at merge time** — before any UPDATE is sent. If the versions agree, state is copied onto the managed instance and the conflict window shifts to the flush: the generated statement is `UPDATE ... SET version = ? WHERE id = ? AND version = ?`, and a zero-row result raises `StaleStateException`/`OptimisticLockException` at flush or commit. Two traps: a **null (or zero) version** makes Hibernate treat the object as transient, so merge attempts to persist a new row instead of guarding an old one; and if the row was **deleted** meanwhile, merge finds nothing and creates a fresh managed instance, quietly resurrecting deleted data.
code
java · 7 linesdetached.setVersion(3L); // DB row is already at version 5
try {
Order managed = em.merge(detached); // throws here: stale snapshot
tx.commit(); // ...or here, if a commit lands in between
} catch (OptimisticLockException e) {
// reload, show the conflict, let the user re-apply
}go deeper
Know that merge reads the row first and that a stale version leads to an optimistic-lock error instead of an overwrite.
Distinguish the merge-time comparison from the flush-time zero-row check, and show the version-qualified UPDATE.
Cover the null-version and deleted-row traps, cascade amplification, and a sane conflict-handling policy for user-facing edits.
Decide organisation-wide how offline conflicts are represented and resolved — version transport format, retry versus user-mediated merge, soft deletion so vanished rows conflict instead of resurrect — and whether merge belongs in the write path at all.
## Why merge needs the database before it can decide anything A detached entity carries an identifier and a set of values, and — crucially — no history. Hibernate cannot know whether those values are current, so `merge` always resolves the identifier against real state first: 1. If the persistence context already holds a managed instance for that id, that is the target. 2. Otherwise Hibernate issues a SELECT (or hits the second-level cache) to load one. 3. Only then can it compare and copy. This is why merge on a large graph generates a burst of SELECTs, and why merge is more expensive than persisting a new entity. ## Where the version comparison happens With a version attribute mapped, Hibernate's merge event listener compares the version of the detached instance with the version of the state it just read. The rule is intuitive: your detached copy must not be **older** than what is in the database. If it is, you edited a superseded snapshot, and continuing would silently overwrite whatever produced the newer version. Hibernate throws `StaleObjectStateException` (a subtype of `StaleStateException`), which the JPA layer translates to `OptimisticLockException`. Notably this happens **during the merge call itself**, not at flush — useful to know when reasoning about where the try/catch belongs and why no UPDATE appears in the SQL log. If the versions agree, the detached state is copied onto the managed instance and merge returns it. The instance is now dirty; at flush, dirty checking generates the version-qualified UPDATE with `version = version + 1` in the SET list and the previously-read version in the WHERE clause. Any transaction that commits in the interval between your SELECT and your flush makes that statement affect zero rows, and Hibernate—which checks the JDBC update count precisely for this reason—throws again. So there are two distinct failure points: a snapshot-age check at merge, and a row-still-untouched check at flush. ## The null-version trap JPA decides between INSERT and UPDATE for a merge argument using the identifier and, when present, the version. If a mapping layer builds the entity from a request body and leaves the version at `null` (or a primitive `0` where the mapping treats zero as unsaved), Hibernate may classify the object as **transient** and attempt to persist it as a new row. Outcomes vary: with a generated identifier you get an unexpected duplicate row; with an assigned identifier you get a constraint violation on the primary key. Either way the protective check you thought you had never ran. Always map the version explicitly in both directions, and use an object type (`Long`, `Integer`) so “absent” is representable and detectable. ## The deleted-row trap When the identifier no longer resolves — the row was deleted while your copy was detached — merge does not fail. Per JPA semantics it creates a new managed instance from your state and schedules an INSERT. Your detached copy resurrects a deleted entity, complete with the old identifier if identifiers are assigned. Two mitigations are common: check existence explicitly before merging when deletion is a real workflow (“the record you were editing was removed” is a user-visible outcome, not a database detail), or use soft deletion so the row remains and the version check applies normally. Some teams simply do not use merge for user-driven edits at all, preferring load-and-apply, which naturally fails with a not-found result when the row is gone. ## Where cascading multiplies the problem With `CascadeType.MERGE`, everything above happens for each reachable associated entity: each is resolved against the database, each version is compared, each copy is applied. A single merge on an aggregate root can therefore throw an optimistic-lock failure for a child you never touched, and can resurrect a deleted child. When diagnosing “why did this merge throw / why are these SELECTs here”, always look at the cascade configuration first. ## Practical guidance to state in an interview - Know both failure points — merge-time snapshot-age check, flush-time zero-row check — and that both surface as `OptimisticLockException`. - Treat `OptimisticLockException` as a **retryable, user-visible** outcome: reload, show what changed, let the user re-apply, or apply an automatic merge policy per field. - Never let the version be reconstructed or defaulted by a mapper; it is a token that must be transported verbatim. - Remember that merge always reads first, so it is not a cheap way to save changes; if you already have the managed entity, mutate it and let dirty checking work.
- How should the application react to an OptimisticLockException from a user-driven edit?Treat it as a business outcome, not an infrastructure error: roll back, reload the current row, and tell the user what changed so they can re-apply or confirm an overwrite. Blind retry loops are appropriate only for idempotent, machine-driven writes where re-reading and recomputing is safe; for human edits, silently retrying reintroduces the lost update you were preventing.
- Why can a merge on one entity throw an optimistic-lock failure for an object you never edited?CascadeType.MERGE walks the association graph, resolving and version-checking each reachable entity. If a child carried a stale version because it came back from the client along with the parent, the failure is reported for that child. Narrow the cascade, or merge only the fields the endpoint owns.
saying these in an interview costs you the question
- Thinking the version check only ever happens at flush
- Assuming merge on a deleted row throws — it re-inserts
- Using a primitive long version and treating 0 as a valid persisted value
- Catching OptimisticLockException and retrying the same stale object in a loop
- Believing merge does not need to read the database first