Optimistic Concurrency — rowversion, Timestamp and Application Patterns

Optimistic concurrency assumes conflicts are rare — it allows concurrent reads without locking, but detects conflicts at write time. SQL Server’s rowversion column automatically increments when a row is modified. EF Core uses it as a concurrency token: when updating, it includes WHERE RowVersion = @originalValue. If another session modified the row since it was read, the RowVersion will have changed and zero rows update — EF Core throws DbUpdateConcurrencyException, which the application handles.

Optimistic Concurrency with rowversion

-- ── Add rowversion to Posts ────────────────────────────────────────────────
ALTER TABLE dbo.Posts ADD RowVersion ROWVERSION NOT NULL;
-- ROWVERSION: 8-byte binary, auto-updated on every modification
-- Equivalent name: TIMESTAMP (deprecated but still works)
-- Value is unique within the database (monotonically increasing)

-- ── The optimistic update pattern ────────────────────────────────────────
DECLARE @OriginalRowVersion BINARY(8) = 0x0000000000000215;  -- client sends this back

UPDATE dbo.Posts
SET    Title     = N'Updated Title',
       Body      = N'Updated content...',
       UpdatedAt = SYSUTCDATETIME()
WHERE  Id        = 42
  AND  RowVersion = @OriginalRowVersion;   -- concurrency check

-- If @@ROWCOUNT = 0: another session modified the row — conflict detected
-- If @@ROWCOUNT = 1: update succeeded

IF @@ROWCOUNT = 0
    RAISERROR('Concurrency conflict: post was modified by another user.', 16, 1);
// ── EF Core concurrency token setup ──────────────────────────────────────
public class Post
{
    public int    Id         { get; set; }
    public string Title      { get; set; } = "";
    // rowversion column — auto-managed by SQL Server
    [Timestamp]
    public byte[] RowVersion { get; set; } = [];
}

// In DbContext (or Fluent API):
modelBuilder.Entity<Post>()
    .Property(p => p.RowVersion)
    .IsRowVersion();   // tells EF Core to include this in WHERE clause on UPDATE

// ── Service method with conflict handling ──────────────────────────────────
public async Task<UpdateResult> UpdatePostAsync(
    UpdatePostRequest request, byte[] originalRowVersion, CancellationToken ct)
{
    var post = await _db.Posts.FindAsync([request.Id], ct);
    if (post is null) return UpdateResult.NotFound;

    // Check that client has the latest version
    if (!post.RowVersion.SequenceEqual(originalRowVersion))
        return UpdateResult.Conflict;

    post.Title     = request.Title;
    post.Body      = request.Body;
    post.UpdatedAt = DateTime.UtcNow;
    // EF Core generates: UPDATE Posts SET ... WHERE Id=@id AND RowVersion=@original

    try
    {
        await _db.SaveChangesAsync(ct);
        return UpdateResult.Success;
    }
    catch (DbUpdateConcurrencyException)
    {
        // Another user saved between our read and our write
        return UpdateResult.Conflict;
    }
}

// ── REST API — return RowVersion as ETag ──────────────────────────────────
// GET /api/posts/42
// Response: { "id": 42, "title": "...", "rowVersion": "AAAAAAAAIFI=" }
// Response Header: ETag: "AAAAAAAAIFI="

// PUT /api/posts/42
// Request Header: If-Match: "AAAAAAAAIFI="
// If RowVersion doesn't match: 412 Precondition Failed
// If RowVersion matches and update succeeds: 200 OK with new RowVersion
Note: rowversion (formerly called timestamp) is a database-wide monotonically increasing counter — every modification to any row in the entire database increments the counter. The value stored in a specific row represents the last time that row was modified. It is not a date/time — it is an 8-byte binary counter. When serialising to JSON for the API, convert to Base64: Convert.ToBase64String(post.RowVersion). The client includes it in subsequent update requests, and the server converts back with Convert.FromBase64String(etag).
Tip: Map the rowversion value to an HTTP ETag response header and require it as an If-Match request header for PUT/PATCH operations. This implements standard HTTP conditional requests — clients cannot update a resource without first reading its current ETag. If the ETag doesn’t match (another client updated the resource), return 412 Precondition Failed. This pattern exposes optimistic concurrency to API consumers in a protocol-standard way.
Warning: When a DbUpdateConcurrencyException is thrown, the EF Core change tracker’s cached values are stale — the entity’s RowVersion in the tracker still has the old value. Before retrying, call _db.Entry(post).Reload() to refresh the entity from the database, or create a new DbContext instance. Retrying with the stale cached entity will fail again because EF Core will use the same old RowVersion in the WHERE clause.

Common Mistakes

Mistake 1 — Not handling DbUpdateConcurrencyException (user sees 500 error on conflict)

❌ Wrong — concurrent update causes DbUpdateConcurrencyException; unhandled exception returns 500.

✅ Correct — catch DbUpdateConcurrencyException; return 409 Conflict with a meaningful message to the client.

Mistake 2 — Retrying after conflict without reloading the entity (retry fails again)

❌ Wrong — catch exception, retry SaveChanges immediately; EF Core still has stale RowVersion; fails again.

✅ Correct — call entry.Reload() after the exception to get fresh data, then re-apply changes and retry.

🧠 Test Yourself

User A reads a post with RowVersion = 0x215. User B updates the same post (RowVersion becomes 0x216). User A submits an update with RowVersion = 0x215. What SQL does EF Core generate and what happens?