Two support agents open the same order in your ASP.NET Core app, change different fields, and both hit Save. Without a concurrency token, SQL Server happily applies both UPDATEs. The second writer overwrites the first with no error, no log line, and a support ticket three days later.

On shared or VPS SQL Server backends this shows up constantly: admin grids, inventory counts, ticket status, and profile editors. A rowversion (timestamp) column plus EF Core 10’s concurrency token mapping turns that silent loss into a catchable failure you can resolve in the UI.

#Add rowversion at the table

rowversion is an 8-byte binary counter maintained by the engine. Every UPDATE to the row changes it. You never assign it from the app. On SQL Server 2022 and 2025 the type behaves the same; use it on any table users edit concurrently.

tsql
ALTER TABLE dbo.Orders
ADD RowVer rowversion NOT NULL;

-- Optional: confirm the column is non-nullable binary(8)
SELECT c.name, t.name AS type_name, c.is_nullable
FROM sys.columns c
JOIN sys.types t ON c.user_type_id = t.user_type_id
WHERE c.object_id = OBJECT_ID(N'dbo.Orders')
  AND c.name = N'RowVer';

Do not put rowversion in a filtered index key or treat it as a business “version number” for display. It is opaque. If you need a human-readable revision, keep a separate int column you control.

#Map the token in EF Core 10

Map the column as a concurrency token so every SaveChanges UPDATE includes it in the WHERE clause. If zero rows are affected, EF throws DbUpdateConcurrencyException instead of succeeding quietly.

csharp
public class Order
{
    public int Id { get; set; }
    public string Status { get; set; } = "";
    public int Quantity { get; set; }
    public byte[] RowVer { get; set; } = Array.Empty<byte>();
}

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(e =>
        {
            e.ToTable("Orders");
            e.Property(o => o.RowVer)
                .IsRowVersion()
                .HasColumnName("RowVer");
        });
    }
}

IsRowVersion() configures the store type, marks the property as a concurrency token, and tells the SQL Server provider to exclude it from INSERT/UPDATE SET lists. Prefer this over a hand-rolled IsConcurrencyToken() on a plain byte[] unless you are mapping an existing non-rowversion column.

#Handle conflicts in the request path

Catch the exception close to the user action. Reload current values, show what changed, and let the operator retry. Blind retries without merging reintroduce lost updates.

csharp
public async Task<IResult> UpdateStatusAsync(
    int id, string status, byte[] rowVer, AppDbContext db, CancellationToken ct)
{
    var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
    if (order is null) return Results.NotFound();

    // Client must post the RowVer it originally loaded
    db.Entry(order).Property(o => o.RowVer).OriginalValue = rowVer;
    order.Status = status;

    try
    {
        await db.SaveChangesAsync(ct);
        return Results.Ok(new { order.Id, order.Status, order.RowVer });
    }
    catch (DbUpdateConcurrencyException)
    {
        await db.Entry(order).ReloadAsync(ct);
        return Results.Conflict(new
        {
            message = "Order changed since you loaded it.",
            current = new { order.Status, order.RowVer }
        });
    }
}

API and MVC forms should round-trip RowVer as a hidden field or ETag-style value. If the client omits it, you are back to last-write-wins. For JSON APIs, base64-encode the eight bytes so they survive serialization cleanly.

#Hosting-floor checks that matter

  • Keep the app and SQL collations/time zones out of this problem—rowversion is not datetime-based.
  • Include RowVer in SELECT projections used by edit screens; AsNoTracking queries still need the value posted back as OriginalValue.
  • Avoid wrapping long UI think-time inside a DB transaction. Load, release, then UPDATE with the token when the user submits.
  • If you use ExecuteUpdateAsync for bulk paths, concurrency tokens are not applied the same way as tracked SaveChanges—reserve bulk updates for non-interactive jobs.
  • On older schemas still named timestamp, map the column name explicitly; the engine type is rowversion either way.

Connection pooling and command timeouts still apply, but they do not replace concurrency tokens. A fast pool only makes lost updates happen quicker under concurrent editors.

#When rowversion is the wrong tool

Use it for lost-update detection on interactive rows. Do not use it as a cache invalidation key across web nodes, a substitute for proper authorization, or a global ordering mechanism across tables. For insert-only logging tables, skip it. For multi-row business operations that must stay atomic, combine tokens with an explicit transaction around the full unit of work—not a single-row version check alone.

Practical takeaway: add a non-nullable rowversion to every user-edited table, map it with IsRowVersion() in EF Core 10, round-trip the value from the edit UI, and translate DbUpdateConcurrencyException into a conflict response the operator can act on. That small pattern removes an entire class of “the database ate my change” incidents on hosted SQL Server without changing your IIS app pool layout or connection string.