newsletter

Optimistic vs Pessimistic Concurrency in .NET

10 min read

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.

Copied

The Lost Update Problem

Here is the domain we'll use throughout the post: a shipment that a warehouse operator can edit.

csharp
public 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:

csharp
public 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:

Loading diagram…

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.

Copied

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:

csharp
modelBuilder.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:

csharp
modelBuilder.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:

csharp
public 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:

csharp
public 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:

sql
UPDATE 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:

Loading diagram…

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:

csharp
public 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:

csharp
dbContext.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.

Copied

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.

csharp
try { 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:

csharp
await 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:

csharp
entry.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:

csharp
var 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.

Copied

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:

csharp
var 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:

csharp
await 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 whose SaveChanges already 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.

Copied

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:

csharp
public 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):

csharp
await 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:

Loading diagram…

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:

csharp
await 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:

csharp
var 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.

Copied

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:

csharp
var 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:

sql
UPDATE 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: ExecuteUpdateAsync bypasses the EF Core Change Tracker. Your concurrency token is not checked, entities already loaded in memory silently go stale, and no SaveChanges interceptors or domain events fire. The guard in Where is 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.

Copied

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.

Copied

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 LOCKED case
  • 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.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design — answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
Join 500+ developers

Not sure where you stand? Take the free .NET interview test:

  • Find out your real level — Junior to Senior+
  • A realistic mock .NET interview — across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next — the perfect way to benchmark yourself before diving into the Playbook.

Start the free test

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 27,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 27,000+ developers already reading
No spam. Unsubscribe any time.