An entity has a field annotated with JPA's @Version. Walk through exactly what SQL Hibernate emits when it flushes a change to that entity, and how it concludes that another transaction modified the row first.
answer
- update ... set version=old+1 where id=? and version=old
- Check + increment in ONE statement
- JDBC row count 0 -> StaleObjectStateException
- Surfaces at flush, usually at commit
- Bulk JPQL bypasses it unless 'update versioned'
basics
~20 sHibernate emits UPDATE t SET cols=?, version = old+1 WHERE id = ? AND version = old. It then reads the JDBC affected-row count. One row means it won; zero rows means someone else already changed the version, so Hibernate raises StaleObjectStateException, surfaced to JPA code as OptimisticLockException.
solid answer
~50 sThe version column is loaded with the entity and kept in the persistence context. At flush, dirty checking finds the entity changed and Hibernate builds a **versioned update**: ```sql update product set name=?, price=?, version=? where id=? and version=? ``` with the new version (old + 1) in the SET and the **loaded** version in the WHERE. The check and the increment are the same statement, so it is atomic at the database level — no separate read is needed and no extra lock is taken. Hibernate then inspects the JDBC update count. `1` means nothing changed the row since the load, and Hibernate bumps the in-memory version. `0` means the row was updated (or deleted) by someone else, so the predicate matched nothing: Hibernate throws `StaleObjectStateException`, which the JPA layer reports as `jakarta.persistence.OptimisticLockException` and the transaction is marked rollback-only. Because it happens at flush — often at commit — the failure surfaces after the business logic finished, which is what makes retry design non-trivial.
code
java · 7 lines@Entity
public class Product {
@Id @GeneratedValue Long id;
private String name;
private BigDecimal price;
@Version private long version;
}go deeper
Recall the shape of the statement — update with the version in both SET and WHERE — and that zero updated rows means somebody else got there first.
Explain dirty checking, the single-statement check-and-increment, the row-count inspection, and that the failure appears at flush/commit rather than at the setter.
Add the operational edges: batching and driver row counts, bulk updates bypassing the version, inverse-side changes not bumping it, and the rollback-only, discard-the-context consequence.
Position it as the cheapest possible concurrency control — no lock held across think time, cost paid only by the loser — and reason about when that trade stops paying on hot rows.
## Setup ```java @Entity class Product { @Id Long id; String name; BigDecimal price; @Version long version; // or int, short, java.sql.Timestamp, Instant } ``` When Hibernate loads a `Product`, it selects the version column along with everything else and stores the value both on the entity and in the entity's **snapshot** inside the persistence context. Application code must never assign the version itself. ## What happens at flush 1. **Dirty checking.** At flush time Hibernate compares each managed entity's current field values with its load-time snapshot. If nothing differs, no statement is emitted and the version does not move — reading an entity never bumps the version. 2. **Statement generation.** For a dirty entity with a version, Hibernate produces a *versioned* update: ```sql update product set name = ?, price = ?, version = ? -- new version = loaded + 1 where id = ? and version = ? -- loaded version ``` The check (`and version = ?`) and the increment (`set version = ?`) are in a single statement, so the database's own row-level atomicity does the work. No `select ... for update`, no extra round trip, no lock held for the duration of the user's thinking time — that is the whole appeal of the optimistic approach. 3. **Row-count inspection.** Hibernate reads the affected-row count returned by JDBC: - `1` — success. Hibernate increments the in-memory version field so the entity matches the row. - `0` — the WHERE matched nothing. Either another transaction updated the row (its version moved) or deleted it. Hibernate cannot distinguish those from the count alone; it throws `org.hibernate.StaleObjectStateException` carrying the entity name and identifier. Through the JPA API you see `jakarta.persistence.OptimisticLockException` (or `OptimisticLockingFailureException` in frameworks that translate exceptions). 4. **Transaction state.** Once that fires, the transaction is doomed: it is marked rollback-only, and the persistence context must be discarded — it holds a snapshot Hibernate now knows is wrong. Deletes work the same way: `delete from product where id=? and version=?`. ## When does the flush happen? With the default flush mode, before queries that overlap the pending changes and always at commit. In practice the exception typically surfaces **at commit**, after your method returned normally. That is why `try/catch` inside the service method usually catches nothing, and why retry logic has to wrap the whole transactional unit. ## Batching caveat With JDBC batching enabled, Hibernate uses `executeBatch()` and inspects the per-statement counts the driver returns. Most drivers report them correctly, but some return `Statement.SUCCESS_NO_INFO`, in which case the mismatch cannot be detected for that batch. Hibernate accounts for this by checking counts where the driver supplies them; if you rely on optimistic locking for critical rows, verify your driver reports usable counts rather than assuming batching is transparent. ## What does and does not bump the version - Changing any mapped basic property or the owning side of an association: **yes**. - Modifying an **owned collection** (`@OneToMany` with the collection owning the join table, `@ElementCollection`): Hibernate treats the owner as dirty and bumps its version by default. - Modifying the **inverse** side (`mappedBy`) only: **no** — the change lives on the other row's foreign key, so the parent's version does not move. If you need the parent invalidated for aggregate consistency, you must force the increment explicitly. - **Bulk JPQL/HQL updates** (`update Product p set p.price = ...`) bypass the persistence context entirely and do **not** touch the version — unless you write Hibernate's `update versioned Product ...`, or set the version column yourself. This is a classic way to silently defeat optimistic locking with a batch job. - Native SQL updates likewise bypass everything. ## Why the row is not locked Nothing is locked between load and update: two transactions may both read version 7, both compute, and both attempt the update. Whichever commits first sets version 8; the second one's `where version = 7` matches nothing, so it loses. This is *first commit wins*. The cost of losing is a wasted transaction, which is the trade you accept in exchange for zero lock holding time. ## Interpreting the failure `StaleObjectStateException` does not mean corruption, deadlock, or a database error. It means: your change was computed against a version of the row that no longer exists, so it was refused rather than allowed to overwrite someone else's work. Handling it means deciding whether to redo the work against fresh data or to report a conflict to a human.
- Where in the transaction is the exception thrown, and why does that matter?At flush, which with the default flush mode most often means at commit — after the service method has returned normally. So a try/catch inside the method typically never sees it, and any side effects the method already performed (emails, external calls) have happened even though the transaction will roll back. Retry and compensation logic must therefore wrap the entire transactional unit.
- Which modifications do not increment the version?Pure reads, changes to the inverse (mappedBy) side of an association, and bulk JPQL or native updates, which bypass the persistence context. Hibernate's HQL `update versioned ...` form increments the version column for bulk statements. Modifying an owned collection does mark the owner dirty and bumps its version by default.
- Does optimistic locking take any database lock while the user is editing?No. Between load and update nothing is held; only the brief row lock of the UPDATE statement itself exists. Concurrency is detected by the version predicate matching zero rows, which is why the model is called optimistic and why the loser pays with a wasted transaction rather than with waiting.
Like editing a shared document by saying "replace revision 7 with revision 8" — if the file is already on revision 8 the instruction simply doesn't apply, and nothing is overwritten.
saying these in an interview costs you the question
- Describing it as a SELECT of the version followed by a separate UPDATE — that would be a race; the check is in the UPDATE's WHERE clause.
- Believing the version increments on read, or that reading with a version field locks the row.
- Assuming a bulk JPQL update maintains the version column.
- Thinking the exception can always be caught inside the service method, ignoring that flush usually happens at commit.
- Claiming zero affected rows definitely means a concurrent update — the row may also have been deleted.