skip to content

Versionless Optimistic Locking

Optimistic concurrency for legacy schemas that cannot grow a version column — comparing all or dirty columns in the UPDATE's WHERE clause instead. A senior-level differentiator question about Hibernate-specific capabilities beyond the JPA spec.

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

questions

4

A legacy table has no version or timestamp column and you are not allowed to alter it. How can Hibernate still detect that another transaction changed a row between your read and your update?

level: middleimportance: must knowfreq 32%

answer

  1. no version column → compare old column values in WHERE
  2. @OptimisticLocking(type = ALL | DIRTY), Hibernate-only
  3. values come from the loaded-state snapshot
  4. 0 rows updated → StaleObjectStateException / OptimisticLockException
  5. pair with @DynamicUpdate; nulls need null-safe comparison

basics

~20 s

Annotate the entity with Hibernate's @OptimisticLocking(type = OptimisticLockType.ALL or DIRTY). Hibernate then puts the originally loaded column values into the UPDATE's WHERE clause; if another transaction changed the row, zero rows match and Hibernate raises a stale-state failure.

solid answer

~50 s

Hibernate supports versionless optimistic locking through `@OptimisticLocking(type = ...)` from `org.hibernate.annotations`. Instead of comparing a version number, it widens the `UPDATE`'s `WHERE` clause to include the column values as they were when the entity was loaded: ```sql update product set price = ? where id = ? and price = ? and name = ? ``` Hibernate always checks the row count returned by the `UPDATE`. If someone changed the row in between, the old values no longer match, zero rows are affected, and Hibernate throws a stale-state failure (`StaleObjectStateException`, surfaced as `OptimisticLockException` through the JPA API) — the same failure path as a version-based mismatch. The comparison values come from the **loaded state snapshot** Hibernate keeps in the persistence context for dirty checking, so this only works while the entity has been managed continuously since it was read. `OptimisticLockType.ALL` compares every column; `DIRTY` compares only the ones you changed. Pair it with `@DynamicUpdate`.

code

java · 9 lines
java
@Entity
@DynamicUpdate
@OptimisticLocking(type = OptimisticLockType.ALL)
public class Product {
    @Id private Long id;
    private String name;
    private BigDecimal price;
    private int stock;
}

go deeper

for a junior

Say Hibernate can put the previously read column values into the UPDATE's WHERE clause, so a concurrent change makes the update match no rows.

for a middle

Name @OptimisticLocking with ALL and DIRTY, the zero-row-count detection, the resulting exception, and the @DynamicUpdate pairing.

for a senior

Explain that the comparison values come from the loaded-state snapshot, so protection ends at detachment, and flag null-safe comparison and non-comparable column types.

for a principal

Position it as a compatibility mechanism for schemas shared with writers you do not control, and say plainly that a version column is the better answer whenever the schema is yours.

## The problem Lost update: two transactions read the same row, both modify it, both write. The second write silently overwrites the first, and nobody is told. The standard defence is a version column that is checked and incremented on every write. Legacy schemas frequently do not have one, and adding a column may be off the table — other applications, ETL jobs or stored procedures write the same table and would not maintain it. ## The mechanism Hibernate can use the row's own data as the version. Mark the entity with `@OptimisticLocking(type = OptimisticLockType.ALL)` (or `DIRTY`) and Hibernate builds the `UPDATE` so that its `WHERE` clause reproduces the values the row had when you read it: ```sql update product set price = ? where id = ? and price = ? and name = ? and stock = ? ``` The detection is the same machinery version-based locking uses: Hibernate inspects the affected-row count returned by JDBC. Expecting one and getting zero means the row either no longer matches its loaded values or no longer exists, so Hibernate throws `StaleObjectStateException`, which the JPA layer surfaces as `OptimisticLockException`. Your options are the usual ones: fail the request, or reload and merge with a retry. ## Where the comparison values come from This is the part candidates miss. Hibernate keeps a **loaded state snapshot** — an array of the column values as they came back from the database — for every managed entity, because that is how automatic dirty checking works at flush time. Versionless locking reuses that snapshot for the `WHERE` clause. Two consequences follow: 1. It only protects a read-modify-write that happens **inside one persistence context**. Once an entity is detached and later reattached with `merge`, Hibernate re-reads the row and the snapshot becomes the *current* database state — any concurrent change made in between is absorbed silently. A version column does not have this hole, because the version travels with the detached object. 2. Nothing is written to the schema. Nothing marks the row as "changed by Hibernate", so other writers of the table need no cooperation at all. That is precisely why this exists. ## The two types - `OptimisticLockType.ALL` — every mapped column goes into the `WHERE`. Any concurrent change to the row is detected. - `OptimisticLockType.DIRTY` — only the columns this transaction modified. Concurrent edits to columns you did not touch are allowed to stand. `OptimisticLockType.VERSION` (the default when a version attribute exists) and `NONE` (no checking) complete the enum. ## Practical notes - `@OptimisticLocking` is a **Hibernate** annotation, not part of JPA — it does not port to another provider. - Pair it with `@DynamicUpdate` so the statement is generated per flush rather than taken from the pre-generated static form. - Nullable columns force a null-safe comparison, so Hibernate emits something like `(col = ? or (col is null and ? is null))`, which is verbose and can defeat index usage. - Some column types compare badly: large text and binary columns are expensive or illegal to put in a `WHERE` clause on several engines, floating point values may not round-trip exactly, and timestamps can lose precision through the driver. Excluding a column from the check with `@OptimisticLock(excluded = true)` is the escape hatch, at the cost of a blind spot. ## How to present it Call it what it is: a compatibility mechanism for schemas you do not control. If you can add a version column, add one — it is cheaper, indexable, portable, and it survives detachment. Versionless locking is what you reach for when the schema is fixed and shared, and you accept a narrower guarantee in exchange.

  • How does Hibernate actually notice the conflict?
    By the affected-row count from the UPDATE. Hibernate expects exactly one row; because the WHERE clause reproduces the values read earlier, a concurrent change makes it match nothing, so the count is zero. Hibernate then throws StaleObjectStateException, surfaced as OptimisticLockException through the JPA API — the identical detection path used for a version mismatch.
  • Where does Hibernate get the old values it puts in the WHERE clause?
    From the loaded-state snapshot it already keeps for each managed entity to perform automatic dirty checking at flush time. That is also the limitation: the snapshot exists only while the entity stays managed in the persistence context in which it was read, so a detached-and-merged entity loses the original values and with them the protection.

saying these in an interview costs you the question

  • Thinking Hibernate adds a hidden version column or writes extra metadata to the table.
  • Believing it protects a detached entity that is later merged — the snapshot is re-read on merge.
  • Assuming @OptimisticLocking is standard JPA and portable to other providers.
  • Expecting a database error on conflict rather than a zero-row update turned into an exception by Hibernate.
  • Forgetting nullable columns need null-safe comparison, and that big text or binary columns may be unusable in the check.

context

open as a page

Why is Hibernate's @DynamicUpdate part of the recipe for column-comparison optimistic locking, and what does Hibernate do with UPDATE statements by default without it?

level: middleimportance: should knowfreq 22%

basics

~20 s

By default Hibernate pre-generates one static UPDATE per entity at startup, setting every column. Column-comparison locking needs the statement built per flush — mandatory for DIRTY, whose compared columns vary — and @DynamicUpdate switches Hibernate to that per-flush generation.

open as a page

Hibernate's @OptimisticLocking annotation accepts OptimisticLockType.ALL and OptimisticLockType.DIRTY. How do the generated UPDATE statements differ, and what concurrency anomaly does DIRTY still allow?

level: seniorimportance: should knowfreq 24%

basics

~20 s

ALL puts every mapped column's loaded value in the UPDATE's WHERE clause; DIRTY puts only the columns this transaction modified. DIRTY therefore lets two transactions edit disjoint columns of the same row concurrently — convenient, but it cannot protect invariants that span columns.

open as a page

You must protect a legacy table against lost updates and can either add a dedicated version column or use Hibernate's column-comparison optimistic locking on the existing columns. How do you decide, and what does column comparison fail to protect?

level: principalimportance: should knowfreq 20%

basics

~20 s

Add the version column if you can: it is cheap, indexable, portable and survives detachment. Column comparison is a compatibility fallback for schemas you do not control. Its main gap is detached entities — merge re-reads the row, so conflicts during the detached window go undetected.

open as a page