skip to content

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%

answer

  1. version column: portable, indexable, survives detachment
  2. column comparison: no schema change, no cooperation needed
  3. merge reloads the snapshot → detached window unprotected
  4. LOB/float/timestamp columns compare badly; excluded = blind spot
  5. DIRTY additionally drops cross-column invariants

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.

solid answer

~60 s

Prefer the version column. It is one small integer, compared and incremented on every write, portable JPA, and — crucially — the value travels with a detached object, so a client that reads, edits offline and submits later is still protected. Choose column comparison when the schema is genuinely fixed: other applications, ETL jobs or stored procedures write the table and would never maintain a version column, or DDL is not yours to issue. Then Hibernate compares the loaded values in the `UPDATE`'s `WHERE` clause instead, and no other writer needs to cooperate. What you give up: - **Detached protection.** Old values come from the persistence-context snapshot; `merge` reloads the row, so a change made during the detached window is absorbed silently. - **Comparable columns.** Large text or binary, floating point and imprecise timestamps compare badly; nullable columns force verbose null-safe predicates that can defeat indexes. - **A visible version.** No value to hand to clients as an ETag or to bump deliberately. Under `DIRTY`, add cross-column invariants to that list.

code

java · 11 lines
java
@Entity
@DynamicUpdate
@OptimisticLocking(type = OptimisticLockType.ALL)
public class Document {
    @Id private Long id;
    private String title;

    @Lob
    @OptimisticLock(excluded = true) // deliberate blind spot: not compared
    private byte[] content;
}

go deeper

for a junior

Say a version column is the normal answer and column comparison is the fallback when you cannot change the schema.

for a middle

Contrast the two mechanically — one narrow indexed predicate versus a full-row comparison — and note that both fail the same way, on a zero-row update.

for a senior

Lead with the detached-entity gap, then the non-comparable column types and the exclusion blind spot, and state where the constraint really comes from: who else writes the table.

for a principal

Make it an ownership decision — who writes this table and where do edits live — and note when to abandon optimistic strategies entirely for a pessimistic lock or a narrower entity design.

## Frame the decision, not the annotation Both strategies detect the same thing — that the row moved between your read and your write — and both signal it the same way: the `UPDATE` affects zero rows, and Hibernate converts that into a stale-state failure. The difference is *what carries the evidence*, and that drives everything else. ## Why the dedicated column usually wins A version column is one small integer or timestamp, compared and incremented on every write. Its properties are hard to beat: - **It survives detachment.** The value lives in the entity, so it can go out to a browser in a form, come back hours later, and still be checked. Column comparison cannot do this, and for request-per-edit web applications that is often the *only* concurrency window that matters. - **Cheap and index-friendly predicates.** `where id = ? and version = ?` is two equality tests on a narrow tuple. Full-row comparison drags every column into the predicate, including nullable ones that need `(col = ? or (col is null and ? is null))`. - **Portable.** Version-based locking is standard JPA; `@OptimisticLocking` is Hibernate-specific. - **A first-class value.** You can expose it as an HTTP ETag, log it, or deliberately bump it to invalidate other readers. So the real question is not "which is better" but "can I add the column". Adding one to a legacy table is usually a nullable-column migration plus a backfill, and Hibernate's own writes maintain it thereafter. ## When you genuinely cannot Two situations force the fallback. First, **shared ownership**: stored procedures, ETL jobs or other applications write the table and will never increment your column, so rows would be modified without the version moving — a version column that is not maintained is worse than none, because it looks like protection. Second, **no DDL authority**: a vendor schema, a replicated table, or an organisational boundary. Column comparison is designed exactly for this. It writes nothing extra, requires no cooperation, and can be switched on per entity. ## The gaps, in order of how often they bite **Detached windows.** The comparison values come from the loaded-state snapshot Hibernate keeps for dirty checking. That snapshot exists only while the entity stays managed in the persistence context that loaded it. `merge` on a detached instance re-reads the row, so the snapshot becomes the *current* state, and any concurrent change during the detached window disappears without a trace. If your architecture edits detached objects, this strategy does not cover your actual risk — you would need to carry the old values yourself and compare them explicitly, which is reimplementing a version column badly. **Columns that do not compare well.** Large text and binary columns are expensive or outright illegal in a `WHERE` clause on several engines; floating point values may not round-trip identically through the driver; timestamps can lose sub-second precision. `@OptimisticLock(excluded = true)` removes such a column from the check, at the price of a deliberate blind spot — concurrent changes to it become invisible. **Predicate cost.** Wide `WHERE` clauses with null-safe branches are bigger statements and can make the optimiser's job harder. Usually the primary-key lookup still dominates, but on very wide rows measure rather than assume. **No externally visible version.** Nothing to hand a client as a concurrency token, and no equivalent of deliberately bumping a parent to signal that an aggregate changed. **Under DIRTY, cross-column invariants.** Comparing only modified columns lets two transactions edit disjoint fields and both commit, which breaks any rule that relates fields to each other. ## How I would decide If the table is mine: add the version column, full stop. If it is shared but I control all *application* writes and the external writers only read: still add the column. If external writers modify rows: use `ALL` column comparison, exclude only columns that cannot be compared, keep read-modify-write inside a single persistence context so the snapshot is meaningful, and be explicit in the design notes that detached edits are unprotected. If contention on independent fields turns out to be a real problem, consider `DIRTY` — but first ask whether the entity is doing too many jobs and should be split into narrower entities with their own lifecycles. And where correctness truly cannot tolerate a retry loop, neither strategy is the answer: take a pessimistic lock so the conflict cannot arise at all.

  • Why is the detached-entity case the decisive argument for a version column?
    Because column comparison relies on the loaded-state snapshot held in the persistence context, and merge re-reads the row before applying your changes, so the snapshot becomes the current database state. A change made while the entity was detached leaves no evidence and is overwritten silently. A version value travels with the detached object, so the same check works across a request boundary or a long user think-time.
  • A colleague wants to add a version column to a table that a nightly ETL job also writes. What is the risk?
    The ETL job will not increment the version, so it can modify rows while the version stays put. Application transactions that read before the job ran will pass their version check and overwrite the job's changes — protection that looks present but is not, which is worse than knowingly having none. Either make the external writer maintain the column (a trigger can), or use column comparison, which needs no cooperation.
  • When is neither optimistic strategy the right answer?
    When conflicts are frequent or a retry is unacceptable — high-contention counters, seat or inventory allocation, anything where losing the race means user-visible failure late in a flow. There, take a pessimistic lock so writers serialise up front instead of discovering the conflict at commit.

saying these in an interview costs you the question

  • Treating column comparison as an equal substitute for a version column rather than a compatibility fallback.
  • Assuming versionless locking protects a detached entity that is later merged.
  • Adding a version column to a table that external writers modify without maintaining it.
  • Excluding awkward columns from the comparison without acknowledging the resulting blind spot.
  • Choosing DIRTY to reduce conflict noise without checking for invariants that span columns.

context