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
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; } } |