Optimistic Locking in PostgreSQL via EF Core
Optimistic concurrency in EF Core is opt-in: by default, SaveChangesAsync issues UPDATEs with no version check at all. Configuring it takes a concurrency token (manual via IsConcurrencyToken(), automatic via PostgreSQL’s xmin system column, or systemic via a SaveChangesInterceptor), a retry wrapper for conflicts the application can resolve, and an exception filter to surface the rest as HTTP 409. This post walks all four pieces against PostgreSQL via Npgsql, with a companion ASP.NET Core API on .NET 10 as the cumulative result.
1. The default has no concurrency control
EF Core’s SaveChangesAsync has no opinion about concurrent writes out of the box. When the change tracker sees a modified entity, it simply emits an UPDATE whose WHERE clause targets the primary key for the specific entity, and that’s it. EF doesn’t automatically insert a magical concurrency token by default, or anything like that.
In a concurrency situation (multiple writes targeting the same row around the same time) the last write wins, and the first writer’s intent is silently lost. The system is susceptible to race conditions.
The database never raises an error, because from its point of view, both updates were legal, and it simply executes them in the order they come. But in an ideal scenario, the 2nd update would fail, and the client would have to decide what to do next (retry or return error to user, etc.)
EF Core’s concurrency model is opt-in optimistic. It can be optimistic when you configure it to be, which is what this post teaches. Until then, there is no concurrency control at ORM level by default.
1.1. The bug in ordinary CRUD code
The failure shows up in the most ordinary CRUD shape: a tracked entity, a controller method that reads, mutates, and saves. Take a Coupon entity:
public class Coupon
{
public Guid Id { get; set; }
public string Code { get; set; } = "";
public int RedemptionsRemaining { get; set; }
public string? Description { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}And a PUT /api/coupons/{id} handler that updates it:
[HttpPut("{id:guid}")]
public async Task<ActionResult<Coupon>> Update(Guid id, UpdateCouponRequest request, CancellationToken ct)
{
var coupon = await db.Coupons.FirstOrDefaultAsync(c => c.Id == id, ct);
if (coupon is null) return NotFound();
coupon.Code = request.Code;
coupon.RedemptionsRemaining = request.RedemptionsRemaining;
coupon.Description = request.Description;
coupon.ExpiresAt = request.ExpiresAt;
await db.SaveChangesAsync(ct);
return coupon;
}It reads the row, copies the request’s values onto it, saves. Nothing fancy.
Now imagine two editors hit the endpoint at the same time, where editor A is changing only the description, and editor B is changing only the redemption count. Each editor’s request body, naturally, carries the full state (form submit) they have read a moment ago, with the one field they cared about set to the new value.
Starting state: description = "Black Friday 25% off", redemptionsRemaining = 10.
A: { description: "Editor A: tweaked", redemptionsRemaining: 10, ... }
B: { description: "Black Friday 25% off", redemptionsRemaining: 5, ... }Both PUTs return HTTP 200. Each response body echoes back the value its editor sent. Both editors close the tab convinced their change went in. A GET after the requests settle:
{
"description": "Black Friday 25% off",
"redemptionsRemaining": 5,
...
}Editor A’s “tweaked” description is gone. Editor B’s redemption decrement is there. Editor B never asked to write to description, but the request body carried the value from before Editor A’s edit, and that’s what landed.
1.2. The SQL EF Core emits
The mechanism is visible the moment you turn on EF Core’s command logging. Set Microsoft.EntityFrameworkCore.Database.Command to Information in appsettings.Development.json and every read and write shows up. Here’s what comes out for the two PUTs above, in the order they hit the database:
-- Editor A's UPDATE
UPDATE "Coupons" SET "Description" = @p0
WHERE "Id" = @p1;
-- Editor B's UPDATE
UPDATE "Coupons" SET "Description" = @p0, "RedemptionsRemaining" = @p1
WHERE "Id" = @p2;Two things matter in those statements.
The WHERE clause is just "Id" = @pN. There is no version column, no timestamp, no compare-and-set, nothing the database can use to notice that the row changed between read and write. The affected-row count is 1 for both.
The SET clause only includes columns the change tracker saw differ from the loaded value. Editor B loaded after A’s commit, so the description in B’s tracker is A’s fresh value while B’s request body still carries the old one. EF flags it as changed, and B’s UPDATE writes both Description and RedemptionsRemaining, wiping out A’s commit on a column B never asked to touch.
The lost-update bug is worse than the obvious framing suggests. Two writers don’t have to be fighting over the same column; a request that honestly mirrors what the user saw a moment ago will overwrite anything that’s shifted in the meantime.
1.3. Why ordinary transactions don’t help
Wrapping the PUT handler’s read and write in a transaction at the default isolation level (READ COMMITTED in PostgreSQL) wouldn’t change anything here. Each transaction’s reads are internally consistent, but two concurrent transactions can still write and commit one after the other without either one being told the other existed.
If the workload’s concurrency hot path is a small set of rows under heavy write contention, the right tool is row-level pessimistic locking, SELECT ... FOR UPDATE inside an explicit transaction, covered in detail in Pessimistic Locking in PostgreSQL via EF Core. For decrement-or-increment style operations whose decision fits in a single SQL statement, an atomic UPDATE with the guard in the WHERE is simpler still; see here. For everything else, where contention is occasional and a retry is cheap, the right tool is optimistic concurrency. The rest of this post is the path from no protection at all to project-wide optimistic concurrency, retried at the data layer and surfaced cleanly at the API edge.
2. Adding a concurrency token, manually (for now)
An optimistic concurrency token is a column EF Core adds to the WHERE clause of every UPDATE and DELETE for the entity. When SaveChanges runs, the database compares the row’s stored token to the value the client edited against; if they don’t match, the affected-row count comes back as zero and EF throws DbUpdateConcurrencyException. The application either retries against the fresh state or surfaces the conflict.
The minimal version is a new property and one Fluent API line:
public class Coupon
{
public Guid Id { get; set; }
public string Code { get; set; } = "";
public int RedemptionsRemaining { get; set; }
public string? Description { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public Guid Version { get; set; } // <- concurrency token (refreshed every update)
}modelBuilder.Entity<Coupon>(b =>
{
// existing config...
b.Property(c => c.Version).IsConcurrencyToken();
});IsConcurrencyToken() and the equivalent [ConcurrencyCheck] attribute on the property do the same thing: mark a column as part of the optimistic-concurrency check. The Fluent API call keeps the configuration in OnModelCreating alongside the rest of the entity’s mapping; the attribute mixes configuration into the POCO. Either is fine; I prefer Fluent for consistency.
There’s also IsRowVersion() (or the [Timestamp] attribute), which adds the same check and tells EF that the database will bump the value on every update. On SQL Server with a byte[] property it maps to rowversion and works automatically. On PostgreSQL with a uint property it triggers an Npgsql convention that maps to the xmin system column (covered in §3); other property types either need application-side bumping or don’t apply. For the explicit-bump pattern we want in this section, a plain Guid is the cleanest fit. The token’s value doesn’t matter, only that it changes on every write; a Guid is portable across providers, visible to clients in JSON without base64 wrangling, and easy to generate from C#.
2.1. The SQL change
After the configuration above, the same controller that previously emitted
UPDATE "Coupons" SET "Description" = @p0
WHERE "Id" = @p1;now emits
UPDATE "Coupons" SET "Description" = @p0, "Version" = @p1
WHERE "Id" = @p2 AND "Version" = @p3;Two changes. The SET writes the new Version alongside the modified columns. The WHERE adds Version = @p3, where @p3 is the original value EF saw when it loaded the entity. The database itself does the comparison; the affected-row count is the signal. EF reads that count and either returns normally (1 row, fine) or throws DbUpdateConcurrencyException (0 rows, the version moved).
2.2. The client’s version vs the row’s version
For the concurrency check to actually catch concurrent writes, EF has to include the right version in the
WHEREclause: the one the client thought they were editing against.
Using the concurrency token as it was read within the PUT handler would NOT be right, because that conc token is snapshotted much after when the user started modifying the data. We need the conc token as it was when the user first read the data.
In a stateless web service (where data is round-tripped through user) that means carrying the conc token as part of the form.
EF’s change tracker calls this loaded-at-PUT-time value the entity’s OriginalValue (one per property). To make the optimistic check do real work, override it with the version from the request body before calling SaveChangesAsync:
The approach below is not ideal; a better workflow is presented in later sections.
[HttpPut("{id:guid}")]
public async Task<ActionResult<Coupon>> Update(Guid id, UpdateCouponRequest request, CancellationToken ct)
{
var coupon = await db.Coupons.FirstOrDefaultAsync(c => c.Id == id, ct);
if (coupon is null) return NotFound();
// EF's UPDATE WHERE compares against the Original value; tell it the client's, not the row we loaded.
db.Entry(coupon).Property(c => c.Version).OriginalValue = request.Version;
coupon.Code = request.Code;
coupon.RedemptionsRemaining = request.RedemptionsRemaining;
coupon.Description = request.Description;
coupon.ExpiresAt = request.ExpiresAt;
// The unfortunate manual bump
coupon.Version = Guid.NewGuid();
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return Conflict();
}
return coupon;
}The OriginalValue override is the load-bearing line. With it in place, the WHERE becomes "Id" = @p2 AND "Version" = <client's V>. The row updates only if the database hasn’t moved past V. Otherwise, zero rows match, DbUpdateConcurrencyException fires, the controller returns 409.
Two concurrent PUTs against the same starting version now have a clear loser:
$ curl -X PUT .../coupons/{id} -d '{... "version":"7b5de321-..."}' &
$ curl -X PUT .../coupons/{id} -d '{... "version":"7b5de321-..."}' &
A: HTTP 200, response.version = "7bdd92db-..." (the new one)
B: HTTP 409 ConflictThe lost-update bug from §1 is gone. Editor B knows the write didn’t land and can decide what to do (refetch, merge, prompt the user). The “I never touched description” overwrite from §1.2 cannot happen, because Editor B’s WHERE doesn’t match the row’s current version, regardless of what columns are in the SET.
2.3. The manual bump is the trap
There’s a line that’s easy to miss: coupon.Version = Guid.NewGuid().
The token only advances if you bump it on every save. Forget it once, in one handler out of fifty and that handler’s writes leave the token unchanged. Two concurrent edits against the same starting version both succeed silently because the WHERE keeps matching the same value, and the lost update is back, with the version column sitting there as a kind of polite decoration.
Optimistic concurrency that depends on every write path remembering to advance a column is a check that only works when the developer pays attention. The next two sections remove the bump from the developer’s hands in different ways. §3 hands the responsibility to PostgreSQL itself, using a system column that advances on every row update for free. §4 keeps the explicit Version column and writes one piece of infrastructure that bumps it on every save, for every entity, automatically.
3. xmin and the Postgres shortcut
PostgreSQL stamps every row with a hidden transaction id called xmin that advances on every update. With one Fluent API line, EF Core can use it as the concurrency token. The manual bump from §2 disappears; PostgreSQL does the work.
The configuration is two changes from §2: the property type becomes uint, and IsConcurrencyToken() becomes IsRowVersion().
public class Coupon
{
// ...
public uint Version { get; set; }
}modelBuilder.Entity<Coupon>(b =>
{
// ...
b.Property(c => c.Version).IsRowVersion();
});There is no real Version column in the database. The property maps to PostgreSQL’s xmin system column via an Npgsql convention that fires when it sees a uint property marked as a concurrency token with ValueGenerated.OnAddOrUpdate. IsRowVersion() sets the concurrency-token flag and the OnAddOrUpdate flag; uint is the piece you have to pick yourself.
If you’ve seen older tutorials suggest b.UseXminAsConcurrencyToken(), that method was obsoleted in Npgsql 7.0 and is gone in current versions. The modern equivalent is what we just did.
3.1. The SQL change
The SELECT picks up xmin automatically:
SELECT c."Id", c."Code", c."Description", c."ExpiresAt", c."RedemptionsRemaining", c.xmin
FROM "Coupons" AS c
WHERE c."Id" = @id;The UPDATE uses xmin in the WHERE and reads the new value back via RETURNING:
UPDATE "Coupons" SET "Description" = @p0
WHERE "Id" = @p1 AND xmin = @p2
RETURNING xmin;The interesting line is the UPDATE. The SET writes only columns the change tracker detected as changed. xmin is not on that list because the application never modifies it. PostgreSQL itself bumps xmin to the new transaction id when the row is updated. The RETURNING xmin clause reads the new value back, and EF Core stores it as the Current value of the tracked entity, ready for the response body or the next mutation.
The same OriginalValue override from §2 still applies. The client supplies the version it edited against, and the controller hands it to EF before SaveChangesAsync:
db.Entry(coupon).Property(c => c.Version).OriginalValue = request.Version;What the application no longer does is bump Version itself. The line coupon.Version = Guid.NewGuid(); from §2 is gone. PostgreSQL provides the new value. The schema doesn’t need a new column; xmin already exists on every table.
The migration that drops the manual Version column from §2 is a one-liner, with one caveat. EF’s auto-generated migration tries to rename Version to xmin and alter the type, but PostgreSQL rejects user columns named xmin (the name is reserved for the system column). The right migration is a DropColumn:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Version",
table: "Coupons");
}3.2. What the shortcut costs
Three trade-offs come with using a system column:
- Provider lock-in. The same model won’t work on SQL Server (which has its own
rowversionstory) or SQLite (no concurrency-column equivalent). If the application might switch providers, an explicitVersioncolumn the application bumps itself stays portable;xmindoesn’t. - No physical column to introspect.
xmindoesn’t show up in\d couponsunless you ask for system columns explicitly with\d+. Schema-comparison tools, ORMs in other languages, and operators reading the schema by hand won’t see anything called “Version” until they know to look for the convention. - Bound to PostgreSQL’s transaction id space.
xminis a 32-bit unsigned integer, the same width as PostgreSQL’s transaction id counter. PostgreSQL’s vacuum process keeps the visible range bounded under any normal load, so wraparound is a non-issue in practice. Worth knowing about if you ever storexminoutside the database for delayed comparisons.
For most applications running on PostgreSQL, none of these concerns matter day to day. The Postgres-only constraint is implicit in choosing PostgreSQL, the column-visibility concern is something tooling can adapt to, and wraparound stays a non-issue under normal vacuum.
3.3. The bridge to a portable version
xmin removes the manual bump for free, in exchange for tying the schema to PostgreSQL. The next section keeps the explicit Version column with IsConcurrencyToken() (cross-provider) and removes the manual bump a different way: a SaveChangesInterceptor that does the bump on the application side, for every entity in the schema, automatically. The pain is gone, the schema stays portable, and the infrastructure is one class.
4. From one entity to all of them: a base class and an interceptor
A SaveChangesInterceptor turns the version bump from a per-handler responsibility into one piece of cross-cutting infrastructure. Combined with a base Entity class that every model inherits, the team gets optimistic concurrency on every new table by writing two lines: a class declaration and a DbSet.
public abstract class Entity
{
public Guid Id { get; set; }
public Guid Version { get; set; }
}
public class Coupon : Entity
{
public string Code { get; set; } = "";
public int RedemptionsRemaining { get; set; }
public string? Description { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}The IsConcurrencyToken() configuration applies to every Entity-derived type by walking the model in OnModelCreating:
foreach (var entityType in modelBuilder.Model.GetEntityTypes()
.Where(et => typeof(Entity).IsAssignableFrom(et.ClrType)))
{
modelBuilder.Entity(entityType.ClrType)
.Property(nameof(Entity.Version))
.IsConcurrencyToken();
}One loop, every Entity-derived type’s Version is marked. Adding a new entity that inherits Entity requires zero changes to OnModelCreating; the loop picks it up.
4.1. The interceptor
The version bump moves into a SaveChangesInterceptor:
public class ConcurrencyInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
BumpVersions(eventData);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
BumpVersions(eventData);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void BumpVersions(DbContextEventData eventData)
{
if (eventData.Context is null) return;
foreach (var entry in eventData.Context.ChangeTracker.Entries<Entity>())
{
if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
{
entry.Entity.Version = Guid.NewGuid();
}
}
}
}Three details worth pulling on.
SavingChanges and SavingChangesAsync are both overridden. Application code typically goes through the async path, but some libraries (older diagnostics, sync compatibility shims) call the sync version. Forwarding both to the same BumpVersions helper is cheap and removes the surprise.
Entries<Entity>() is the filter that makes the interceptor universal. EF Core’s typed Entries<T> returns only entries whose entity type is assignable to T. Coupon, Promotion, and any future class that inherits Entity get included; any other entity (audit logs, reference tables) is skipped without per-type wiring.
For both Added and Modified states, the bump assigns a fresh Guid. On Added, that’s the initial value the row will hold. On Modified, it overwrites whatever the application or the loaded entity had; the interceptor is the single source of “what the new Version is.” Combined with the controller-side OriginalValue override (still required, see below), the optimistic check is fully driven by infrastructure: the developer routes the client’s claimed version to EF, the interceptor decides what the new version becomes.
4.2. Registering the interceptor
The interceptor is stateless, so a singleton registration is fine. The DbContext picks it up via the per-DI options overload:
builder.Services.AddSingleton<ConcurrencyInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options
.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"))
.AddInterceptors(sp.GetRequiredService<ConcurrencyInterceptor>());
});AddInterceptors accepts an IInterceptor and registers it on the context. The DI version ((sp, options) => ...) is what lets the interceptor resolve from the container instead of being new-ed inline. That matters when the interceptor has its own dependencies (logging, time providers, audit context), even if this particular one doesn’t.
4.3. What the controller drops
The interceptor removes one line from every PUT handler: coupon.Version = Guid.NewGuid(). A small DbContext extension method removes the other: the OriginalValue override that routes the client’s claimed version into EF. Different concerns, both centralized at the data layer.
The helper fuses the load with the override:
public static class DbContextLoadExtensions
{
public static async Task<T?> LoadForUpdateAsync<T>(
this DbContext db,
Guid id,
Guid claimedVersion,
CancellationToken ct = default)
where T : Entity
{
var entity = await db.Set<T>().FirstOrDefaultAsync(e => e.Id == id, ct);
if (entity is null) return null;
db.Entry(entity).Property(e => e.Version).OriginalValue = claimedVersion;
return entity;
}
}The PUT handler shrinks to its domain logic. Before:
var coupon = await db.Coupons.FirstOrDefaultAsync(c => c.Id == id, ct);
if (coupon is null) return NotFound();
db.Entry(coupon).Property(c => c.Version).OriginalValue = request.Version;
coupon.Code = request.Code;
// ... other fields ...
coupon.Version = Guid.NewGuid(); // manual bumpAfter:
var coupon = await db.LoadForUpdateAsync<Coupon>(id, request.Version, ct);
if (coupon is null) return NotFound();
coupon.Code = request.Code;
// ... other fields ...
// Version bump happens in ConcurrencyInterceptor before SaveChanges.Two lines gone. What remains is “load the entity, apply the request, save.” The override isn’t omitted, it’s just done by LoadForUpdateAsync at the same time as the load. Forgetting to call the helper means forgetting to load the entity at all, which fails on the very next line; the “loaded but skipped the override” footgun is structurally impossible.
An action filter or a custom model binder could also hide the override, but routing the version through ASP.NET Core’s request infrastructure couples the optimistic-concurrency mechanism to the HTTP lifecycle. The same DbContext then becomes awkward to use from background jobs or hosted services, because the version-capturing filter only fires on HTTP requests. The helper centralizes the concern at the data layer instead, where it stays useful outside HTTP entry points.
4.4. Adding a new entity
The whole point of the base class plus interceptor is that the next entity is trivial:
public class Promotion : Entity
{
public string Name { get; set; } = "";
public DateTimeOffset StartsAt { get; set; }
public DateTimeOffset EndsAt { get; set; }
}public DbSet<Promotion> Promotions => Set<Promotion>();That’s it. The model-walk in OnModelCreating picks up Promotion and marks its Version as a concurrency token. The interceptor’s Entries<Entity>() filter picks up Promotion entries on every SaveChanges. EF Core emits INSERTs and UPDATEs with Version in the right places automatically. Application code never has to mention Promotion.Version.
4.5. Compared to xmin
Both §3 and §4 remove the manual bump. The trade space:
| xmin (§3) | base class + interceptor (§4) | |
|---|---|---|
| Cross-provider | No (PostgreSQL-only) | Yes (works on SQL Server, SQLite, etc.) |
| Schema column | None (system column) | Real uuid column on every entity |
| Bump source | PostgreSQL on UPDATE | Application interceptor on SaveChanges |
Visible in \d / schema introspection | No | Yes |
| Infrastructure cost | One Fluent line | Base class + interceptor + DI registration |
| Future-proofing | Locked to PostgreSQL | Portable |
For a team committed to PostgreSQL with no realistic chance of switching, xmin is the smaller-surface choice. For a team that values explicitness, multi-provider compatibility, or visible schema artifacts, the interceptor pattern is the right one. Most production systems built around EF Core land on the interceptor pattern for the last reason: the schema is documentation, and a Version uuid column tells the next reader more than an invisible system column does.
The next section turns the optimistic-concurrency story from “detect and report” into “detect, retry, and report only when retry can’t help.”
5. Central retry for concurrency conflicts
EF Core detects optimistic-concurrency conflicts by checking the affected-row count after every UPDATE and DELETE. When the count is zero (and one was expected), the row’s concurrency token didn’t match. Someone else changed the row between the read and the write. EF throws DbUpdateConcurrencyException with the conflicting entries listed in the exception’s .Entries property. Catching the exception is the start of every retry strategy. The harder part is making the retry actually do something different from the failed attempt.
5.1. How EF detects the conflict
The detection mechanism is the SQL behaviour we’ve been watching all along, plus a single check.
When a column is configured as a concurrency token (via IsConcurrencyToken(), [ConcurrencyCheck], IsRowVersion(), or the xmin convention), EF Core includes that column in the WHERE clause of every UPDATE and DELETE for the entity. The database executes the statement and returns an affected-row count. EF compares that count to what it expected (one row per modified entity). On a mismatch, it throws.
Roughly, inside EF’s update pipeline:
var rowsAffected = await command.ExecuteNonQueryAsync(ct);
if (rowsAffected != expectedRowsAffected)
{
throw new DbUpdateConcurrencyException(
message,
entries: failedEntries);
}There is no SELECT to verify, no version comparison in C#. Just: emit the conditional UPDATE, trust the database’s row count, react to the number. That’s why the WHERE clause matters; that’s why setting OriginalValue to the client’s claimed version is the load-bearing line in the controller. The database does the comparison for free; EF reads the count and decides whether to throw.
5.2. Why a naive retry doesn’t work
The first retry shape an engineer tries is: catch the exception, call SaveChangesAsync again.
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
// Try once more.
await db.SaveChangesAsync(ct); // fails the same way
}The retry fails the same way the original did. The reason is the change tracker. After the first SaveChangesAsync throws, the change tracker still holds the entity with the same Original values it had on the first attempt, the values that didn’t match the database. When the second SaveChangesAsync runs, it issues the same UPDATE with the same WHERE clause against a row that has already moved past that version. Zero rows match. Same exception, same place.
For the retry to do anything different, the change tracker has to be brought up to date with the database between attempts. EF Core has a method for exactly this: entry.ReloadAsync(). Calling it on a tracked entry replaces both Original and Current values with the row’s current state. Subsequent SaveChangesAsync calls now compare against the fresh Original, and the application can decide whether to re-apply its changes.
5.3. This is not what IExecutionStrategy retries
EF Core ships with IExecutionStrategy, an extension point used for retrying transient failures. The Npgsql provider’s strategy retries on transport errors (broken connections, timeouts) and a small allowlist of PostgreSQL SQLSTATEs: 40001 (serialization_failure), 40P01 (deadlock_detected). When EnableRetryOnFailure() is configured on the provider, those failures retry transparently and the application doesn’t have to handle them.
DbUpdateConcurrencyException is not on that allowlist, and that’s intentional. The kinds of failures IExecutionStrategy retries are network or scheduling glitches; replaying the same operation is exactly the right thing because the database is fine, the wire wasn’t. A concurrency conflict means the database is fine and the wire was fine; the application’s view of the world is stale. Replaying the same operation against the same stale view fails the same way. Until the change tracker is refreshed, retry has nothing to do.
5.4. The reload-and-recompute wrapper
The retry shape that does work is “reload, recompute, save again.” Wrapped as an extension method on DbContext:
public static class DbContextRetryExtensions
{
public static async Task<TResult> ExecuteWithConcurrencyRetryAsync<TResult>(
this DbContext db,
Func<Task<TResult>> operation,
ConcurrencyRetryPolicy? policy = null,
CancellationToken ct = default)
{
policy ??= new ConcurrencyRetryPolicy();
for (var attempt = 1; ; attempt++)
{
try
{
return await operation();
}
catch (DbUpdateConcurrencyException ex) when (attempt < policy.MaxAttempts)
{
foreach (var entry in ex.Entries)
{
await entry.ReloadAsync(ct);
}
await Task.Delay(JitteredBackoff(policy.InitialBackoff, attempt), ct);
}
}
}
}The catch filter (when (attempt < policy.MaxAttempts)) is the cleanest way to decide “retry or rethrow” without a separate if (...) throw inside the catch body. When attempt == MaxAttempts, the filter is false, the catch doesn’t fire, and the exception propagates to the caller.
The retry refreshes only the conflicting entries, not the whole change tracker. EF hands us exactly those entries in ex.Entries. Reloading them replaces their Original and Current values with the row’s freshly-fetched state. Subsequent FirstOrDefaultAsync or Find calls in the operation delegate return the same tracked instances (with refreshed values), so the next attempt operates on fresh state without the operation knowing that anything happened.
A small ConcurrencyRetryPolicy record carries the knobs:
public record ConcurrencyRetryPolicy
{
public int MaxAttempts { get; init; } = 3;
public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromMilliseconds(50);
}A POST /api/coupons/{id}/redemptions endpoint uses the wrapper:
[HttpPost("{id:guid}/redemptions")]
public async Task<IActionResult> Redeem(Guid id, CancellationToken ct)
{
var outcome = await db.ExecuteWithConcurrencyRetryAsync(async () =>
{
var coupon = await db.Coupons.FirstOrDefaultAsync(c => c.Id == id, ct);
if (coupon is null) return RedeemOutcome.NotFound;
if (coupon.RedemptionsRemaining <= 0) return RedeemOutcome.Exhausted;
coupon.RedemptionsRemaining -= 1;
await db.SaveChangesAsync(ct);
return RedeemOutcome.Ok;
}, ct: ct);
return outcome switch
{
RedeemOutcome.Ok => NoContent(),
RedeemOutcome.NotFound => NotFound(),
RedeemOutcome.Exhausted => UnprocessableEntity(new { error = "Coupon has no redemptions remaining" }),
_ => StatusCode(500),
};
}Three outcomes, mapped cleanly to HTTP. The retry resolves the transient conflicts transparently. When the wrapper exhausts its attempts, DbUpdateConcurrencyException propagates out of the action; §6 turns those into HTTP 409.
Under heavy contention the policy’s MaxAttempts matters. With ten concurrent redemptions on a coupon that has five remaining, the default of three attempts resolves three of them cleanly and leaves the remaining seven hitting the cap. Bumping MaxAttempts higher resolves more, at the cost of longer tail latency. The right number depends on the workload’s contention and the API’s latency budget; what matters is that the knob exists and can be tuned per call site.
5.5. When retry is the right answer
Retry is the right answer when the operation is idempotent under reload: re-reading the entity, recomputing the change, and saving again has the same intent as the first attempt would have had. Decrementing a counter while it’s positive is the canonical example; “set a status if it’s still in the previous state” is another, and most queue-pull-and-mark-processed shapes fit too.
Retry is the wrong answer when the operation is intent-bound to a specific version. The PUT handler from §4 falls into this category. A user editing a coupon for ten seconds and then submitting a full body claims “this is the right state for the version I read.” If the version has moved, retrying against the new version isn’t doing what the user asked; it’s letting the application decide that the new state is close enough. Better to surface the 409 and let the user merge or cancel.
The rule of thumb: if the user should know about the conflict, surface it. If the system can resolve the conflict by reading fresh state and rerunning the action, retry it. The retry wrapper makes the second case cheap; the next section makes the first case clean.
6. Translating concurrency conflicts into HTTP responses
Conflicts that retry can’t resolve become HTTP 409 Conflict responses. A single ASP.NET Core exception filter catches DbUpdateConcurrencyException and translates it into a ProblemDetails JSON body, removing per-action try/catch from every controller.
6.1. The filter
public class ConcurrencyConflictExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not DbUpdateConcurrencyException) return;
var problem = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = "The resource was modified by another caller. Refetch and retry.",
};
context.Result = new ConflictObjectResult(problem);
context.ExceptionHandled = true;
}
}Three lines do the work. The type guard returns early on anything that isn’t a concurrency exception, leaving other exceptions to ASP.NET Core’s default handlers. The ProblemDetails shape (type, title, status, detail) is RFC 9457; ASP.NET Core’s own Conflict() helper returns the same shape, so the filter’s response is indistinguishable from a hand-written return Conflict(problem). The context.ExceptionHandled = true line is the load-bearing one; without it, ASP.NET Core re-throws the exception after the filter returns, which sends both the JSON response and propagates the exception to the developer exception page or 500 handler.
Registered globally:
builder.Services.AddControllers(options =>
{
options.Filters.Add<ConcurrencyConflictExceptionFilter>();
});Every controller action picks the filter up. Combined with the §4 load helper, the Update action that previously had a per-action try/catch and a manual OriginalValue override becomes a clean read-mutate-save:
[HttpPut("{id:guid}")]
public async Task<ActionResult<Coupon>> Update(Guid id, UpdateCouponRequest request, CancellationToken ct)
{
var coupon = await db.LoadForUpdateAsync<Coupon>(id, request.Version, ct);
if (coupon is null) return NotFound();
coupon.Code = request.Code;
coupon.RedemptionsRemaining = request.RedemptionsRemaining;
coupon.Description = request.Description;
coupon.ExpiresAt = request.ExpiresAt;
await db.SaveChangesAsync(ct);
return coupon;
}The same filter catches the redeem endpoint’s exception when the wrapper exhausts its retries: the DbUpdateConcurrencyException propagates out of the action, the filter catches it, the client sees 409.
6.2. What 409 means here
HTTP 409 is the right status when “the request could not be completed due to a conflict with the current state of the target resource” (RFC 9110 § 15.5.10). For optimistic-concurrency conflicts, “current state” is “the row’s version has moved past the value the caller edited against.” The response tells the caller two things: the request didn’t land, and it didn’t land because the caller’s view is stale.
A 409 isn’t a 500 (server fault) and isn’t a 422 (the body parsed but the values were rejected on their own merits). The body parsed, the values would have been valid, only the timing was off. The client knows to refetch the resource and decide what to do.
6.3. What the caller is expected to do
Three reasonable responses, in roughly increasing complexity:
- Refetch and surface to the user. A web form re-pulls the resource, shows the current values alongside the user’s edits, and asks the user how to merge. A CLI prints “the resource changed; rerun after
view”. A mobile app shows a toast and reloads. - Refetch and retry the operation in the application layer. For operations the system can resolve without user input (a queue worker that’s PUTting an updated status, an automation that’s tagging a record), refetching and reapplying within the application is fine. This overlaps with §5’s retry wrapper; the wrapper handles the in-process case, the 409 surface handles the case where the conflict survived all the in-process attempts.
- Refetch, merge, retry. When the application has enough domain knowledge to merge two concurrent edits (counters, set additions, last-write-wins on disjoint fields), the controller can read the fresh row, combine it with the request, and try again. This crosses the line from “conflict resolution” to “domain logic” and belongs at the action level, not in the filter.
The filter handles the contract; the caller’s response is a domain decision.
6.4. The HTTP-native variant: ETag and If-Match
There’s a more HTTP-native way to do all of this. Instead of putting the version in the JSON body, put it in an ETag HTTP header on the GET response and require it back as If-Match on the PUT. The server compares the If-Match value against the row’s current version, returns 412 (Precondition Failed) on mismatch, and never has to round-trip a JSON version field.
The advantage is protocol fluency: HTTP caches, gateways, and clients all understand If-Match. The disadvantage is more plumbing: the application has to generate an ETag from the entity, parse the header on every write, and surface 412 (semantically the right status for failed preconditions but reading as “Precondition Failed” rather than the friendlier “Conflict”). Both shapes work; this post stayed with the in-body version field because it’s lower-friction for callers built around JSON contracts. The If-Match / 412 path is documented at RFC 9110 § 13.1.1 and is the right call for systems already speaking HTTP cache semantics.
6.5. The full picture
The companion project, by the end of this section, contains seven pieces of infrastructure that compose into a working optimistic-concurrency setup:
Entitybase class withIdandVersion.CouponandPromotion, both inheritingEntity. Each gets the concurrency token automatically.OnModelCreatingwalks the model and marks everyEntity-derivedVersionas a concurrency token.ConcurrencyInterceptorbumpsVersionon every saved insert and update, registered as a singleton on the DbContext.LoadForUpdateAsync<T>extension onDbContextfuses the load with the client-version override so write handlers never have to remember it.ExecuteWithConcurrencyRetryAsyncwrapper for the cases where retry-on-conflict is the right answer.ConcurrencyConflictExceptionFiltertranslates exhausted conflicts into HTTP 409 with aProblemDetailsbody.
Seven small pieces, roughly two hundred lines of code. Every entity from here on gets full optimistic-concurrency treatment by inheriting Entity. The two patterns the application chooses between (retry transparently, or surface to the caller) are explicit at the action level and need no infrastructure to switch. The schema stays portable, the controllers stay short and the lost-update bug that opened this post stays impossible by construction.