Almost everyone had this situation in production: a customer changed the delivery address on a shipment, saved it, saw the confirmation, and an hour later the old address is back.
You start investigating, and you don't see any errors or exceptions in the logs. The request returned 200 OK, but the data is just wrong.
This is a lost update, and it's one of the most common bugs in production .NET systems. It happens whenever two requests read the same row, change it in memory, and write it back.
I've debugged this exact bug in payment systems, warehouse software, and booking flows. It never shows up in a unit or integration test, because the tests don't run two requests in the same millisecond (your integration test can actually test concurrent requests).
There are two classic ways to solve it: optimistic concurrency and pessimistic concurrency. They solve the same problem with different assumptions, and picking the wrong one either kills your throughput or leaves the bug in place.
In this post, we will explore:
- The Lost Update Problem
- Optimistic Concurrency: Detect the Conflict
- Handling DbUpdateConcurrencyException
- Retrying Conflicts with a Resilience Pipeline
- Pessimistic Concurrency: Lock the Row First
- A Third Option: One Atomic Statement
- Optimistic vs Pessimistic: Deep Comparison
- How to Choose: A Decision Checklist
Let's dive in.
The Lost Update Problem
Here is the domain we'll use throughout the post: a shipment that a warehouse operator can edit.
csharppublic class Shipment { public Guid Id { get; set; } public string Number { get; set; } public string Address { get; set; } public string Carrier { get; set; } public ShipmentStatus Status { get; set; } public List<ShipmentItem> Items { get; set; } = []; }
And here is the update handler almost everyone writes first:
csharppublic async Task<Result> Handle(UpdateShipmentRequest request, CancellationToken ct) { var shipment = await dbContext.Shipments .FirstOrDefaultAsync(s => s.Number == request.Number, ct); if (shipment is null) { return Result.NotFound($"Shipment '{request.Number}' not found"); } shipment.Address = request.Address; shipment.Carrier = request.Carrier; await dbContext.SaveChangesAsync(ct); return Result.Success(); }
The code reads a row, changes it in memory, and writes it back.
Between the read and the write there is a gap. It's small, usually a few milliseconds, but it's real. Anything that happens inside that gap is invisible to this handler.
Now put two operators in that gap at the same time. One changes the address, the other changes the carrier:
Both requests got a success response, and both updated exactly one row. Neither of them did anything wrong on its own.
But the row ends up with the address "Amsterdam" and the carrier "UPS". Request A's address change is gone, and no one was notified.
This is what a lost update means: one writer silently overwrites another writer's change because it never saw it.
The first instinct is to wrap the handler in a transaction, and that doesn't fix it.
At the Read Committed isolation level, which is the default in PostgreSQL and SQL Server, both transactions read a valid committed row, and both writes succeed. The database is doing exactly what you asked. You just never told it that the second write depended on the first read.
Serializable isolation does catch this, at the cost of serialization failures that you have to retry anyway. If you want the full picture of what each level protects against, I covered it in Complete Guide to Transaction Isolation Levels in SQL.
The two techniques below fix the problem directly, and you can apply either one per use case.
Optimistic Concurrency: Detect the Conflict
Optimistic concurrency starts with the assumption that conflicts are rare.
So it doesn't lock anything. It lets both requests run at full speed and, at the moment of writing, makes the database check whether anyone changed the row in the meantime.
The mechanism is a concurrency token: a column whose value changes on every update. You read it together with the row, and your UPDATE statement carries it in the WHERE clause.
If the token in the database no longer matches the one you read, your UPDATE matches zero rows, and you know somebody got there first.
EF Core supports this out of the box, and you have three ways to configure it.
PostgreSQL, using the built-in xmin system column:
csharpmodelBuilder.Entity<Shipment>() .UseXminAsConcurrencyToken();
This is the cheapest option on PostgreSQL because xmin already exists on every row. You get concurrency checks without adding a column or writing a migration.
SQL Server, using a rowversion column:
csharpmodelBuilder.Entity<Shipment>() .Property<byte[]>("Version") .IsRowVersion();
SQL Server maintains the value itself on every update, so you never assign it in code.
Any provider, using your own token:
csharppublic class Shipment { // ... public Guid Version { get; set; } } modelBuilder.Entity<Shipment>() .Property(s => s.Version) .IsConcurrencyToken();
A manual token is the one I reach for most often, and not because of provider portability. It's a plain Guid column, so it survives a round trip to a browser or a mobile client, which is exactly what a stateless web API needs.
You do have to change the value yourself. The cleanest place is a SaveChangesAsync override on the DbContext:
csharppublic override Task<int> SaveChangesAsync(CancellationToken ct = default) { var entries = ChangeTracker.Entries<Shipment>() .Where(e => e.State is EntityState.Added or EntityState.Modified); foreach (var entry in entries) { entry.Entity.Version = Guid.NewGuid(); } return base.SaveChangesAsync(ct); }
Whichever option you pick, EF Core now generates a different UPDATE:
sqlUPDATE shipments SET address = @p0, carrier = @p1, version = @p2 WHERE id = @p3 AND version = @p4;
The version = @p4 predicate is the whole trick. EF Core sends the value it read, counts how many rows the statement actually changed, and throws a DbUpdateConcurrencyException when the answer is zero.
Here is the same race as before, with the token in place:
Request A still wins the race, exactly as it did before. The difference is that Request B now finds out rather than quietly replacing the value.
There's one part that's easy to miss in a web API. Your two requests don't share a DbContext, and they don't even overlap in time. The operator opens an edit form, thinks for two minutes, and submits.
For the check to mean anything, the version has to travel to the client and come back:
csharppublic sealed record ShipmentResponse( string Number, string Address, string Carrier, string Version); public sealed record UpdateShipmentRequest( string Number, string Address, string Carrier, string Version);
Then you tell EF Core to use the client's version instead of the one you just read from the database:
csharpdbContext.Entry(shipment) .Property(s => s.Version) .OriginalValue = Guid.Parse(request.Version); shipment.Address = request.Address; shipment.Carrier = request.Carrier; await dbContext.SaveChangesAsync(ct);
Setting OriginalValue is what puts the client's version into the WHERE clause. Without this line, you're comparing the row against a value you read milliseconds ago, and the two minutes when the operator was typing go completely unchecked.
Now the write fails when it should. The next question is what to do about it.
Handling DbUpdateConcurrencyException
DbUpdateConcurrencyException isn't an error you log and forget. Somebody's change is about to be discarded, and you have to decide whose.
csharptry { await dbContext.SaveChangesAsync(ct); return Result.Success(); } catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var databaseValues = await entry.GetDatabaseValuesAsync(ct); if (databaseValues is null) { return Result.Conflict("The shipment was deleted by another user"); } // resolve the conflict here }
A null result means the row is gone entirely. Somebody deleted the shipment while your user was editing it, and no merge strategy can help with that.
Store wins. Throw the user's edit away and show them the current data:
csharpawait entry.ReloadAsync(ct); return Result.Conflict( "This shipment was changed by another user. Review the current values and try again.");
This is the safest option and my default for anything a human edits. Nothing is overwritten silently, and the person gets to decide with the real data in front of them.
Client wins. Force your values through:
csharpentry.OriginalValues.SetValues(databaseValues); await dbContext.SaveChangesAsync(ct);
Copying the database values into OriginalValues refreshes the token, so the second UPDATE matches the row, and your changes overwrite the other user's changes.
Use this only when your writer is authoritative, such as an import job that owns the record.
Merge. Keep the fields the user actually changed and take the rest from the database:
csharpvar currentValues = entry.CurrentValues; foreach (var property in currentValues.Properties) { var proposed = currentValues[property]; var original = entry.OriginalValues[property]; var fromDatabase = databaseValues[property]; if (Equals(proposed, original)) { currentValues[property] = fromDatabase; } } entry.OriginalValues.SetValues(databaseValues); await dbContext.SaveChangesAsync(ct);
The loop compares each property's proposed value with the value originally loaded. If they're equal, the user didn't touch that field, so the database's newer value wins. If they differ, the user's edit is kept.
Two operators editing different fields of the same shipment both get their change through, which is why merge is worth the extra code on forms with many independent fields.
All three strategies assume somebody is waiting for an answer. Background jobs need something else.
Retrying Conflicts with a Resilience Pipeline
When no user is watching, a concurrency conflict is just a transient failure. The correct response is to read the new row and repeat the work.
That's a retry, and in .NET you can use the Polly library and create a pipeline that handles exactly this exception:
csharpvar pipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { ShouldHandle = new PredicateBuilder() .Handle<DbUpdateConcurrencyException>(), MaxRetryAttempts = 3, Delay = TimeSpan.FromMilliseconds(20), BackoffType = DelayBackoffType.Exponential, UseJitter = true }) .Build();
UseJitter matters here. Without it, several workers that collide on the same row will back off for the identical delay and collide again on the next attempt. Jitter spreads them out.
The pipeline has to wrap the whole read-modify-write, not just the save:
csharpawait pipeline.ExecuteAsync(async token => { await using var dbContext = await dbContextFactory.CreateDbContextAsync(token); var shipment = await dbContext.Shipments .FirstAsync(s => s.Number == number, token); shipment.Status = ShipmentStatus.Dispatched; shipment.DispatchedAtUtc = DateTime.UtcNow; await dbContext.SaveChangesAsync(token); }, ct);
Retrying only SaveChangesAsync would send the same stale token again, causing every attempt to fail for the same reason. The read has to happen again too.
Note: each attempt needs its own DbContext, created here through
IDbContextFactory. A context whoseSaveChangesalready threw an exception still holds the failed entity in its change tracker with its original, stale values and reusing it turns a clean retry into a subtle bug. See How To Manage EF Core DbContext Lifetime for why a factory is the right tool in background work.
Pessimistic Concurrency: Lock the Row First
Pessimistic concurrency starts from the opposite assumption: conflicts are expected.
Instead of detecting a conflict after the write, it stops the second writer from creating one. The first transaction locks the row, and everybody else waits their turn.
The classic case is a counter that many requests hit at once. In our logistics domain, that's warehouse stock:
csharppublic class StockItem { public Guid Id { get; set; } public string Sku { get; set; } public int Quantity { get; set; } }
During a sale, hundreds of requests per second decrement the quantity of the same popular SKU. Optimistic concurrency handles that poorly because, at high contention, most attempts lose the race, and the retries pile up on the same row.
Locking gives you a queue instead (example for Postgres):
csharpawait using var transaction = await dbContext.Database.BeginTransactionAsync(ct); var stockItem = await dbContext.StockItems .FromSql($"SELECT * FROM stock_items WHERE sku = {sku} FOR UPDATE") .SingleAsync(ct); if (stockItem.Quantity < request.Quantity) { await transaction.RollbackAsync(ct); return Result.Conflict($"Not enough stock for SKU '{sku}'"); } stockItem.Quantity -= request.Quantity; await dbContext.SaveChangesAsync(ct); await transaction.CommitAsync(ct);
FOR UPDATE takes a row-level lock that the database holds until the transaction commits or rolls back.
The second request blocks on the SELECT instead of the UPDATE.
That's the important difference from everything above: by the time it gets to read, the first transaction is finished, and it sees the new quantity.
There is no gap, so there is no conflict to resolve.
EF Core has no built-in API for pessimistic locking, so the query is implemented manually using the FromSql method.
The interpolated {sku} becomes a real SQL parameter, so this stays safe from injection.
The same two requests with a pessimistic lock:
Compare this with the previous two diagrams. In all three, Request A wins. What changes is what happens to Request B: it silently destroys A's work, it gets an exception, or it waits and then reads the correct value.
Note: on SQL Server the equivalent is a locking hint:
SELECT * FROM StockItems WITH (UPDLOCK, ROWLOCK) WHERE Sku = @sku.
By default, a blocked request waits for as long as the lock is held, so set a timeout right after you open the transaction:
csharpawait dbContext.Database.ExecuteSqlRawAsync("SET LOCAL lock_timeout = '3s'", ct);
PostgreSQL offers two additional variants worth knowing about.
FOR UPDATE NOWAIT fails immediately instead of waiting, raising error 55P03. Use it when a fast rejection is better for the caller than a slow success.
FOR UPDATE SKIP LOCKED skips rows that someone else has locked. This is how you build a job queue on a table that many workers poll at once:
csharpvar jobs = await dbContext.ShipmentJobs .FromSql($""" SELECT * FROM shipment_jobs WHERE status = 'Pending' ORDER BY created_at LIMIT {batchSize} FOR UPDATE SKIP LOCKED """) .ToListAsync(ct);
Every worker gets a different batch, and none of them wait on each other. I've used this to scan the Outbox table in production apps, and for large data volumes, it holds up well.
Locking isn't free, and the costs are the following:
- The transaction stays open for the whole read-modify-write, holding a connection from the pool the entire time
- Deadlocks become possible as soon as two code paths lock the same rows in a different order
- It only works inside one transaction, so you can't hold a lock while a user fills in a form
- A slow operation inside the lock blocks every other writer on that row
That last point is worth repeating. Never call an external API inside a locked transaction, because a three-second payment provider timeout becomes three seconds of blocking for every request queued behind you.
For a single counter, there's a way to skip both patterns entirely.
A Third Option: One Atomic Statement
Optimistic and pessimistic concurrency both exist because of the gap between reading a value and writing it back. Sometimes you can just remove the gap.
If the new value is a function of the old value, the database can compute it in one statement, and ExecuteUpdateAsync expresses that in LINQ:
csharpvar affected = await dbContext.StockItems .Where(s => s.Sku == sku && s.Quantity >= request.Quantity) .ExecuteUpdateAsync( s => s.SetProperty(x => x.Quantity, x => x.Quantity - request.Quantity), ct); if (affected == 0) { return Result.Conflict($"Not enough stock for SKU '{sku}'"); }
That produces a single round trip:
sqlUPDATE stock_items SET quantity = quantity - @p0 WHERE sku = @p1 AND quantity >= @p2;
The read and write occur in a single statement, and the database automatically locks the row for its duration. No entity is loaded, no transaction stays open across a network hop, and there is nothing to retry.
The quantity >= @p2 guard is what makes it correct. It prevents the update from running at all when stock has already dropped below what the caller asked for, and the affected-row count indicates whether it ran.
Zero rows means either the SKU doesn't exist or there wasn't enough stock, and both lead to the same answer for the caller.
Note:
ExecuteUpdateAsyncbypasses the EF Core Change Tracker. Your concurrency token is not checked, entities already loaded in memory silently go stale, and noSaveChangesinterceptors or domain events fire. The guard inWhereis the only protection you get, so it has to be right. I covered the other consequences in Correct Way To Use ExecuteUpdate and ExecuteDelete Methods in EF Core.
This approach is limited and works for counters, flags, and status transitions, where the rule fits within a single SQL predicate. As soon as the decision needs several entities, external data, or logic that lives in your domain model, you're back to choosing between optimistic and pessimistic concurrency.
Optimistic vs Pessimistic: Deep Comparison
Let's compare the two approaches across the criteria that actually decide the choice.
Conflict handling
- Optimistic: detects the conflict after the fact and fails the write
- Pessimistic: prevents the conflict by making the second writer wait
Best contention level
- Optimistic: low to moderate, where conflicts are the exception
- Pessimistic: high, where many writers hit the same row constantly
Throughput and latency
- Optimistic: fast while conflicts are rare, and it degrades as contention grows because every lost race is wasted work plus a retry
- Pessimistic: a lower ceiling, but predictable, because each writer waits for the lock instead of redoing the operation
Failure mode
- Optimistic: an exception your code must handle, on every single update path
- Pessimistic: a wait, a lock timeout, or a deadlock
Deadlock risk
- Optimistic: none, because nothing is locked
- Pessimistic: real as soon as two code paths take locks in different orders
Transaction duration
- Optimistic: short, since the transaction is just the save
- Pessimistic: covers the whole read-modify-write, holding a connection throughout
Long-lived and stateless edits
- Optimistic: works across requests, because the token travels to the client and back
- Pessimistic: impossible, since a lock can't survive between two HTTP requests
Complexity
- Optimistic: a token, plus conflict resolution and retry code in every write path
- Pessimistic: raw SQL for the lock, plus timeouts and a consistent lock ordering
Database support
- Optimistic: first-class in EF Core through concurrency tokens
- Pessimistic: no EF Core API, so the locking hint is written by hand (SQL) per database provider
Across services or databases
- Optimistic: works anywhere, because the token is just a value you read, pass around, and compare
- Pessimistic: works across any number of services that share the same database, since the lock lives in the database and not in your process. It stops working when the data is split across separate databases, or when the lock would need to outlive one transaction
Neither approach is universally better, and most systems I've worked on ended up using both. The interactive parts of the application were optimistic, and a handful of hot counters were pessimistic or atomic.
Summary
When you're picking an approach for one specific operation, walk through this list.
Use optimistic concurrency when:
- Conflicts are rare, which is true of most business entities
- A human edits the data through a form, across separate requests
- The edit window is long, from seconds to minutes
- You can show the user a meaningful message and let them decide
- The write path spans services or databases, so no shared lock exists
Use pessimistic concurrency when:
- Many writers hit the same row at once and optimistic retries keep failing
- The operation must succeed on the first attempt, not eventually
- Correctness depends on reading several rows and writing them as one unit
- You're distributing work from a queue (outbox) table, which is the
SKIP LOCKEDcase - The whole operation is short and runs inside one transaction
Use a single atomic statement when:
- You're changing a counter or a flag, and the new value follows from the old one
- The rule fits into one SQL predicate, such as
quantity >= @requested - You want maximum throughput on a hot row with no retry logic at all
Red flags that you chose wrong:
- Concurrency exceptions show up constantly in your logs, so optimistic is the wrong fit for that row
- Requests time out waiting for locks, so a pessimistic transaction is doing too much work
- Deadlock errors appear, so two paths take the same locks in a different order
- Users report changes disappearing, so you have no concurrency control at all yet
Hope you find this newsletter useful. See you next time.


Comments