Chapter 13 · Optimistic Concurrency and Conflict Resolution

How EF Detects Lost Updates and Builds Concurrency-Sensitive UPDATE/DELETE Predicates

Observe the exact key-plus-original-token predicates EF emits, reproduce a deterministic two-context lost-update race, and distinguish optimistic conflicts from locking failures.

Advanced130–170 minutespredicate + two-context race labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

The phrase “EF detected a conflict” can sound magical until the generated SQL is inspected. In reality, a tracked update is a conditional write: the primary key identifies the row and the original concurrency token proves that the row still represents the version that was read.

01

Read the key-plus-original-token predicate in generated UPDATE and DELETE commands.

02

Explain why zero affected rows becomes DbUpdateConcurrencyException.

03

Reproduce a deterministic two-context stale-writer conflict against SQLite.

04

Distinguish optimistic concurrency detection from locks, deadlocks, timeouts, and network faults.

05

Explain why ExecuteUpdate/Delete need manual rows-affected concurrency logic.

06

Use logs, exception entries, database state, and tracker snapshots as separate evidence sources.

Reproducible baseline

Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, and the existing application-managed Guid Revision token. SQL Server rowversion and PostgreSQL xmin are optional provider comparisons, not mandatory infrastructure. EF Core 11 previews are excluded.

1. A normal tracked UPDATE is a compare-and-swap shape

csharp · two values of the token have different jobs
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);var token = db.Entry(order).Property(x => x.Revision);Console.WriteLine($"original={token.OriginalValue}");order.ReviseSummary("Replace coupling guard before startup");order.AdvanceRevision();Console.WriteLine($"current ={token.CurrentValue}");await db.SaveChangesAsync(ct);
sql · representative SQLite UPDATE
UPDATE "work_orders"SET "summary" = @summary,    "revision" = @newRevisionWHERE "work_order_id" = @id  AND "revision" = @originalRevision;

The current token is the version being published. The original token is the precondition. If another writer changed the stored revision after this context queried the row, the predicate matches zero rows.

2. Zero rows is the concurrency signal

For an entity configured with a concurrency token, EF expects the UPDATE or DELETE to affect the row identified by key + original token. When zero rows are affected, EF cannot safely infer that its intended state was persisted, so SaveChanges throws DbUpdateConcurrencyException.

csharp · observe rather than swallow
try{    await db.SaveChangesAsync(ct);}catch (DbUpdateConcurrencyException ex){    foreach (var entry in ex.Entries)    {        Console.WriteLine(entry.Metadata.DisplayName());        Console.WriteLine(entry.DebugView.LongView);    }    throw;}

The exception tells you that the optimistic write precondition failed. It does not say which competing edit is correct. Resolution is application policy, covered in Lesson 3.

3. Deterministic two-context race: same read, ordered writes

csharp · stale writer without physical write overlap
await using var first = await factory.CreateDbContextAsync(ct);await using var second = await factory.CreateDbContextAsync(ct);var a = await first.WorkOrders.SingleAsync(x => x.Id == id, ct);var b = await second.WorkOrders.SingleAsync(x => x.Id == id, ct);Console.WriteLine(a.Revision == b.Revision); // True: same starting versiona.ReviseSummary("Dispatcher A approved bearing replacement");a.AdvanceRevision();await first.SaveChangesAsync(ct);b.ReviseSummary("Dispatcher B approved vibration-only inspection");b.AdvanceRevision();await Assert.ThrowsAsync<DbUpdateConcurrencyException>(    () => second.SaveChangesAsync(ct));

The writes are intentionally ordered. Both contexts read the same version, writer A commits first, and writer B then submits a stale predicate. This isolates optimistic stale-state detection from SQLite write-lock timing.

4. DELETE uses the same idea

csharp · tracked delete with stale token protection
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);db.Remove(order);await db.SaveChangesAsync(ct);
sql · representative DELETE predicate
DELETE FROM "work_orders"WHERE "work_order_id" = @id  AND "revision" = @originalRevision;

If another writer changed the token first, the delete affects zero rows and surfaces a concurrency exception. Foreign-key or cascade failures are different: they typically surface as a database/provider update exception, not a token mismatch.

5. Concurrency conflict is not a deadlock, busy lock, or timeout

Failure Evidence Meaning Typical response
DbUpdateConcurrencyException Zero-row key+token write; conflicting entry available Business state changed since read Reload/merge/reject according to policy.
SQLite SQLITE_BUSY/LOCKED Provider exception / lock evidence Database could not acquire required lock Provider-specific wait/retry/backoff if safe.
SQL Server deadlock victim SQL error number/provider transient classification Competing lock graph was broken by engine Execution strategy/idempotent retry where documented.
Network/timeout Provider/network exception, telemetry Infrastructure did not complete normally Provider-specific transient policy; ambiguous commit may require idempotency.

Blindly putting DbUpdateConcurrencyException into a generic transient retry loop can repeatedly overwrite or re-propose stale business state. Conversely, asking a user to merge after a network timeout may be equally wrong.

6. Set-based DML requires explicit concurrency predicates

Chapter 12 showed that ExecuteUpdateAsync bypasses EF's change tracker and normal automatic concurrency checking. If a set-based operation is version-sensitive, include the expected version in the filter and inspect rows affected.

csharp · manual optimistic check with ExecuteUpdate
var newRevision = Guid.NewGuid();var affected = await db.WorkOrders    .Where(x => x.Id == id && x.Revision == expectedRevision)    .ExecuteUpdateAsync(setters => setters        .SetProperty(x => x.Revision, newRevision), ct);if (affected == 0)    throw new WorkOrderConflictException(id, expectedRevision);

That application exception is your domain/API contract; EF does not raise DbUpdateConcurrencyException automatically for this direct set-based write.

7. Hands-on lab and evidence bundle

  1. Reset SQLite and enable EF command + update logging without sensitive-data logging in shared/production-like output.
  2. Run one successful tracked update and capture original/current Revision values.
  3. Run the ordered two-context race and capture both UPDATE commands.
  4. Verify the second command uses its stale original revision and affects zero rows.
  5. Inspect ex.Entries, tracker state, and database state after the exception.
  6. Repeat with a stale DELETE.
  7. Implement the rows-affected pattern for one ExecuteUpdateAsync call and verify zero is handled explicitly.

Check your understanding

  1. Which Revision value goes in the WHERE predicate?
  2. Which Revision value is written by the successful update?
  3. Why does EF throw when zero rows match?
  4. Is a SQLite busy-lock error a DbUpdateConcurrencyException?
  5. Does ExecuteUpdate automatically use EF concurrency-token tracking?
  6. What does ToQueryString prove for SaveChanges DML?
Review the answers

The original value that was read/tracked.

The new/current application-managed value.

The key+token precondition for the tracked write failed, indicating stale/deleted state.

No. It is a provider/database locking failure with different recovery semantics.

No. Add an expected-token predicate and inspect affected rows yourself.

Nothing directly; SaveChanges command logging/interception is needed because ToQueryString targets queries, not the generated modification command pipeline.

8. Production judgment and bridge

Use the exception type, generated command, affected-row expectation, tracker snapshot, and database state together. Once a stale write is proved, the next question is policy: reject it, prefer the store, reapply the client proposal, or merge non-overlapping fields. Lesson 3 implements those policies without unbounded retry loops.

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