Handling Concurrency Conflicts

Optimistic concurrency with a [Timestamp]/rowversion column and catching DbUpdateConcurrencyException.

The lost update problem

Imagine two users load the same Product in a form, both edit it, and both click save a few seconds apart. Without any concurrency protection, the second SaveChangesAsync() simply overwrites whatever the first one wrote — the first user's change is silently lost, with no error and no indication anything went wrong. EF Core's answer to this is optimistic concurrency: instead of locking the row for the whole time a user might be looking at an edit form (impractical over an HTTP request/response cycle), the database checks at save time whether the row still looks the way it did when it was originally read, and rejects the update if not.

Optimistic concurrency with a rowversion column

The standard way to opt an entity into this check is a dedicated concurrency token column, marked with [Timestamp]:

C#
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }

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

On SQL Server, [Timestamp] maps to a rowversion column — the database automatically generates a new value for it on every write to that row, regardless of which column actually changed. EF Core includes the last-known RowVersion value in the WHERE clause of every generated UPDATE, not just the primary key:

SQL
UPDATE Products SET Name = @p0, Price = @p1
WHERE Id = @p2 AND RowVersion = @p3;

If another transaction already updated (and thus changed the RowVersion of) that row since it was read, this UPDATE matches zero rows — and EF Core detects that mismatch and raises DbUpdateConcurrencyException instead of silently doing nothing.

Catching DbUpdateConcurrencyException

C#
app.MapPut("/products/{id:int}", async (int id, Product input, AppDbContext db) =>
{
    var product = await db.Products.FindAsync(id);
    if (product is null) return Results.NotFound();

    // The client must send back the RowVersion it originally loaded, so EF Core
    // knows which version this update is actually based on.
    db.Entry(product).OriginalValues["RowVersion"] = input.RowVersion;
    product.Name = input.Name;
    product.Price = input.Price;

    try
    {
        await db.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var entry = ex.Entries.Single();
        var databaseValues = await entry.GetDatabaseValuesAsync();

        if (databaseValues is null)
        {
            return Results.NotFound(new { message = "The product was deleted by another user." });
        }

        return Results.Conflict(new
        {
            message = "This product was modified by someone else. Reload and try again.",
            currentValues = databaseValues.ToObject(),
        });
    }

    return Results.Ok(product);
});

ex.Entries lists every entity involved in the conflict (usually just one); GetDatabaseValuesAsync() fetches what's actually in the database right now, which is null specifically when the row was deleted rather than merely modified — letting the handler tell those two cases apart and respond differently.

Resolution strategies

Catching the exception is only step one — a real application has to decide what happens next. Three common strategies:

  • Client wins — overwrite the database's current values with whatever the client submitted, then retry the save. Appropriate when the current user's edit should simply take precedence.
  • Store wins — discard the client's pending changes and reload the current database values, effectively telling the user "someone beat you to it, here's the current state." Appropriate when data integrity matters more than any one user's edit.
  • Merge — the most user-friendly and the most work: show the user exactly which fields conflict (their submitted value vs. the current database value) and let them decide field by field, common in collaborative editing tools.

Optimistic vs pessimistic concurrency

Optimistic concurrency Pessimistic concurrency
Locking None while the user is editing Row locked for the duration of the transaction
Conflict handling Detected at save time, resolved after the fact Prevented upfront by blocking other writers
Fits well with Web apps (long "think time" between reading and saving) Short, guaranteed-fast transactions
EF Core support Built in via [Timestamp]/concurrency tokens Possible, but requires explicit transactions and locking hints — much less common in EF Core code

Common mistakes

  • Forgetting to send the originally-loaded RowVersion value back from the client on update — without it, EF Core has nothing to compare against, and the concurrency check silently never triggers.
  • Assuming DbUpdateConcurrencyException always means "the record doesn't exist" — it means the row changed (or was deleted) since it was loaded; checking whether GetDatabaseValuesAsync() returns null is what actually distinguishes "deleted" from "modified by someone else."
  • Catching the exception and picking a resolution strategy silently, with no way for the end user to know a conflict happened at all — a real UI needs to surface the conflict meaningfully, not just resolve it invisibly and hope it was the right call.

Interview questions

Q: How does EF Core detect an optimistic concurrency conflict? Via a concurrency token column — typically a [Timestamp]/rowversion column that the database automatically changes on every write to that row. EF Core includes the last-known token value in the generated UPDATE's WHERE clause; if another transaction already changed the row, the value no longer matches, the UPDATE affects zero rows, and EF Core raises DbUpdateConcurrencyException.

Q: What's the difference between optimistic and pessimistic concurrency, and why does EF Core default to the former? Pessimistic concurrency locks a row for the duration of a transaction, preventing conflicts upfront; optimistic concurrency takes no lock and instead detects a conflict at save time. EF Core defaults to optimistic concurrency because holding a database lock for as long as a user might spend editing a web form (seconds to minutes) is impractical — optimistic concurrency only pays a cost when a real conflict actually happens.

Q: If GetDatabaseValuesAsync() returns null inside a DbUpdateConcurrencyException handler, what does that mean? It means the row no longer exists in the database at all — it was deleted by another transaction after this entity was loaded, as opposed to merely being modified (in which case GetDatabaseValuesAsync() would return the current, changed values instead of null).