Catching DbUpdateConcurrencyException is the easy part. Deciding what your application actually does next is where real conflict-resolution design lives.
Intermediate's "Transactions and Concurrency" lesson left you at exactly this point: two support agents edit the same account, EF Core detects the conflict via a RowVersion token, and throws DbUpdateConcurrencyException instead of silently letting one overwrite the other. That's the detection half of the problem, solved. But detection was never the hard part — catch (DbUpdateConcurrencyException) is three lines of ceremony. The genuinely hard question, the one that lesson deliberately left for later, is: once you've detected the conflict, what does your application actually do? Reject Agent B outright? Let Agent A's change simply win because it saved first? Try to merge both agents' edits field by field? Each answer is a real design decision with real trade-offs — and this lesson is about making that decision deliberately, with actual code, instead of by accident.
In this lesson, you'll implement three real optimistic-concurrency conflict-resolution strategies — database wins, client wins, and field-by-field merge — using the DbUpdateConcurrencyException.Entries and PropertyValues APIs, and then look honestly at pessimistic concurrency as the alternative approach EF Core doesn't give you first-class support for.
Conflict resolution is the specific logic your application runs once DbUpdateConcurrencyException tells you two operations touched the same row. It's not one universal answer — it's a choice, made deliberately per entity or even per operation, about whose version of the truth wins, or whether the two versions can be combined into something that respects both.
When a concurrency conflict is detected, EF Core's DbUpdateConcurrencyException exposes an Entries collection — one EntityEntry per conflicted entity. Each entry gives you three distinct sets of property values to reason about:
| Property Values | What it represents |
|---|---|
| entry.OriginalValues | What your code believed the row looked like when it was first read — the version the failed update's WHERE clause was checking against. |
| entry.CurrentValues | What your in-memory entity looks like right now, including whatever changes your code made before the failed save. |
| await entry.GetDatabaseValuesAsync() | What the row actually looks like in the database right now — the version that "won" and caused the conflict in the first place. |
Every conflict-resolution strategy in this lesson is really just a different, deliberate way of combining these three sets of values into what actually gets saved.
EF Core's RowVersion mechanism does exactly one job, and does it well: it tells you, reliably, that a conflict happened. It has no opinion at all about what should happen next — and it shouldn't, because the right answer genuinely depends on the domain. An admin overwriting a customer's shipping address probably should simply win outright. Two warehouse staff both adjusting the same inventory count probably need their adjustments combined, not one silently discarded. A financial ledger entry probably should never allow either side to "just win" — the conflict itself needs to be surfaced to a human. Leaving this decision unmade — catching the exception and doing nothing meaningful with it — just turns a silent data-loss bug into a slightly-less-silent, still-unhandled crash.
Three well-established strategies cover the overwhelming majority of real scenarios: database wins (discard the in-memory change, reload, tell the user to reapply their edit against current data), client wins (force the in-memory change through regardless of what changed underneath it), and merge (apply the in-memory change only to the specific fields it actually touched, leaving every other field's concurrent update from the database intact). None of these is a default that fits every entity — the right one depends entirely on what the conflicting data actually means.
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (EntityEntry entry in ex.Entries)
{
// Discard the in-memory change; refresh with what's actually in the database now.
await entry.ReloadAsync();
}
// The caller sees the current, real state — and must decide whether to try again.
throw new InvalidOperationException(
"This record was changed by someone else. Please review the current data and try again.");
}try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (EntityEntry entry in ex.Entries)
{
// Fetch the row's CURRENT database values, purely to read its current RowVersion...
PropertyValues databaseValues = await entry.GetDatabaseValuesAsync()
?? throw new InvalidOperationException("The record was deleted by another user.");
// ...then overwrite the token EF Core checks, forcing OUR values through on retry.
entry.OriginalValues.SetValues(databaseValues);
}
// Retry — this time the WHERE clause matches, and OUR in-memory values win.
await context.SaveChangesAsync();
}entry.OriginalValues.SetValues(databaseValues) is the key move: it updates what EF Core believes the row's original state was — including the RowVersion — to match what's actually in the database right now, without touching entry.CurrentValues at all. The retried save's WHERE clause now matches, and your in-memory (client) values are what actually get written.
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (EntityEntry entry in ex.Entries)
{
PropertyValues databaseValues = await entry.GetDatabaseValuesAsync()
?? throw new InvalidOperationException("The record was deleted by another user.");
PropertyValues currentValues = entry.CurrentValues;
PropertyValues originalValues = entry.OriginalValues;
foreach (IProperty property in currentValues.Properties)
{
object? proposedValue = currentValues[property];
object? originalValue = originalValues[property];
object? databaseValue = databaseValues[property];
// Only keep OUR change for a field we actually modified.
// For every field we DIDN'T touch, defer to whatever's in the database now.
if (!Equals(proposedValue, originalValue))
currentValues[property] = proposedValue; // we changed it — keep our value
else
currentValues[property] = databaseValue; // we didn't — take theirs
}
// Move the token forward so the retry's WHERE clause matches the current row.
entry.OriginalValues.SetValues(databaseValues);
}
await context.SaveChangesAsync();
}The logic hinges on comparing currentValues against originalValues per property: if they differ, your code actually changed that field, so your change is preserved; if they're identical, you never touched that field, so the concurrent update from the database is allowed to stand. This is the strategy that best respects both transactions' intent, at the cost of noticeably more code and a real assumption — that field-level merging is actually meaningful for this entity, which isn't true for every entity (a financial balance, for instance, usually shouldn't be "merged" this way at all).
A Product row with Name, Price, and StockCount. Agent A updates only Price. Agent B, concurrently, updates only StockCount. Neither agent touched the field the other one changed:
A real application rarely uses just one strategy everywhere — the right choice is a per-entity, sometimes per-field, design decision:
| Entity / Scenario | Best strategy | Why |
|---|---|---|
| A CMS article's body text, edited by one author at a time | Database wins | Silently merging prose is nonsensical — a human needs to see the conflict and decide. |
| An admin correcting a customer's address after a support call | Client wins | The admin's action is authoritative and intentional — it should take effect regardless of a stale background job's concurrent touch. |
| A product's price and stock count, updated independently by pricing and warehouse systems | Field-by-field merge | The two systems genuinely never conflict at the field level — merging preserves both without loss. |
| A bank account balance | Neither blind strategy — reject and require an explicit, audited retry | Silently merging or overwriting a monetary balance risks real financial correctness; the conflict itself needs a human-visible resolution path. |
Two people edit the same paragraph of a shared document while both were offline, then both try to sync. Database wins is the sync tool discarding your edit entirely and showing you the current version — you have to look and manually redo your change. Client wins is the sync tool forcing your version through, silently erasing whatever the other person wrote. Merge is what a good version-control diff tool actually does: if you edited paragraph 3 and they edited paragraph 7, both edits survive intact, because neither person touched the other's paragraph — it's only when you both edited the same line that a real, human-visible conflict remains.
Isolation levels govern what one transaction can see while it's running; they don't prevent two separate, sequential transactions from both successfully writing to the same row on their own turns. Even at Serializable, two transactions that run one after another (not concurrently overlapping) can each successfully update the same row — and if the second one's RowVersion is now stale, it still gets a DbUpdateConcurrencyException. Isolation and optimistic concurrency solve genuinely different problems and are frequently used together.
It looks alarming the first time you see it — "deliberately overwrite what the other transaction just committed?" — but it's a legitimate, deliberate strategy for the right scenario: cases where one actor's write is genuinely meant to be authoritative regardless of concurrent, lower-priority changes (an admin override, a corrective action). The difference between "client wins as a deliberate design decision" and "silently overwriting data as a bug" is entirely whether you chose it on purpose, for a reason that fits the domain.
Wrapping every DbUpdateConcurrencyException handler in a generic "overwrite and retry" without ever asking whether that entity's data can safely be blindly overwritten. Choose the strategy per entity, based on what silently discarding a concurrent change would actually mean for that specific data — financial and audit-sensitive data almost never belongs to "client wins."
Calling GetDatabaseValuesAsync() and assuming it always returns a value — if the other transaction deleted the row instead of updating it, this returns null, and code that doesn't check for that throws a confusing NullReferenceException instead of a meaningful error. Always check for null and handle "the row no longer exists" as its own distinct case.
Adding explicit row-level locking hints to avoid ever having to handle DbUpdateConcurrencyException, without weighing the real throughput cost against how rare actual conflicts genuinely are for that entity. Default to optimistic concurrency — it fits the vast majority of web applications well — and only reach for pessimistic locking where measured, high-contention scenarios show that retry storms are a worse problem than the blocking pessimistic locking would introduce.
| Situation | Reach for |
|---|---|
| Human-authored content where a silent auto-merge would be nonsensical (prose, configuration) | Database wins — surface the conflict to a person |
| An authoritative, intentional override action (admin correction) | Client wins |
| Independent systems that legitimately touch different fields on the same row | Field-by-field merge |
| Financial, audit, or otherwise correctness-critical data | Neither blind strategy — reject and require explicit, visible resolution |
| Most ordinary web application data, low measured contention | Optimistic concurrency (RowVersion) — the right default |
| Very high, measured contention on a specific row/table where retry storms are demonstrably worse than blocking | Consider pessimistic locking — a deliberate, measured exception, not a default |
You've written real conflict-resolution code, not just caught the exception. Let's confirm it clicked.
1. Two systems update the same Product row concurrently — one changes only Price, the other changes only StockCount. Which strategy best preserves both systems' intent, and why?
Correct: C
Why C is correct: Since the two systems modified different fields, a field-by-field merge preserves both changes intact — Price from one system, StockCount from the other — by keeping a field's proposed value only where it actually differs from the original, and taking the database's current value everywhere else.
Why A is incorrect: Database wins would discard whichever change retries second entirely, even though it never actually conflicted at the field level with what's already saved.
Why B is incorrect: Client wins would force the second save's values through, silently overwriting the first system's already-committed, non-conflicting change to a different field.
Why D is incorrect: This is exactly the scenario RowVersion-based optimistic concurrency detects — the second save's WHERE clause fails to match because the RowVersion already changed, triggering DbUpdateConcurrencyException.
Reinforcement: Merge is the right tool specifically when concurrent changes touch different fields — it's not a universal answer, but it's the best fit for this exact shape of conflict.
2. In the "client wins" strategy, what does calling entry.OriginalValues.SetValues(databaseValues) actually accomplish before the retried SaveChangesAsync() call?
Correct: B
Why B is correct: OriginalValues represents what EF Core will check the row against in the retried UPDATE's WHERE clause. Setting it to the current database values (including the current RowVersion) makes that check pass, while CurrentValues — the client's actual in-memory change — is left completely untouched, so it's what actually gets written.
Why A is incorrect: This is backwards — CurrentValues is deliberately left alone specifically so the client's change survives; only OriginalValues is updated.
Why C is incorrect: This has nothing to do with deletion — it only updates in-memory tracking metadata used for the next save attempt.
Why D is incorrect: The RowVersion check still runs on every future save — this call only resolves the current conflict by aligning the tracked original value with what's now in the database.
Reinforcement: Client wins works by updating what EF Core checks against, not by bypassing the check — the mechanism stays intact, it's just satisfied.
3. Why doesn't EF Core give pessimistic concurrency the same first-class support it gives optimistic RowVersion tokens?
Correct: B
Why B is correct: Achieving pessimistic locking means dropping to engine-specific locking hints or explicit lock management, which is inherently less portable than the EF-native, database-agnostic RowVersion/[Timestamp] mechanism — and since optimistic concurrency fits most real applications well, that's what EF Core builds first-class, portable support around.
Why A is incorrect: Pessimistic locking is entirely achievable in relational databases (via constructs like row-level locking hints) — it's just not wrapped in an EF-native, portable API the way optimistic tokens are.
Why C is incorrect: The lesson is explicit that this support is asymmetric — optimistic tokens get a dedicated, portable attribute-based mechanism; pessimistic locking does not have an equivalent.
Why D is incorrect: Pessimistic concurrency is a relational-database concept just as much as optimistic concurrency is — both apply squarely to the relational databases this curriculum covers.
Reinforcement: Recognize this as an honest, real gap in EF Core's feature set, not an oversight to work around by pretending equivalent support exists.
You now have real, working conflict-resolution strategies, and an honest picture of optimistic versus pessimistic concurrency. Next up: connection management — what a DbContext's lifetime actually does, and doesn't, control.
dotnetmadeeasy.com — Learn C# and .NET, the right way.