Optimistic Locking in EF Core C# with Concurrency Tokens

Database Concurrency November 15, 2026 csharp

Reject stale EF Core writes with a GUID concurrency token and DbUpdateConcurrencyException. Covers client versions, conflict handling, deletion, and rowversion.

Overview

Optimistic concurrency detects whether a row changed between reading it and saving an edit. EF Core adds the original concurrency-token value to the UPDATE or DELETE predicate.

The example uses an application-managed GUID token so the pattern can be demonstrated on PostgreSQL without pretending that SQL Server rowversion is a portable database feature.

How it works

Configure Version with IsConcurrencyToken and ValueGeneratedNever. Return the version to the client when reading stock and require it on the next command.

The service uses a fresh tracked context, rejects a token already known to be stale, checks stock, assigns a new GUID, and calls SaveChangesAsync.

Another writer may still commit between the in-memory check and SaveChanges. EF includes the originally read Version in its SQL predicate, so that race results in zero affected rows and DbUpdateConcurrencyException rather than a lost update.

The service wraps that database exception in a domain conflict. If the row is deleted after it was read, the same concurrency mechanism detects the missing write target.

Contention, performance, and trade-offs

No row lock is held during a user's editing period. The database still takes normal locks while executing UPDATE; optimistic concurrency is not a lock-free write protocol.

Low-conflict editing is a good fit. If a command is safe to re-evaluate, use a bounded retry with a fresh context and freshly read business state. Do not retry stale field assignments indefinitely.

Do not merely overwrite OriginalValues with current database values and save stale CurrentValues. That bypasses detection without deciding which edit should win. Merge, reject, or re-run the domain action deliberately.

Database support, correctness, and error handling

Runnable sample: net10.0, EF Core 10.x, and Npgsql/PostgreSQL. quantity must be positive and expectedVersion must be a nonempty GUID. The stock check constraint remains a final database safeguard.

All relevant writers must assign a new Version when changing the protected state. ExecuteUpdate, raw SQL, bulk imports, and external writers do not automatically follow the tracked SaveChanges concurrency protocol.

A stale submitted token is rejected before the edit. A race at SaveChanges is detected by the database predicate. Keep the client's token rather than silently replacing it with a freshly loaded one.

SQL Server can instead map a byte-array property with IsRowVersion for a database-generated rowversion token. rowversion is a change token, not a wall-clock timestamp, and its automatic behavior is SQL Server-specific.

For an HTTP API, return a conflict response with refresh guidance, commonly 409; a failed If-Match precondition is 412. Do not leak database exception details to the client.

Framework-specific details

Each attempt owns and disposes its context. After an unsuccessful save, do not accidentally reuse tracked modified entities for a later unrelated operation.

The example explicitly changes Version. IsConcurrencyToken marks the comparison property but does not generate a new application-managed token for you. A SaveChanges interceptor can centralize token generation in a larger application.

Setup, usage, and tests

Create a net10.0 project and install Npgsql.EntityFrameworkCore.PostgreSQL 10.x. Copy the implementation below, register a DbContext factory, and generate/apply EF migrations for the model.

Read a stock row and return its Version with the response. Usage: nextVersion = await reservations.ReserveAsync(1, 1, originalVersion, ct). The successful response returns the replacement token.

The test project forces two independent contexts to reach SaveChanges with the same original token. Exactly one succeeds, and the loser carries a real DbUpdateConcurrencyException. Tests also cover an already stale client token, deleted rows, insufficient stock, invalid input, cancellation, and the database constraint.

Download the complete examples, dependency lock file, and integration tests: https://www.dimkoug.com/static/downloads/locking-examples-tested.zip

Official documentation

EF Core concurrency handling: https://learn.microsoft.com/en-us/ef/core/saving/concurrency

EF Core ExecuteUpdate concurrency behavior: https://learn.microsoft.com/en-us/ef/core/saving/execute-insert-update-delete#concurrency-control-and-rows-affected

Npgsql concurrency tokens: https://www.npgsql.org/efcore/modeling/concurrency.html

Complete C# code example

csharp
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// Optimistic Locking in EF Core C# with Concurrency Tokens
//
// PURPOSE
// Optimistic concurrency detects whether a row changed between reading it and saving an edit. EF
// Core adds the original concurrency-token value to the UPDATE or DELETE predicate.
//
// The example uses an application-managed GUID token so the pattern can be demonstrated on
// PostgreSQL without pretending that SQL Server rowversion is a portable database feature.
//
// METHOD
// Configure Version with IsConcurrencyToken and ValueGeneratedNever. Return the version to the
// client when reading stock and require it on the next command.
//
// The service uses a fresh tracked context, rejects a token already known to be stale, checks
// stock, assigns a new GUID, and calls SaveChangesAsync.
//
// Another writer may still commit between the in-memory check and SaveChanges. EF includes the
// originally read Version in its SQL predicate, so that race results in zero affected rows and
// DbUpdateConcurrencyException rather than a lost update.
//
// The service wraps that database exception in a domain conflict. If the row is deleted after it
// was read, the same concurrency mechanism detects the missing write target.
//
// COMPLEXITY
// No row lock is held during a user's editing period. The database still takes normal locks
// while executing UPDATE; optimistic concurrency is not a lock-free write protocol.
//
// Low-conflict editing is a good fit. If a command is safe to re-evaluate, use a bounded retry
// with a fresh context and freshly read business state. Do not retry stale field assignments
// indefinitely.
//
// Do not merely overwrite OriginalValues with current database values and save stale
// CurrentValues. That bypasses detection without deciding which edit should win. Merge, reject,
// or re-run the domain action deliberately.
//
// CONTRACT
// Runnable sample: net10.0, EF Core 10.x, and Npgsql/PostgreSQL. quantity must be positive and
// expectedVersion must be a nonempty GUID. The stock check constraint remains a final database
// safeguard.
//
// All relevant writers must assign a new Version when changing the protected state.
// ExecuteUpdate, raw SQL, bulk imports, and external writers do not automatically follow the
// tracked SaveChanges concurrency protocol.
//
// A stale submitted token is rejected before the edit. A race at SaveChanges is detected by the
// database predicate. Keep the client's token rather than silently replacing it with a freshly
// loaded one.
//
// SQL Server can instead map a byte-array property with IsRowVersion for a database-generated
// rowversion token. rowversion is a change token, not a wall-clock timestamp, and its automatic
// behavior is SQL Server-specific.
//
// For an HTTP API, return a conflict response with refresh guidance, commonly 409; a failed
// If-Match precondition is 412. Do not leak database exception details to the client.
//
// LANGUAGE DETAILS
// Each attempt owns and disposes its context. After an unsuccessful save, do not accidentally
// reuse tracked modified entities for a later unrelated operation.
//
// The example explicitly changes Version. IsConcurrencyToken marks the comparison property but
// does not generate a new application-managed token for you. A SaveChanges interceptor can
// centralize token generation in a larger application.
//
// EXAMPLE
// Create a net10.0 project and install Npgsql.EntityFrameworkCore.PostgreSQL 10.x. Copy the
// implementation below, register a DbContext factory, and generate/apply EF migrations for the
// model.
//
// Read a stock row and return its Version with the response. Usage: nextVersion = await
// reservations.ReserveAsync(1, 1, originalVersion, ct). The successful response returns the
// replacement token.
//
// The test project forces two independent contexts to reach SaveChanges with the same original
// token. Exactly one succeeds, and the loser carries a real DbUpdateConcurrencyException. Tests
// also cover an already stale client token, deleted rows, insufficient stock, invalid input,
// cancellation, and the database constraint.
//
// Download the complete examples, dependency lock file, and integration tests:
// https://www.dimkoug.com/static/downloads/locking-examples-tested.zip
//
// SOURCES
// EF Core concurrency handling: https://learn.microsoft.com/en-us/ef/core/saving/concurrency
//
// EF Core ExecuteUpdate concurrency behavior:
// https://learn.microsoft.com/en-us/ef/core/saving/execute-insert-update-delete#concurrency-control-and-rows-affected
//
// Npgsql concurrency tokens: https://www.npgsql.org/efcore/modeling/concurrency.html
//
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace LockingPosts.Optimistic;

public sealed class Stock
{
    public int Id { get; set; }
    public int Available { get; set; }
    public Guid Version { get; set; } = Guid.NewGuid();
}

public sealed class InventoryDb : DbContext
{
    public InventoryDb(DbContextOptions<InventoryDb> options) : base(options) { }
    public DbSet<Stock> Stocks => Set<Stock>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var entity = modelBuilder.Entity<Stock>();
        entity.ToTable("LockingOptimisticStocks", table =>
            table.HasCheckConstraint("ck_opt_stock_nonnegative", "\"Available\" >= 0"));
        entity.Property(stock => stock.Version)
            .IsConcurrencyToken()
            .ValueGeneratedNever();
    }
}

public sealed class ReservationConflictException : Exception
{
    public ReservationConflictException(string message, Exception? inner = null)
        : base(message, inner) { }
}

public sealed class InsufficientStockException : InvalidOperationException
{
    public InsufficientStockException() : base("Not enough stock.") { }
}

public sealed class Reservations
{
    private readonly IDbContextFactory<InventoryDb> factory;
    public Reservations(IDbContextFactory<InventoryDb> factory) => this.factory = factory;

    public async Task<Guid> ReserveAsync(
        int id, int quantity, Guid expectedVersion, CancellationToken ct = default)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        if (expectedVersion == Guid.Empty)
            throw new ArgumentException("A client version is required.", nameof(expectedVersion));

        // A fresh context for each logical attempt; contexts are not thread-safe.
        await using var db = await factory.CreateDbContextAsync(ct);
        var stock = await db.Stocks.SingleOrDefaultAsync(item => item.Id == id, ct)
            ?? throw new KeyNotFoundException("SKU not found.");

        // Preserve the version supplied by the client; do not silently overwrite it.
        if (stock.Version != expectedVersion)
            throw new ReservationConflictException("The submitted version is stale.");
        if (stock.Available < quantity)
            throw new InsufficientStockException();

        stock.Available -= quantity;
        stock.Version = Guid.NewGuid();
        try
        {
            // EF compares the ORIGINAL Version in the UPDATE's WHERE clause.
            await db.SaveChangesAsync(ct);
        }
        catch (DbUpdateConcurrencyException error)
        {
            throw new ReservationConflictException(
                "Stock changed or was deleted. Refresh before trying again.", error);
        }
        return stock.Version;
    }
}