EF Core optimistic concurrency: preventing lost updates with concurrency tokens (C#)

EF Core Interview Q&A November 13, 2026 csharp

By default: last write wins.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ======================================================================
// Q: Two users edit the same product at the same time. What happens and how do
// you prevent it?
//
// By default: last write wins. The second user overwrites the first user's
// change without knowing it (lost update). Solution: optimistic concurrency with
// a concurrency token.
// EF Core puts the token in the WHERE clause. If it changed in the meantime, 0
// rows are affected and it throws DbUpdateConcurrencyException.
// In an API: the version goes to the client (ETag / version field) and comes
// back. On conflict: 409 Conflict (or 412 with If-Match), and the user reloads.
// In a background job: reload and retry (or merge per field).
// ======================================================================

// SQL Server: rowversion that changes automatically on every UPDATE
public class Product
{
    public int Id { get; set; }
    public decimal ListPrice { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = [];
}

// PostgreSQL (Npgsql): the xmin system column as the token, no extra column
public uint Version { get; set; }
modelBuilder.Entity<Product>().Property(p => p.Version).IsRowVersion();

// Or for specific fields only
[ConcurrencyCheck] public decimal ListPrice { get; set; }

// PUT /products/{sku}: the version the client saw becomes the "original value"
var product = await db.Products.SingleAsync(p => p.Sku == sku, ct);
db.Entry(product).Property(p => p.Version).OriginalValue = request.Version;
product.ListPrice = request.Price;
try
{
    await db.SaveChangesAsync(ct);
    return Results.NoContent();
}
catch (DbUpdateConcurrencyException)
{
    return Results.Conflict("The product was changed by someone else. Reload and retry.");
}

// Background job: reload and retry on a concurrency conflict
for (var attempt = 1; ; attempt++)
{
    try
    {
        product.Stock += delta;
        await db.SaveChangesAsync(ct);
        break;
    }
    catch (DbUpdateConcurrencyException ex) when (attempt < 3)
    {
        var entry = ex.Entries.Single();
        var current = await entry.GetDatabaseValuesAsync(ct)
                      ?? throw new NotFoundException();       // deleted in the meantime
        entry.OriginalValues.SetValues(current);   // new token
        entry.CurrentValues.SetValues(current);    // "database wins", then we reapply
    }
}