Skip to main content
ANVISoftware Solutions
Lesson 13 of 17Intermediate18 min

Concurrency Conflicts

By the end of this lesson

Detect and resolve two users editing the same record.

Two people open the same employee record. Both see a salary of 58,000 and a role of Engineer. One changes the salary and saves. A minute later the other changes the role and saves. What happens to the salary?

It depends on how the second save was built, and that is the uncomfortable part. Because EF Core writes only the columns that differ from its snapshot, two users editing different fields of an entity they both loaded in the same context may not collide at all. That is luck, not a design.

In a web application the entity is usually not loaded when the edit begins. The form posts every field back, the code loads the row and copies the posted values over it, or attaches a new instance and marks it modified. Now every column is written, including the ones this user never touched. The other person's change is gone.

That is a lost update. No exception, nothing unusual in the log, and no way to tell afterwards that it happened.

The sequence, with times, because the ordering is the entire problem:

  1. 09:00:00 — Priya opens employee 14

    The form is filled from the current row: salary 58,000, role Engineer.

  2. 09:00:05 — Sam opens employee 14

    Same row, same values. Neither user has any way of knowing about the other.

  3. 09:02:00 — Priya saves a salary of 61,500

    The UPDATE succeeds. The row now holds 61,500 and Engineer.

  4. 09:03:00 — Sam saves a role of Senior Engineer

    Sam's form still carries 58,000 in the salary field, and the save writes it back.

  5. The result

    The row holds 58,000 and Senior Engineer. Priya's change has vanished, both users were told their save succeeded, and nothing in the logs looks wrong. Somebody notices the salary weeks later.

Adding a concurrency token
C#
public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Role { get; set; } = "";
    public decimal AnnualSalary { get; set; }
    public int DepartmentId { get; set; }

    // The concurrency token. The database maintains it; you never assign it.
    [Timestamp]
    public byte[]? RowVersion { get; set; }
}

// The same thing in configuration, which keeps persistence out of the entity:
modelBuilder.Entity<Employee>()
    .Property(e => e.RowVersion)
    .IsRowVersion();
  • rowversion is a SQL Server column type whose value changes automatically every time the row is written. The value means nothing on its own; what matters is that it differs after any change.
  • The attribute is named Timestamp, which is unhelpful, because the column holds no date or time. IsRowVersion in configuration says the same thing more clearly.
  • Other providers have equivalents — PostgreSQL uses its system xmin column — and with any database you can mark an ordinary column as a concurrency token with IsConcurrencyToken and maintain it yourself.
  • The token has to travel with the edit. If a web form round-trips the row version in a hidden field, it must be the value read when the form was rendered. Fetching a fresh one at save time defeats the whole mechanism.
The check, in SQL
SQL
-- Priya saves first
UPDATE [Employees] SET [AnnualSalary] = @p0
OUTPUT INSERTED.[RowVersion]
WHERE [Id] = @p1 AND [RowVersion] = @p2;
-- @p2 = 0x00000000000007D1, the value Priya read
-- 1 row updated, and a new RowVersion is returned

-- Sam saves a minute later, carrying the same row version Sam read
UPDATE [Employees] SET [Role] = @p0, [AnnualSalary] = @p1
OUTPUT INSERTED.[RowVersion]
WHERE [Id] = @p2 AND [RowVersion] = @p3;
-- @p3 = 0x00000000000007D1, no longer the current value
-- 0 rows updated
  • The token is part of the WHERE clause. The UPDATE applies only if the row still looks the way it did when this user read it.
  • Priya's save changed the row, so the database generated a new row version. Sam's WHERE clause now matches nothing.
  • Zero rows updated is the signal. EF Core expected one, got none, and throws DbUpdateConcurrencyException instead of reporting success. The lost update has become an error you can handle, which is the whole mechanism.
  • This is called optimistic concurrency: no locks are taken while the user thinks, and the conflict is detected at the moment of writing. The alternative, holding a lock from read to write, does not survive a user who goes to lunch with a form open.

An exception is progress, not an answer. You still have to decide what happens next, and there are three defensible choices:

Last write wins (client wins)
Overwrite the stored row with what this user submitted. Simple and honest, and it does discard the other edit. The difference from having no token at all is that you chose this, and you can tell the user about it.
Keep the database value (store wins)
Discard this user's change, reload the row, and tell them it moved. Right when the stored value is the more trustworthy one, or when re-entering the edit is cheap.
Merge
Combine field by field. Take the other user's salary, keep this user's role, and ask a person only about fields both of them changed. The most useful option for a form with many fields, and the most work to build.
Handling the exception — a merge, then a retry
C#
// EntityEntry and PropertyValues live in
// Microsoft.EntityFrameworkCore.ChangeTracking.
try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    EntityEntry entry = ex.Entries.Single();
    PropertyValues? stored = await entry.GetDatabaseValuesAsync();

    if (stored is null)
    {
        // Somebody deleted the row. There is nothing to merge into.
        throw new InvalidOperationException("That employee no longer exists.");
    }

    // Merge: this user only meant to change the role, so let the stored
    // salary stand and keep the role they typed.
    entry.CurrentValues[nameof(Employee.AnnualSalary)] =
        stored[nameof(Employee.AnnualSalary)];

    // Accept the stored row as the new baseline so the retry passes the check.
    entry.OriginalValues.SetValues(stored);

    await context.SaveChangesAsync();
}
  • ex.Entries holds the entities whose save failed. For a single-row edit there is exactly one.
  • GetDatabaseValuesAsync re-reads the row as it stands now. A null result means it was deleted rather than changed, which needs its own answer — usually telling the user, because there is nothing left to write to.
  • The merge line is the interesting one: the field this user did not intend to change is taken from the database, so the other person's edit survives.
  • Setting OriginalValues to the stored values updates the snapshot, including the row version. The second SaveChanges therefore sends a WHERE clause that matches, and it succeeds.
  • For store wins, call entry.ReloadAsync() instead and ask the user to try again against the current values. For client wins, set OriginalValues and retry without touching CurrentValues.
  • Retry once. If you loop, cap the attempts — a busy row can keep moving underneath you, and an uncapped retry turns a conflict into a hot loop.

Summary

  • Without a concurrency check, a later save can silently overwrite an earlier one and nobody is told
  • A rowversion token goes into the WHERE clause, so a changed row no longer matches
  • Zero rows affected becomes DbUpdateConcurrencyException, which is a conflict you can handle
  • Three resolutions: last write wins, keep the database value, or merge field by field
  • Transactions do not help here, because the two edits happen in separate requests

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Reproduce a lost update, then stop it

Using two separate contexts, load the same Employee in both. Change the salary in the first and save. Change the role in the second and save.

Now add a rowversion token, run the same sequence again, and observe the exception.

For the second save to lose data in the first run, it has to write the salary column too. What do you need to do to make that happen, and why is that closer to a real web request than the version that gets away with it?

Show solution

With both entities loaded and only one property changed in each context, EF Core writes only that property, and the two saves do not collide. The naive version looks safe.

Mark the whole entity modified — with context.Update, or by copying a DTO over every property, which is what a posted form does — and every column is written. The second save now writes a stale salary, and the first user's change is gone.

That is why the web case matters. The disconnected pattern, where the values come from outside and the entity is not the one that was read, is both the most common shape in real applications and the one where lost updates are certain rather than possible.

With the token in place the second save raises DbUpdateConcurrencyException, and you get to decide what happens instead of finding out later.

C#
using AppDbContext first = factory.CreateDbContext();
using AppDbContext second = factory.CreateDbContext();

Employee a = await first.Employees.SingleAsync(e => e.Id == 14);
Employee b = await second.Employees.SingleAsync(e => e.Id == 14);

a.AnnualSalary = 61_500m;
await first.SaveChangesAsync();

b.Role = "Senior Engineer";
second.Update(b);                     // marks every property modified
await second.SaveChangesAsync();      // throws once RowVersion exists

Think about it

Pick a strategy per field

One screen edits a product's Description and its UnitsInStock.

Which resolution strategy suits each field, and is there a change that would make the stock conflict disappear rather than be resolved?

Show solution

For Description, ask the person. Two people rewriting the same text is a genuine editorial conflict and no rule resolves it well; showing both versions is more honest than picking one.

For UnitsInStock, none of the three strategies is really right, because the field is not a value somebody chose — it is the result of arithmetic. Last write wins loses a despatch; store wins loses a delivery.

The better answer is to stop reading and writing the number at all. A relative update, sending stock equal to stock minus two as a single statement, has no conflict to resolve because it never carried a stale value. Do it in one statement, not in memory.

The general lesson: concurrency strategy is a per-field question, and for counters the best strategy is to reshape the write so the conflict cannot occur.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

When EF Core throws DbUpdateConcurrencyException, what did the database actually report?
No concurrency token is configured. Two users post the whole employee form a minute apart, and the code copies every posted field onto the entity before saving. What happens?

Saved in this browser only.