Skip to content
Katabench
Try free
7 min read The Katabench team

Optimistic concurrency in EF Core: stop silent overwrites

Two users edit the same row and the last save silently wins. Use EF Core concurrency tokens, inspect the generated UPDATE, handle DbUpdateConcurrencyException, and test the race with two DbContexts.

Two support agents open the same customer record. Ana changes the billing address. Ben changes the credit limit. Ana saves first. Ben saves thirty seconds later from a screen that still contains the old address.

If the update sends every field back to the database, Ben's save can restore the old address while changing the limit. Both requests return success. No exception is logged. The data is simply wrong.

This is the lost-update problem. Optimistic concurrency in EF Core turns that silent overwrite into an explicit conflict your application can resolve.

Put the version in the WHERE clause

A concurrency token is a value EF Core reads with the entity and includes in the eventual update. On SQL Server, a rowversion column is the simplest whole-row token:

public sealed class Customer
{
    public Guid Id { get; init; }
    public required string BillingAddress { get; set; }
    public decimal CreditLimit { get; set; }

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

The fluent equivalent is explicit about the provider behavior:

modelBuilder.Entity<Customer>()
    .Property(x => x.Version)
    .IsRowVersion();

When EF Core loads the customer, it remembers the original version. The generated update is conceptually:

UPDATE customers
SET billing_address = @address,
    credit_limit = @limit
WHERE id = @id
  AND version = @originalVersion;

If nobody changed the row, the version still matches and one row is updated. If another request saved first, the predicate matches zero rows. EF Core expected one, sees zero, and throws DbUpdateConcurrencyException.

Editor A

reads v7

UPDATE ... WHERE version = 7

1 row updated → v8

Editor B

reads v7

UPDATE ... WHERE version = 7

0 rows → conflict

Both editors started from version 7. The version predicate lets the first save win and turns the second save into an explicit decision instead of a silent overwrite.

A concurrency token turns "last writer wins" from an invisible accident into a decision the application has to make in daylight.

rowversion is SQL Server-specific. Other providers expose different database-generated tokens; you can also manage a GUID or numeric version in application code and configure it with .IsConcurrencyToken(). The invariant is the same: the update must prove it is based on the version the writer originally read.

A token without a conflict policy is unfinished

Adding [Timestamp] is only detection. The application still needs to decide what a conflict means. There are three common policies:

  • Database wins: discard the attempted changes, reload, and tell the user the record changed.
  • Client wins: refresh the original version and retry the attempted values, deliberately overwriting the intervening change.
  • Merge: compare original, attempted, and current database values, then combine non-conflicting fields or ask a person to choose.

For an interactive edit form, a clear conflict response is usually safer than an automatic retry:

try
{
    await db.SaveChangesAsync(cancellationToken);
    return Results.NoContent();
}
catch (DbUpdateConcurrencyException)
{
    var current = await db.Customers
        .AsNoTracking()
        .SingleOrDefaultAsync(x => x.Id == command.Id, cancellationToken);

    return current is null
        ? Results.NotFound()
        : Results.Conflict(new
        {
            message = "This customer changed after you opened it.",
            current
        });
}

Blindly retrying the same entity is often just "client wins" with the policy hidden. It may be reasonable for an automated operation that can recompute its result from fresh state. It is risky for a stale form because the retry silently erases someone else's accepted work.

Carry the version through the API

The token has to survive the round trip from read to write. Returning it in the response body works, but HTTP already has a vocabulary for versions: ETag and If-Match.

HTTP/1.1 200 OK
ETag: "B7m2vQ=="
Content-Type: application/json

{ "id": "...", "billingAddress": "...", "creditLimit": 5000 }

The client sends that exact tag with the update:

PUT /customers/8c1...
If-Match: "B7m2vQ=="

The endpoint decodes the tag and sets it as the entity's original concurrency value before saving. A mismatch can become 412 Precondition Failed, which accurately says the request's precondition is no longer true. If your API uses a body field instead, 409 Conflict is a common and readable choice. Consistency matters more than arguing that only one status code is ever correct.

Do not expose a database timestamp as a human timestamp. A concurrency version is an opaque token; clients should echo it, not parse or increment it.

Watch for disconnected updates

Web APIs rarely keep one tracked entity alive between the GET and PUT requests. The write request usually receives a DTO, creates or attaches an entity, and marks properties as modified. That is where the concurrency token is often lost: setting the current Version value is not the same as telling EF Core which version the client originally read.

Set the original value explicitly when attaching a disconnected entity:

var customer = new Customer
{
    Id = command.Id,
    BillingAddress = command.BillingAddress,
    CreditLimit = command.CreditLimit
};

db.Attach(customer);
db.Entry(customer).Property(x => x.BillingAddress).IsModified = true;
db.Entry(customer).Property(x => x.CreditLimit).IsModified = true;
db.Entry(customer).Property(x => x.Version).OriginalValue = command.Version;

await db.SaveChangesAsync(cancellationToken);

Now command.Version becomes the predicate in the UPDATE. Do not copy every DTO property onto a tracked entity by habit. Updating only the fields the command owns reduces accidental overwrites, and it makes the conflict policy easier to explain.

Be equally careful with mapping libraries. A convenient Map(dto, entity) call can overwrite the tracked token or mark it modified. Test the generated SQL and the stale-write path after changing mapping configuration, not just the successful update.

Concurrency conflicts should also be observable without becoming error noise. Count them by endpoint and entity type, then alert on a sustained change in rate rather than every exception. A few conflicts prove the protection is working. A spike can reveal a UI holding stale forms too long, an automated job retrying the wrong state, or an aggregate boundary that forces unrelated edits to fight each other.

Protect the right boundary

Whole-row concurrency is a conservative default: any change conflicts with any other change. That is appropriate when fields participate in the same invariant. If Ana changes an address while Ben adds an internal note in a separate table, those operations may not need to collide.

Application-managed tokens can be regenerated only when protected properties change, but selective tokens add policy complexity. Start by asking what must be decided together. The answer may point to a smaller aggregate, a separate entity, or a command that updates only the field it owns.

Concurrency tokens also do not replace database constraints. Two requests inserting the same username do not produce an EF concurrency exception because neither updated an existing versioned row. A unique index enforces that invariant. Likewise, balance transfers and inventory reservations may need transactions, atomic SQL, or domain-specific locking beyond last-write detection.

Test with two real contexts

One DbContext cannot reproduce the race honestly because its change tracker holds one view of the entity. Load the same row through two contexts, save through the first, then assert the second fails:

[Fact]
public async Task Stale_customer_update_is_rejected()
{
    await using var first = CreateDbContext();
    await using var second = CreateDbContext();

    var ana = await first.Customers.SingleAsync(x => x.Id == customerId);
    var ben = await second.Customers.SingleAsync(x => x.Id == customerId);

    ana.BillingAddress = "12 New Street";
    await first.SaveChangesAsync();

    ben.CreditLimit = 7_500m;
    await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
        () => second.SaveChangesAsync());
}

Run this against the real relational provider. The behavior depends on generated SQL, affected-row counts, and token generation, all things an in-memory substitute can hide. Microsoft's EF Core concurrency guide shows the same mechanism and the three value sets available during resolution: the attempted current values, the originally read values, and the values now in the database.

The broader database habit is to inspect the statement rather than trusting the LINQ. Just as N+1 queries hide behind innocent property access and query plans hide behind correct results, lost updates hide behind a successful SaveChanges. Katabench's database and EF Core articles build that translation instinct, while the database track makes the generated work part of the grade.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.