---
title: EF Core concurrency tokens and temporal tables
date: 2025-02-25
tags: dotnet, ef-core, sql-server
summary: Two flavours of optimistic concurrency, temporal tables for free history, and three small extension methods that only compile on the right entities.
---

Two users open the same order, both change it, both save. Without a concurrency check the second save silently wins. And when someone asks "who changed this, and what was it before?", the audit fields only tell you the last change.

I added both on the same day: optimistic concurrency, and SQL Server temporal tables for the history.

## Two base classes

Most entities inherit from `BaseEntity`, which carries the [audit fields](/p/2025-02-04-ef-core-audit-fields/). Entities whose rows change a lot get one extra column:

```csharp title="BaseEntity.cs"
public abstract class BaseEntity : IBaseEntity
{
    public int Id { get; set; }
    public Guid CreatedBy { get; set; }
    public DateTime CreatedOn { get; set; }
    public Guid UpdatedBy { get; set; }
    public DateTime UpdatedOn { get; set; }
}

public abstract class BaseEntityRowVersioning : BaseEntity
{
    public byte[] RowVersion { get; set; } = [];
}
```

## Three extension methods

All the configuration fits in three methods. The generic constraints are the nice part: `UseRowVersioning()` only compiles on an entity that actually has a `RowVersion`.

```csharp title="ModelBuilderExtensions.cs"
internal static class ModelBuilderExtensions
{
    // Optimistic concurrency using the audit timestamp
    internal static void UseConcurrencyToken<T>(this EntityTypeBuilder<T> b) where T : BaseEntity
        => b.Property(p => p.UpdatedOn).IsConcurrencyToken();

    // Optimistic concurrency using SQL Server rowversion
    internal static void UseRowVersioning<T>(this EntityTypeBuilder<T> b) where T : BaseEntityRowVersioning
        => b.Property(p => p.RowVersion).IsRowVersion();

    // System-versioned temporal table
    internal static void IsAuditTable<T>(this EntityTypeBuilder<T> b) where T : BaseEntity
        => b.ToTable(t => t.IsTemporal());
}
```

At the end of `OnModelCreating` it reads like a list of decisions:

```csharp title="ApplicationDbContext.cs"
// Concurrency
builder.Entity<Customer>().UseConcurrencyToken();
builder.Entity<Account>().UseRowVersioning();   // balances change often
builder.Entity<Order>().UseRowVersioning();

// History
builder.Entity<Order>().IsAuditTable();
builder.Entity<Customer>().IsAuditTable();
```

## Two flavours of concurrency

| Flavour | Column | Good for | Caveat |
|---|---|---|---|
| `IsRowVersion()` | `rowversion`, 8 bytes, set by the database | Rows updated often or at the same time (balances, ledgers) | An extra column. Must be on the root of a TPH hierarchy. |
| `UpdatedOn` as token | the existing `datetime2` | Master data that rarely conflicts | Only works because every save bumps `UpdatedOn`. |

Both work the same way. EF adds the original value to the `WHERE` of the `UPDATE`. If another save changed the row in between, zero rows match and EF throws a `DbUpdateConcurrencyException`.

`rowversion` is the safe choice. SQL Server changes it on every write, whoever does the write.

`UpdatedOn` costs nothing, because the column is already there. But it depends on the `SaveChanges` override setting a new value on every save. A raw `UPDATE` from a script, or a save through `SystemSaveChangesAsync`, doesn't change it, and then the check doesn't see the conflict. That's fine for master data. For balances it isn't.

## Temporal tables

`IsTemporal()` turns a table into a system-versioned temporal table. SQL Server keeps a history table next to it and copies the old row there on every update and delete. EF adds `PeriodStart` and `PeriodEnd` as shadow properties.

You query the history with LINQ:

```csharp title="history"
// The order as it was yesterday at noon
var then = await db.Orders
    .TemporalAsOf(new DateTime(2025, 2, 24, 12, 0, 0, DateTimeKind.Utc))
    .FirstAsync(o => o.Id == id);

// Every version of the order
var versions = await db.Orders
    .TemporalAll()
    .Where(o => o.Id == id)
    .OrderBy(o => EF.Property<DateTime>(o, "PeriodStart"))
    .ToListAsync();
```

This works well together with the audit fields. Every history row still has its `UpdatedBy` and `UpdatedOn`, so you get *who* changed it and *what* it looked like before. No triggers, no audit log table, no interceptor. That's why I never wrote an audit-log interceptor.

> The history table grows forever. Decide early if you need a retention policy (`HISTORY_RETENTION_PERIOD` in SQL Server), and only make tables temporal where someone will actually ask about the past.

A concurrency exception is only half of the story. You still have to do something when it happens. That's [the next post](/p/2025-03-24-ef-core-concurrency-retry/).
