skip to content

Optimistic Locking with @Version

The default answer to lost updates: a version column checked and incremented on every flush, failing fast with OptimisticLockException. Interviewers always follow with recovery — retry loops, user-facing conflict handling — not just the mechanism.

part ofHibernateoverview, primer and where to startread it →
on this pageshow

questions

5

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.

level: middleimportance: must knowfreq 70%

answer

  1. update ... set version=old+1 where id=? and version=old
  2. Check + increment in ONE statement
  3. JDBC row count 0 -> StaleObjectStateException
  4. Surfaces at flush, usually at commit
  5. Bulk JPQL bypasses it unless 'update versioned'

basics

~20 s

Hibernate 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 s

The 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
java
@Entity
public class Product {
    @Id @GeneratedValue Long id;
    private String name;
    private BigDecimal price;
    @Version private long version;
}

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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.

context

open as a page

A concurrent update makes a flush fail with jakarta.persistence.OptimisticLockException. Describe a retry strategy that is actually correct — what state you must discard, what you must re-read, and when retrying is the wrong answer.

level: seniorimportance: must knowfreq 55%

basics

~20 s

Roll back and discard the persistence context — it holds a snapshot now known to be wrong. Retry the whole transactional unit: open a fresh context, re-read the row, re-apply the business intent (not the stale field values), flush again. Bound the attempts, add jitter, and only retry work that is safe to redo.

open as a page

JPA's @Version annotation can be placed on an integer-typed field or on a timestamp-typed field. What are the practical differences, and which would you choose for a table written by several application instances?

level: middleimportance: should knowfreq 35%

basics

~20 s

A numeric version is a pure counter: monotonic, database-agnostic, no clock involved, and collisions are impossible. A timestamp version depends on clock resolution and, when generated in the JVM, on clock agreement between instances; column precision can round two updates to the same value. Prefer numeric; use timestamp only when the column must double as a human-readable last-modified.

open as a page

A user opens an edit form on a record, spends several minutes changing it, and submits. How do you use a JPA @Version field to detect that somebody else changed that row in the meantime, and what common implementation mistake silently defeats the check?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Send the loaded version out with the form, get it back on submit, put it on the detached entity, and merge. Hibernate compares the supplied version with the database row and rejects a stale one. The classic mistake is re-reading the entity server-side and copying only the form fields onto it — the managed copy carries a fresh version, so the check always passes.

open as a page

You are deciding where to put JPA @Version fields across a domain model. What does versioning every entity cost, and how would you decide which ones actually need it?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

A version column makes conflict detection row-shaped: any two writers to the same row conflict, even on unrelated fields. Version entities with genuinely concurrent writers and lost-update risk. For hot counters and aggregates, change the model — atomic SQL updates or split rows — rather than adding a version and retrying.

open as a page