Skip to main content
the lies your orm tells you

Version Columns, Timestamps, and Conflict Granularity

5 min read Chapter 26 of 30

Version Columns, Timestamps, and Conflict Granularity

@Version supports int, long, Integer, Long, short, Short, and Timestamp. The choice affects conflict detection behavior and introduces subtle failure modes.

The Lie

Version type does not matter. Pick one. They all detect conflicts the same way.

The Reality

Integer/Long versions: Incremented by 1 on each UPDATE. Deterministic. If you load version 5 and the database has version 6, you have a conflict. The arithmetic is exact.

Timestamp versions: Set to the current JVM time on each UPDATE. Non-deterministic. Two updates within the same millisecond can produce the same timestamp, causing a silent lost update. On machines with coarse system clocks (some VMs report time in 10-15ms increments), the window for silent conflicts is wider.

// BAD: Timestamp version with millisecond resolution
@Entity
public class Document {
    @Id
    private Long id;

    @Version
    private Timestamp lastModified;  // java.sql.Timestamp

    private String content;
}

// Two requests update the same document within the same millisecond:
//
// Request A loads: version = 2024-01-15 10:00:00.123
// Request B loads: version = 2024-01-15 10:00:00.123
//
// Request A updates: SET lastModified = 2024-01-15 10:00:00.456
//   WHERE id = 1 AND lastModified = 2024-01-15 10:00:00.123
//   → 1 row updated. Success.
//
// Request B updates: SET lastModified = 2024-01-15 10:00:00.456
//   WHERE id = 1 AND lastModified = 2024-01-15 10:00:00.123
//   → 0 rows updated. Conflict detected.
//
// This works IF the two updates are not in the same millisecond.
// If they are: Request B's WHERE clause matches (same timestamp),
// and Request B overwrites Request A's changes silently.

Use integer versions. They are deterministic and unambiguous.

The Evidence: False Positive Conflicts

A more insidious problem with @Version is false positive conflicts. The version applies to the entire row. If two users update different fields of the same entity concurrently, the second update conflicts even though the fields do not overlap.

@Entity
public class UserProfile {
    @Id
    private Long id;

    @Version
    private Long version;

    private String displayName;
    private String bio;
    private String avatarUrl;
    private String timezone;
}

// User A changes their displayName. User B changes their timezone.
// Both load version 3.
//
// User A: UPDATE user_profiles SET display_name = ?, version = 4
//           WHERE id = 1 AND version = 3
//         → Success.
//
// User B: UPDATE user_profiles SET timezone = ?, version = 4
//           WHERE id = 1 AND version = 3
//         → 0 rows. OptimisticLockException.
//         → User B's timezone change is rejected even though
//           it does not conflict with User A's display name change.

This is a false positive. The changes are to independent fields. The version mechanism cannot distinguish between conflicting and non-conflicting changes because it operates at the row level.

The Fix

1. Use @DynamicUpdate for Column-Level Conflict Detection

// BETTER: Only include changed columns in the UPDATE
@Entity
@DynamicUpdate
@OptimisticLocking(type = OptimisticLockType.DIRTY)
public class UserProfile {
    @Id
    private Long id;

    // No @Version column needed with DIRTY strategy

    private String displayName;
    private String bio;
    private String avatarUrl;
    private String timezone;
}

// User A changes displayName:
// UPDATE user_profiles SET display_name = ?
//   WHERE id = 1 AND display_name = 'OldName'
//
// User B changes timezone:
// UPDATE user_profiles SET timezone = ?
//   WHERE id = 1 AND timezone = 'UTC'
//
// Both succeed. No false positive conflict.
// The WHERE clause checks only the columns that were loaded,
// detecting conflicts only when the same column was changed.

OptimisticLockType.DIRTY adds the original values of changed columns to the WHERE clause instead of using a version column. Two updates to different columns do not conflict.

The trade-off: the WHERE clause includes column values, which can be less efficient than a simple version check (larger WHERE clause, less index-friendly). For entities with TEXT or BLOB columns, including the original value in the WHERE clause is expensive.

2. Split Entities by Update Domain

If different parts of a row are updated by different use cases, separate them:

// BETTER: Separate entities for separate update domains
@Entity
@Table(name = "user_profiles")
public class UserIdentity {
    @Id
    private Long id;

    @Version
    private Long version;

    private String displayName;
    private String bio;
}

@Entity
@Table(name = "user_profiles")
public class UserPreferences {
    @Id
    private Long id;

    @Version
    private Long version;

    private String avatarUrl;
    private String timezone;
}

Two entities mapped to the same table, each with its own version column. Identity changes do not conflict with preference changes. This is a DDD-style approach: separate aggregates for separate consistency boundaries.

The trade-off: you need two version columns in the table, and loading a “full profile” requires joining or loading both entities.

The Cost Model

StrategyFalse Positive RateWHERE Clause SizeIndex Friendliness
@Version (integer)High (row-level)Small (id + version)Excellent
@Version (timestamp)High + silent loss riskSmall (id + timestamp)Good
DIRTY lockingNone (column-level)Large (id + all dirty columns)Poor for large values
Split entitiesNone (per-aggregate)Small (id + version per entity)Excellent

For entities that are updated by one use case at a time, @Version with integer is the right choice. For entities where independent fields are updated by different use cases concurrently, @DynamicUpdate with OptimisticLockType.DIRTY or entity splitting eliminates false positives. The “right” strategy depends on your update patterns, not on a universal rule.