Optimistic Concurrency & Conflict Resolution
How EF Core's row version tokens detect concurrent updates β and what to do when DbUpdateConcurrencyException fires.
Optimistic concurrency in EF Core works by adding a rowversion (or any concurrency token) to UPDATE and DELETE WHERE clauses. If two users load the same row and both try to save changes, the first save succeeds and increments the rowversion. The second save's WHERE clause references the old rowversion β it matches 0 rows β so EF throws DbUpdateConcurrencyException. The exception gives you all three value sets needed to resolve the conflict: what the user originally loaded, what the user wants to save, and what's currently in the database.
In SQL Server, a rowversion (or timestamp) column is an 8-byte binary value auto-incremented by the database engine on every INSERT or UPDATE to that row. No application code needed to update it. EF maps [Timestamp] byte[] to rowversion. Each time the row changes, the value increments β and EF uses it as a collision detector.
When EF calls SaveChanges() on a tracked entity with a [Timestamp] property, it generates: UPDATE [Products] SET [Stock] = @p0 WHERE [Id] = @p1 AND [RowVersion] = @p2. If another user has updated the row between your load and save, the RowVersion in the DB will have changed. The WHERE clause matches 0 rows. EF sees rows affected = 0 and throws DbUpdateConcurrencyException.
The exception's Entries property contains IReadOnlyList<EntityEntry> β the entities whose save failed. Each entry has: OriginalValues (what you loaded), CurrentValues (what you tried to save), and GetDatabaseValues() which fires a SELECT to fetch what's currently in the DB. These three value sets are the inputs to any conflict resolution strategy.
After catching the exception, call entry.ReloadAsync() to refresh OriginalValues from the DB. This updates the rowversion to the current DB value. Then retry SaveChanges(). The next UPDATE's WHERE clause will match the current rowversion. Client's values overwrite whatever was in the DB. Do NOT retry without ReloadAsync β you'll loop on the same stale rowversion forever.
Call entry.GetDatabaseValuesAsync() to fetch current DB state. Then call entry.CurrentValues.SetValues(dbValues) to overwrite the client's pending changes with what the DB has. Call entry.OriginalValues.SetValues(dbValues) to update the rowversion. No retry needed β the entity is now consistent with the DB.
Compare OriginalValues (what client loaded), CurrentValues (what client wants to save), and DatabaseValues (what's in DB now). Fields where only one side changed can be safely merged. Fields where both the DB and client changed from the original are genuine conflicts that must be surfaced to the user β you cannot resolve them automatically without business logic.
Key Concepts
Data annotation that maps a byte[] property to a SQL Server rowversion column. EF automatically includes this column in UPDATE/DELETE WHERE clauses. The DB engine increments the value atomically on every write β no application code required. Equivalent to fluent API: .IsRowVersion().
Marks any property as a concurrency token. Unlike [Timestamp], EF does NOT auto-update it β your application must update the value before SaveChanges. Use for non-SQL Server databases or for user-visible version numbers (e.g., a Version int that increments in code).
Thrown by SaveChanges()/SaveChangesAsync() when a tracked entity's UPDATE or DELETE affected 0 rows. The Entries property contains the conflicting EntityEntry objects with OriginalValues, CurrentValues, and GetDatabaseValues(). Never swallow this exception silently.
Executes a SELECT statement to fetch the current state of the row from the database. Returns PropertyValues (or null if the row was deleted). This is the freshest view of the DB state and is the foundation of all three conflict resolution strategies.
The property values as they were when the entity was first loaded from the DB (or last successfully saved). These are what EF puts in the WHERE clause of UPDATE statements. After a concurrency conflict, these must be refreshed (via ReloadAsync or SetValues) before retrying.
Optimistic concurrency assumes conflicts are rare β no locks held while user edits. Conflicts detected at save time. Pessimistic concurrency holds a DB lock (BEGIN TRAN, UPDLOCK hint) for the duration of the edit. Pessimistic is safe but causes deadlocks at scale. Optimistic is preferred for web workloads.
1// EF Core 9 β Optimistic Concurrency with rowversion23// Entity with concurrency token4public class Product5{6 public int Id { get; set; }7 public string Name { get; set; } = "";8 public int Stock { get; set; }9 public decimal Price { get; set; }1011 [Timestamp] // maps to rowversion in SQL Server12 public byte[] RowVersion { get; set; } = [];13 // Alternative for non-SQL Server: [ConcurrencyToken] on any property14}1516// SQL Server: ALTER TABLE [Products] ADD [RowVersion] rowversion NOT NULL17// rowversion auto-increments on every UPDATE β no application code needed1819// ------------------------------------------------------------------20// The WHERE clause EF generates on UPDATE/DELETE:21// UPDATE [Products] SET [Stock] = @p022// WHERE [Id] = @p1 AND [RowVersion] = @p2 β concurrency check23// If 0 rows affected β someone else updated first β exception2425// ------------------------------------------------------------------26// Handling DbUpdateConcurrencyException β three strategies:2728// Strategy 1: Client Wins (overwrite DB with current user's values)29public async Task UpdateStockClientWinsAsync(int productId, int newStock, CancellationToken ct)30{31 var maxRetries = 3;32 for (var attempt = 0; attempt < maxRetries; attempt++)33 {34 try35 {36 var product = await _dbContext.Products.FindAsync([productId], ct);37 if (product is null) throw new NotFoundException(productId);3839 product.Stock = newStock;40 await _dbContext.SaveChangesAsync(ct);41 return;42 }43 catch (DbUpdateConcurrencyException ex)44 {45 if (attempt == maxRetries - 1) throw; // exhaust retries4647 var entry = ex.Entries.Single();48 // Refresh original values from database so next SaveChanges49 // sends the CURRENT rowversion in the WHERE clause50 await entry.ReloadAsync(ct); // β CRITICAL: refresh OriginalValues51 }52 }53}5455// Strategy 2: Database Wins (discard client changes, keep DB values)56catch (DbUpdateConcurrencyException ex)57{58 var entry = ex.Entries.Single();59 var dbValues = await entry.GetDatabaseValuesAsync(ct);6061 if (dbValues is null)62 throw new InvalidOperationException("The entity was deleted by another user.");6364 // Overwrite current values with what's in the DB65 entry.OriginalValues.SetValues(dbValues);66 entry.CurrentValues.SetValues(dbValues);67 // Don't retry β database already has the "winning" state68}6970// Strategy 3: Merge (apply non-conflicting fields, surface conflicts)71catch (DbUpdateConcurrencyException ex)72{73 var entry = ex.Entries.Single();74 var dbValues = await entry.GetDatabaseValuesAsync(ct);75 var clientValues = entry.CurrentValues;76 var originalValues = entry.OriginalValues;7778 var conflicts = new List<string>();7980 foreach (var property in entry.Properties)81 {82 var dbValue = dbValues![property.Metadata.Name];83 var clientValue = clientValues[property.Metadata.Name];84 var origValue = originalValues[property.Metadata.Name];8586 if (!Equals(dbValue, origValue) && !Equals(clientValue, origValue))87 {88 // Both client and DB changed this field from the original89 conflicts.Add($"{property.Metadata.Name}: DB={dbValue}, You={clientValue}");90 }91 }9293 if (conflicts.Count > 0)94 throw new ConcurrencyConflictException(conflicts); // surface to user9596 // Non-conflicting fields: take client values, refresh rowversion97 entry.OriginalValues.SetValues(dbValues!);98 await _dbContext.SaveChangesAsync(ct);99}
Optimistic concurrency lets your application scale without holding database locks across user interactions. But it's only safe if you handle DbUpdateConcurrencyException correctly. A catch block that logs and swallows the exception is silently discarding user data. A retry without ReloadAsync loops forever on a stale rowversion. Either way, users lose data without knowing it. The exception is not a failure β it's a signal that requires a deliberate, domain-aware response.
Common Pitfalls
1DbUpdateConcurrencyException During Flash Sale β 200 Users, One Product
We ran a flash sale: a limited-edition product with 50 units. Announced on social media, 3,000 concurrent users hit the buy button simultaneously. Within the first 200ms, our exception logging showed 847 DbUpdateConcurrencyException per second. The inventory service was handling ~5% of purchases β 95% were retrying or failing with a user-visible error.
Our conflict resolution was: catch DbUpdateConcurrencyException, log it, and return HTTP 409 to the client to 'try again'. No automatic retry in the service. Users hit F5, generating more concurrent attempts. We hadn't thought through the retry strategy. The product sold out in 8 seconds but the exception storm lasted 4 minutes as clients kept retrying stale states.
Added a 3-attempt retry loop with entry.ReloadAsync() on each DbUpdateConcurrencyException. Added a stock check after reload: if stock == 0 after reload, return 'sold out' immediately (no retry). Added Polly retry policy at the HTTP client level with exponential backoff. Subsequent flash sale: 4,200 concurrent users, 2 DbUpdateConcurrencyExceptions logged total β absorbed by the retry loop.
Takeaway: DbUpdateConcurrencyException on stock/inventory tables is expected, not exceptional, during high concurrency. Design your exception handling as a retry loop with ReloadAsync() + domain check (is the operation still valid after reload?), not as a failure path.
2Swallowed ConcurrencyException Silently Discarded User Edits
Our CRM had a customer profile editor. Two support agents could simultaneously edit different fields of the same customer record. We had [Timestamp] on Customer but our exception handler caught DbUpdateConcurrencyException and returned HTTP 200 with a silent 'Database Wins' strategy β always overwriting the client's changes with the DB version.
Agent A opened a customer record. Agent B opened the same record and updated the phone number (saved successfully). Agent A updated the email address and saved β DbUpdateConcurrencyException fired. Our 'Database Wins' handler discarded Agent A's email change silently, wrote Agent B's phone number back to the entity, and returned 200 OK with no indication that the save failed. Agent A's email change was silently lost.
Implemented a per-field merge strategy: compare OriginalValues, CurrentValues, and DatabaseValues per property. Non-conflicting field changes (Agent A's email vs Agent B's phone = different fields) are merged automatically. Genuine field conflicts surface a 409 response with a diff UI showing: 'You changed email to X, but another user changed it to Y simultaneously.' Let the user decide.
Takeaway: Never silently discard user edits. Database Wins is appropriate for automated background processes. For human-facing edit flows, surface conflicts explicitly. A 'your changes were discarded' message is always better than a silent data loss that users discover days later.