← All articles

When Two People Edit the Same Record

Optimistic concurrency on any database, so the second save cannot silently win

When two people save the same record, the second save is refused instead of quietly erasing the first, whichever database the application runs on. The second user reloads and re-applies their change.
When Two People Edit the Same Record

Two colleagues open the same customer record. One corrects the phone number, the other adds a delivery note, and both press Save within the same minute. Both get a green "saved". The phone number is gone, and nobody finds out until the customer calls.

Nothing failed. Each PUT carried the whole record as its sender had read it, and the second one wrote its copy over the first: last write wins, 200 OK. Every API that maps a request body onto a row has this behavior by default.

The fix has been known for decades: give every row a version stamp that the server changes on every write. A client reads the stamp with the record and sends it back with its save; a save whose stamp no longer matches the row is refused, and the client reloads and tries again. The pattern is called optimistic concurrency, because nobody locks anything — the second writer simply finds out. It is opt-in per entity: a record one person edits can keep last-write-wins, and the stamp earns its column where several people edit the same rows, or where a stale write must not win.

Universal or Regira? The version stamp is the pattern, and EF Core has the building blocks: [Timestamp] on SQL Server's rowversion, [ConcurrencyCheck] on any column. What Regira Entities adds is a marker that works on every provider (the server mints the stamp, so SQLite, which has no row version, gets it too), the primer that moves it, the check that the DTOs actually carry it, and a 409 whose title tells the client what to do.

Two clients read order 1 and both hold token A. The first client saves with A; the server writes the row, mints token B and returns it. The second client saves with A: the row now holds B, so the write matches no row and answers 409 Concurrency conflict. The client reloads, receives B, re-applies its edit and saves with B, which succeeds and mints C.

Step 1: one interface on the entity

using Regira.Entities.Attributes;
using Regira.Entities.Models.Abstractions;

public class Order : IEntityWithSerial, IHasTimestamps, IHasConcurrencyToken
{
    public int Id { get; set; }
    [ServerOwned] public string? Number { get; set; }   // not on the input DTO: kept on update
    public string? CustomerName { get; set; }
    public OrderStatus Status { get; set; }
    public string? Notes { get; set; }

    public Guid ConcurrencyToken { get; set; }   // no initializer - see step 2

    public DateTime Created { get; set; }
    public DateTime? LastModified { get; set; }
}

Nothing else goes on the entity, provided the registration has UseDefaults(), as every post in this series has assumed:

builder.Services.AddControllers();
builder.Services.ConfigureDefaultJsonOptions(); // enums as strings, and the filter that answers 409

builder.Services
    .UseEntities<AppDbContext>(options =>
    {
        options.UseDefaults();        // declares the token + registers the primer that mints it
        options.UseMapsterMapping();
    })
    .AddOrders();

Behind that line, the context's options declare ConcurrencyToken an EF Core concurrency token when the model is built, so OnModelCreating stays as it is; and HasConcurrencyTokenDbPrimer mints a Guid on insert when the row carries none, and a new one on every update — a soft delete and a write through the raw DbContext included. Adding the interface to an existing entity adds a column, so it needs a migration like any other.

Step 2: the token travels on both DTOs

The write path compares the stamp the client sent with the row. That only works if the client received the stamp and can send it back, so it goes on the read DTO and on the input DTO, without an initializer:

public class OrderDto
{
    public int Id { get; set; }
    // ...
    public Guid ConcurrencyToken { get; set; }
}

public class OrderInputDto
{
    public int Id { get; set; }
    public string? CustomerName { get; set; }
    public OrderStatus Status { get; set; }
    public string? Notes { get; set; }
    public Guid ConcurrencyToken { get; set; }   // sent back exactly as it was read
}

Startup validation reads the DTOs and reports the shapes that break the check before the first request does. The three you are most likely to write:

  • A DTO without the property — the client never sees the token, or can never send it back, and every write is last-write-wins again. A warning.
  • An initializer on the entity's token (= Guid.NewGuid()) with no input-DTO property to overwrite it — the controller maps a fresh entity per request, so every PUT carries a token the row never held and answers 409 for everyone. An error.
  • [VersionStamp] on a property the model does not treat as a token — nothing is compared. A warning.

Step 3: the 409, and what the client does with it

Run the sample (dotnet run, .NET 10 SDK, SQLite created and seeded on first start) and play both colleagues from one terminal:

URL=http://localhost:5340

# both clients read order 1 and hold the same token (A)
A=$(curl -s $URL/orders/1 | grep -o '"concurrencyToken":"[^"]*"' | cut -d'"' -f4)

# the first client saves with A: 200, and the response carries a new token (B)
curl -s -X PUT $URL/orders/1 -H "Content-Type: application/json" \
  -d '{"id":1,"customerName":"Acme Tools","status":"Confirmed","notes":"Ring twice.","concurrencyToken":"'$A'"}'

# the second client saves with A: the row holds B now, so nothing matches
curl -s -i -X PUT $URL/orders/1 -H "Content-Type: application/json" \
  -d '{"id":1,"customerName":"Acme Tools","status":"Shipped","notes":"","concurrencyToken":"'$A'"}'

The second save writes nothing and answers:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8

{"title":"Concurrency conflict","status":409,"detail":"The record was changed or removed since it was read. Reload it and try again."}

The title matters: a Regira Entities API answers 409 for a refused delete too — a row that is still referenced — under the title Conflict, and a rule a validator refuses, on a save or a delete, is a 400 with the reasons per field. A client can branch on status and title without parsing prose.

What the client does next is the reload: GET the row again, which brings the current token and the other colleague's change, let the user re-apply their edit on top, and save with the new token. In a form built on useForm, the response of a successful save is already written back into both the working copy and the pristine one — Edit Forms That Survive Reality explains why — so the token the next save sends is always the last one the server issued.

What each write is checked against

The token is only compared where a client could have read it; the rest of the write surface behaves as before:

Write Checked against
PUT carrying the token the client's token — a stale one answers 409
PUT without it (left out, or the zero Guid) nothing the client read: the write goes through, and only a write racing it is caught. The empty value never overwrites the stored token; the primer mints a new one as usual
PATCH the token in the body when it carries one; otherwise the stored row's, so only a write racing it is caught
DELETE /orders/1 no token reaches it: the endpoint loads the row and deletes against the token it just read, so only a write racing the delete is caught. A stub delete of your own, Remove(new Order { Id = 1 }), works the same way
Your own code on the raw DbContext the token the entity was loaded with. Copying the client's value onto a tracked entity changes its current value only; set the original: db.Entry(order).Property(x => x.ConcurrencyToken).OriginalValue = dto.ConcurrencyToken
Owned children synced by Related() each child's own token, when it declares one; one stale child fails the save

The second row is a feature, not a gap. An import, a nightly job or an admin tool that must always win simply leaves the token out. A client that must always prove what it read gets the opposite: [VersionStamp(Required = true)] on the property refuses a PUT without the token with a 400 naming ConcurrencyToken as the field, before anything is written; an insert has nothing to prove and goes through.

Three setups you are likely to have

  • SQL Server with rowversion already in place. Keep it: [Timestamp] public byte[]? RowVersion { get; set; } is a version stamp the database moves; put it on both DTOs, as in step 2, and the same 409 comes out. The marker is for the day the same code has to run on SQLite in tests or on PostgreSQL in production.
  • A token of your own — [ConcurrencyCheck, VersionStamp] public Guid Version { get; set; } plus a primer that re-mints it on every write (HasConcurrencyTokenDbPrimer is the one to copy). Without the attribute, an application-owned token is only treated as a stamp on writes where the server happens to change it.
  • A data column as the token — [ConcurrencyCheck] public string? LastName { get; set; }, no attribute, nothing moves it. The client's value is the edit itself, so it is written as sent and compared with the stored row: only a write racing the save is caught. Fine for a field two people rarely edit together; not a substitute for a stamp.

Why not lock the row? A pessimistic lock has to be taken when the form opens and released when it closes, and a browser tab closes without saying so. The stamp costs one column and one WHERE clause, needs no session state, and the only user who pays is the one whose save would have destroyed someone else's.

Docs: Regira Entities — concurrency. The sample runs on the free tier, without a license key.

Versions: IHasConcurrencyToken, [VersionStamp], EntityConcurrencyException and the 409 mapping arrived in 6.3.0, validators in 6.5.0; the sample pins Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.4.0.

Built with

Regira.Entities

Preppers, primers and the declarations that keep a PUT from blanking a field are part of Regira Entities.

What Regira Entities covers Entities documentation Free tier & licensing