Chapter 04 · Keys, Indexes, Property Semantics, Value Generation, and Shadow State

Concurrency Tokens, Store-Generated Values, and Mapping Decisions that Affect Updates

Connect concurrency-token and store-generated property mappings to real UPDATE predicates, affected-row detection, and provider-specific optimistic-concurrency mechanisms.

Intermediate100–125 minutestwo-context concurrency + write-semantics labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

Two ServiceHub dispatchers can load the same work order, make different edits, and save seconds apart. Without a concurrency policy the later save can overwrite the earlier one. EF Core implements optimistic concurrency by adding original concurrency-token values to UPDATE/DELETE predicates and checking affected-row counts. This lesson connects that write behavior to the property metadata you configured throughout Chapter 04.

01

Configure an application-managed concurrency token that works in the mandatory SQLite lab.

02

Inspect original/current token values and the generated UPDATE predicate that detects a lost update.

03

Distinguish a concurrency conflict from deadlocks, timeouts, transient failures, and validation errors.

04

Compare application-managed tokens with SQL Server rowversion without presenting rowversion as portable.

05

Inspect store-generated/save behaviors to explain which columns EF sends, omits, or expects back.

06

Run a deterministic two-context conflict and repair it without blanket last-write-wins behavior.

1. Optimistic concurrency assumes conflicts are exceptional—but detectable

EF does not keep a database row locked for the entire time a user edits a form. Instead, a query records the original value of configured concurrency tokens. On update, EF includes those original values in the WHERE predicate. If another transaction changed the token first, zero rows match and EF throws DbUpdateConcurrencyException.

Important distinction

A concurrency conflict is not a deadlock, command timeout, network failure, or retryable transient provider error. Each mechanism has different evidence and recovery. Chapter 13 treats conflict-resolution policies in depth; this lesson establishes the mapping/write mechanism.

2. Add an application-managed token for SQLite

SQLite does not have SQL Server's automatically changing rowversion type. Microsoft specifically recommends application-managed concurrency tokens as a portable way to implement optimistic concurrency where no native auto-updating token exists.

csharp · evolve WorkOrder with a revision token
public Guid Revision { get; private set; } = Guid.NewGuid();public void AdvanceRevision(){    Revision = Guid.NewGuid();}
csharp · concurrency-token mapping
builder.Property(x => x.Revision)    .HasColumnName("revision")    .IsConcurrencyToken();

This token is not database-generated. Application code must assign a new value for each meaningful update that should invalidate stale writers.

3. Original and current values play different roles

csharp · inspect token before save
var workOrder = await db.WorkOrders.SingleAsync(    x => x.WorkOrderNumber == "WO-2026-000201", ct);var revision = db.Entry(workOrder).Property(x => x.Revision);Console.WriteLine($"Original: {revision.OriginalValue}");Console.WriteLine($"Current:  {revision.CurrentValue}");workOrder.ReviseSummary("Inspect cavitation alarm and suction pressure");workOrder.AdvanceRevision();Console.WriteLine($"Original: {revision.OriginalValue}"); // old tokenConsole.WriteLine($"Current:  {revision.CurrentValue}");  // new tokenawait db.SaveChangesAsync(ct);

For the update, the old/original token belongs in the predicate and the new/current token belongs in the values being written. That is how one statement both verifies “I am editing the version I read” and publishes a new version for later writers.

4. Read the generated UPDATE semantically

Exact SQL formatting and parameter names are provider/version details, but the shape should be equivalent to:

sql · conceptual SQLite UPDATE shape
UPDATE work_ordersSET summary = @p0,    revision = @p1WHERE work_order_id = @id  AND revision = @original_revisionRETURNING 1;

If another writer has already changed revision, the predicate matches zero rows. EF detects the zero affected-row result for a concurrency-protected update and raises DbUpdateConcurrencyException.

5. Deterministic two-context conflict

csharp · two independent units of work
await using var first = await factory.CreateDbContextAsync(ct);await using var second = await factory.CreateDbContextAsync(ct);var a = await first.WorkOrders.SingleAsync(    x => x.WorkOrderNumber == "WO-2026-000201", ct);var b = await second.WorkOrders.SingleAsync(    x => x.WorkOrderNumber == "WO-2026-000201", ct);a.ReviseSummary("Dispatcher A confirmed suction pressure check");a.AdvanceRevision();await first.SaveChangesAsync(ct);b.ReviseSummary("Dispatcher B changed motor inspection scope");b.AdvanceRevision();try{    await second.SaveChangesAsync(ct);}catch (DbUpdateConcurrencyException ex){    Console.WriteLine($"Concurrency conflict on {ex.Entries.Count} tracked entry/entries.");    throw; // Chapter 13 implements merge/retry policies.}

Do not “repair” this lab by catching the exception and blindly retrying the same stale state. A valid response may require store-wins, client-wins, field-level merge, user intervention, or domain-specific recomputation.

6. Deliberately broken token: never advance it

If Revision is configured as a concurrency token but remains unchanged across updates, both writers can still match the same token. The first update does not invalidate the second writer, so the second can overwrite data without a conflict.

csharp · broken application-managed token lifecycle
var workOrder = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);workOrder.ReviseSummary(newSummary);// BUG: Revision remains unchanged.await db.SaveChangesAsync(ct);

The repair is to regenerate the application-managed token whenever a change should invalidate stale writers. Centralizing that policy in a domain method, SaveChanges pipeline, or interceptor can reduce omissions—but those mechanisms must be tested and are covered later.

7. SQL Server rowversion is a different, provider-specific mechanism

SQL Server has a database-generated binary rowversion value that changes when a row is updated. EF's SQL Server provider can configure a byte[] property with IsRowVersion(). This is not portable to SQLite/PostgreSQL/MySQL/Oracle.

csharp · SQL Server-only rowversion mapping
// SQL Server provider-specific entity variant.public byte[] RowVersion { get; private set; } = Array.Empty<byte>();builder.Property(x => x.RowVersion)    .HasColumnName("row_version")    .IsRowVersion();

IsRowVersion() combines concurrency-token semantics with store-generated-on-add-or-update behavior for SQL Server. PostgreSQL's xmin and other provider mechanisms have different types/lifecycles. Chapter 13 and provider chapters compare them explicitly.

8. Store-generated values change what EF sends and reads back

The model now contains several categories:

Property Owner Write behavior concept
Id Database/store-generated on add EF omits/uses provider generation and propagates final key
CreatedUtc shadow SQLite default on add EF expects store value after insert according to provider support
SearchLabel shadow Computed from stored columns Application should not treat it as ordinary writable state
Revision Application-managed concurrency token Current token written; original token used in update predicate
Summary Application/domain Changed value written through backing-field mapping

Inspect ValueGenerated and save behaviors rather than guessing which properties will appear in a command.

csharp · save/concurrency metadata evidence
var entity = db.Model.FindEntityType(typeof(WorkOrder))!;foreach (var property in entity.GetProperties()){    Console.WriteLine(        $"{property.Name}: " +        $"generated={property.ValueGenerated}, " +        $"before={property.GetBeforeSaveBehavior()}, " +        $"after={property.GetAfterSaveBehavior()}, " +        $"concurrency={property.IsConcurrencyToken}");}

9. Mapping choices influence write amplification and correctness

A concurrency token adds predicate work to every guarded update. Store-generated values may require provider-specific RETURNING/OUTPUT or follow-up retrieval. Computed columns remove application write responsibility but move logic into database DDL. Broadly marking many properties as concurrency tokens can create excessive conflicts; marking too little can permit lost updates.

Use the smallest token scope that matches your domain consistency requirement. A single row-wide revision token is simple but conflicts on any guarded write. Field-specific tokens are possible but make conflict logic more complex.

10. Hands-on lab: prove lost-update detection

  1. Add Revision to WorkOrder and deterministic seed rows.
  2. Configure it with IsConcurrencyToken; keep the SQLite path application-managed.
  3. Generate/review/apply the migration to the disposable database.
  4. Load one row and record original/current revision values.
  5. Change the summary, call AdvanceRevision, save, and inspect SQL/logs.
  6. Run the two-context conflict exactly once with deterministic work-order identity.
  7. Capture DbUpdateConcurrencyException evidence without automatically retrying stale data.
  8. Reset, then deliberately omit AdvanceRevision and observe that two writers can overwrite without a token mismatch; repair the policy.
  9. Run the metadata probe and classify every generated/concurrency property.
  10. Document SQL Server rowversion as a provider variant only; do not install SQL Server just to complete the mandatory lab.

Verification checklist

  • The first writer changes both business state and revision token.
  • The second stale writer receives DbUpdateConcurrencyException.
  • No retry is performed without a conflict-resolution decision.
  • SQLite and SQL Server token mechanisms are not described as interchangeable.
  • Generated/store-owned properties are not treated as ordinary application-writable fields.

Check your understanding

  1. What makes EF raise DbUpdateConcurrencyException for an optimistic-concurrency update?
  2. Why must an application-managed token be regenerated?
  3. Is a concurrency conflict the same as a deadlock?
  4. What does SQL Server IsRowVersion combine?
  5. Why should computed/store-generated properties not be treated like ordinary writable properties?
  6. Why is blindly retrying a concurrency exception dangerous?
Review the answers

The update/delete predicate includes original concurrency-token values and affects zero rows because the stored token no longer matches.

Changing the token invalidates stale writers; if it never changes, stale predicates can continue matching.

No. They are distinct mechanisms with different causes/evidence/recovery.

Concurrency-token semantics with SQL Server store-generated rowversion behavior.

The database owns or derives those values, so sending arbitrary application values can conflict with generation/save behavior.

The retry can simply reapply stale state and overwrite a newer decision; resolution requires comparing/reloading/merging according to domain policy.

11. Chapter 04 production judgment and bridge

Chapter 04 has turned “properties” into a precise persistence contract: keys define identity, indexes define candidate access paths, value-generation metadata defines ownership/timing, shadow/indexer/property-bag patterns define storage location, and concurrency tokens alter write predicates. Each choice affects migrations, provider behavior, tracking, disconnected workflows, and production observability.

Before production, test key/unique/null semantics on the target database, inspect real plans before tuning indexes, review every generated/default/computed expression in migration DDL, keep shadow names covered by model tests, and choose a concurrency mechanism that your provider and domain actually support. Chapter 05 now builds relationships on top of these foundations: principal/dependent roles, navigations, requiredness, cascades, many-to-many joins, and relationship fixup.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n