Skip to content
Preptima
mediumConceptCodingMidSeniorStaff

Two users edit the same record and both save. What does EF Core do about it?

Nothing, unless you configured a concurrency token. With one, EF Core adds the original value to the UPDATE's WHERE clause and throws DbUpdateConcurrencyException when no row matches, which hands you a conflict to resolve rather than a lost update.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Whether the candidate knows EF Core writes only the properties it tracked as modified, so an unguarded lost update is column-level
  • Does the answer identify the affected-row count as the detection mechanism rather than a read-then-compare
  • That resolving the conflict is treated as a product decision instead of a blind retry loop
  • Whether the provider-specific token is named, rowversion on SQL Server or xmin on PostgreSQL
  • Does the candidate know which EF Core operations skip the token altogether

Answer

With no token, the second save simply wins

By default there is no conflict, because nothing is looking for one. Both requests load the row, both modify their tracked entity, and both issue an UPDATE keyed on the primary key. The second one succeeds and overwrites whatever the first one wrote. Nobody is told.

There is a nuance here that most candidates miss and that changes how often the problem bites. EF Core's change tracker compares each property against its original value and generates an UPDATE that sets only the properties that actually changed. So if user A edits the status and user B edits the shipping address, the two updates touch different columns and neither loses anything — the row ends up with both edits. The lost update happens when the two users changed the same property, or when the entity was attached in a way that marked every property modified regardless. That second case is the common one in web applications, because Update(entity) on a disconnected object marks the whole entity modified, which turns every concurrent save on that row into a full overwrite.

A concurrency token moves the check into the WHERE clause

Configure a token and EF Core adds its original value to the UPDATE predicate: match on the key and on the token as it was when you read the row. The database then does the detection for you atomically, because the row either still has that token value or it does not. EF Core reads the affected-row count, and a count of zero means somebody changed or deleted the row since your read, so it throws DbUpdateConcurrencyException.

The token needs to change on every write and it should be the database's job to change it, so nobody can forget. On SQL Server that is a rowversion column, which the server increments on any update to the row; you get it from [Timestamp] or IsRowVersion(). On PostgreSQL, Npgsql can use the system column xmin through UseXminAsConcurrencyToken(), which requires no schema change at all. Any ordinary property can be made a token with IsConcurrencyToken(), which is how you express "this update is only valid if the status is still Pending".

public class Order
{
    public int Id { get; set; }
    public string Status { get; set; } = "";

    [Timestamp]                       // SQL Server bumps this on every update to the row
    public byte[]? RowVersion { get; set; }
}

try
{
    await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    var entry = ex.Entries.Single();
    var stored = await entry.GetDatabaseValuesAsync(); // null means the row was deleted
    // Decide here. Do not simply loop.
}

Trace two requests against an order whose RowVersion reads as 0x...05. Both load it and both hold 05 as the original value. A saves first: the WHERE matches, the row is written, and the server moves the token to 06. B saves second: the predicate still asks for 05, no row matches, the affected count is zero, and B gets the exception instead of silently erasing A's change. Note what B does not get — B's own request never read 06, so B's user interface is showing data that is now stale, and that is the real problem to solve.

Resolving a conflict is a product decision

The exception hands you three values: the ones your user submitted, the originals you read, and, after GetDatabaseValues, the ones currently stored. What you do with them is not a technical default.

Store wins means discarding the user's edit and reloading, which is safe and infuriating for anyone who just typed a paragraph. Client wins means overwriting, which you implement by setting the entry's original values to the stored values so the retry's predicate matches — and which is exactly the lost update you added the token to prevent, now chosen deliberately rather than by accident. Merge means resolving property by property, keeping the stored value where the user did not touch it and the submitted value where they did, and asking the user only about the genuine collisions.

The interesting answer names the choice per field rather than per entity. A quantity that two people incremented should probably be re-read and re-applied rather than overwritten, and an idempotent status transition can often just be retried. A free-text note should never be silently discarded. And if GetDatabaseValues returns null the row is gone, which is a different conversation entirely and needs its own branch.

Retrying without re-creating the conflict

A retry is only correct if it re-reads. Calling SaveChanges again on the same context with the same original values reproduces the same failing predicate forever, so the loop either spins or, if you patched the original values to make it pass, quietly performs the overwrite. The safe shape is to abandon the attempt, start a fresh context and a fresh read, re-apply the intent, and try again with a bounded number of attempts. That is also why the resolution logic wants to live in one place rather than in every handler: it is easy to write a retry that looks defensive and is actually a lost update with extra steps.

Where the token is not consulted at all

Two paths bypass everything above, and knowing them is what separates a working knowledge from a real one. ExecuteUpdate and ExecuteDelete, added in EF Core 7, translate straight to SQL without going through the change tracker, so they do not include the concurrency token and cannot raise DbUpdateConcurrencyException. They are the right tool for a bulk state change and the wrong tool for a user-initiated edit, and mixing the two in one codebase gives you a table that is protected on some write paths and not on others.

The other is raw SQL, migrations and anything else that writes outside the model. Optimistic concurrency in EF Core is a convention enforced by the code that generates the statement, not a constraint the database holds. If the invariant genuinely must never be violated — no overselling the last unit of stock, no two bookings for one seat — then it needs a check the database itself enforces, a unique index or a constraint, because a token only defends the writes that went through EF Core.

A concurrency token converts a lost update into an exception by putting the original value in the WHERE clause and reading the affected row count, which means the hard part is not detection but deciding, per field, whose version of the truth survives.

Likely follow-ups

  • The user should not lose the fifteen minutes of edits they just typed. What does resolution look like on their screen?
  • Why is a DateTime LastModified column a weak concurrency token, and what would make it adequate?
  • When would you take a pessimistic lock instead, and what does that cost under load?
  • Where does the token live while the entity is edited in a disconnected web form and posted back?

Related questions

Further reading

ef-coreconcurrencyoptimistic-lockingchange-trackingdotnet