// Data & Databases · 09
Optimistic Concurrency
Detect conflicting edits at save time instead of locking up front.
How it fits together
- User A creates Order row (1 v7 to v8)
- User B uses / calls Order row (2 WHERE v = 7)
- Order row fails / error path 412 Precondition (3 0 rows)
Spot it
- One user's save silently overwrites another's
- Edits happen in long-lived forms
- Conflicts are rare but costly when they happen
It costs
No locks held, but callers must handle rejected saves.
In code
public sealed class Order{ public Guid Id { get; init; } public string Status { get; set; } = ""; [ConcurrencyCheck] public int Version { get; set; }}public sealed class OrderHandler(AppDbContext db){ public async Task<IResult> Update(Guid id, int ifMatch, string status, CancellationToken ct) { var order = await db.Orders.FindAsync([id], ct) ?? throw new KeyNotFoundException(); var entry = db.Entry(order); entry.Property(o => o.Version).OriginalValue = ifMatch; order.Status = status; order.Version = ifMatch + 1; try { await db.SaveChangesAsync(ct); return Results.Ok(order); } catch (DbUpdateConcurrencyException) { await entry.ReloadAsync(ct); return Results.Json(order, statusCode: StatusCodes.Status412PreconditionFailed); } }}Go deeperThe full explanation and what to ask for · 1 min readExpandCollapse
Each row carries a version. An update says WHERE id = @id AND version = @expected; if no row changes, someone else got there first. Nothing is locked while the user is thinking, which is why it suits web forms and APIs.
SQL Server has a row version column, bumped automatically on every update. PostgreSQL has the xmin system column, which many ORMs can use as a concurrency token, or you can increment an integer version yourself. Over HTTP the version travels as an ETag and comes back in If-Match.
The classic mistake is catching the conflict and retrying the same write, which just overwrites again. Re-read, re-apply the intent, or ask the user. When conflicts are frequent, pessimistic locking (SELECT ... FOR UPDATE, or an update lock hint) or a single atomic UPDATE beats endless retries.
Ask for it
- lost update
- concurrency token
- version column
- ETag / If-Match
- conflict exception
Add optimistic concurrency to Order: a version column (a row version column on SQL Server, xmin on PostgreSQL) as the concurrency token, returned as an ETag. Require If-Match on PUT and map the conflict error to 412 with the current state.