← Open in the full interactive course (progress tracking, search & more)

SaveChanges() is already safer than you might think — but two separate saves, or two people editing the same row, need you to reach for a bit more.

Remember the bank transfer scenario from the ADO.NET transactions lesson — $500 vanishing forever if a crash happens between two separate UPDATE statements? EF Core actually protects you from a version of that automatically, every time you call SaveChangesAsync() — but only within that one call. The moment your business logic needs two separate SaveChangesAsync() calls to succeed or fail together, or two different users can edit the same row at nearly the same moment, you need to explicitly reach for the tools this lesson covers.

In this lesson, you'll learn about EF Core's implicit per-SaveChanges() transaction, how to wrap multiple SaveChanges() calls in one explicit transaction, and how optimistic concurrency with a RowVersion token protects against two users overwriting each other's edits.

What Is It?

The Simple Explanation

This lesson covers two related but different problems. A transaction makes sure a group of database changes happen together, all-or-nothing — the ADO.NET transactions lesson's problem, now in EF Core's context. Concurrency control makes sure that when two separate operations try to change the same row around the same time, one of them doesn't silently overwrite the other's work without anyone noticing.

The Technical Definition

EF Core wraps every call to SaveChanges() in an implicit transaction automatically — every INSERT/UPDATE/DELETE generated from one call either all commit or all roll back together. For multiple SaveChanges() calls that need to succeed or fail as one unit, you create an explicit transaction with context.Database.BeginTransactionAsync(). For the "two users, same row" problem, EF Core supports optimistic concurrency: a concurrency token property (commonly a byte[] RowVersion, marked [Timestamp]) that the database automatically changes on every update, letting EF Core detect — and refuse — a save based on stale data.

Why Does It Exist?

The Problem — Multi-Step Saves Can Half-Fail, and Simultaneous Edits Can Silently Clash

Two distinct failure modes, both common in real applications: First, a business operation sometimes genuinely needs more than one SaveChanges() call — perhaps because different parts of a larger workflow save independently — and if the process crashes between them, you're left with a half-completed operation, exactly the "$500 vanished" problem from before. Second — and this one has nothing to do with crashes — imagine two support agents both open the same customer's account, both see "Balance: $1,000," Agent A changes it to $900 and saves, then Agent B (still looking at their now-stale $1,000 view) changes it to $1,200 and saves. Agent B's save silently wipes out Agent A's change entirely — not because anything crashed, but simply because two people edited the same row without either knowing about the other.

The Solution — Explicit Transactions, and a Version Check on Every Save

Explicit transactions solve the first problem the same way ADO.NET transactions always have — group the operations, commit or roll back together. Optimistic concurrency solves the second: every row carries a hidden "version" value that changes every time it's updated; every UPDATE EF Core generates includes a check — "only update this row if its version still matches what I originally read" — and if that check fails, EF Core throws instead of silently overwriting, giving your application a chance to tell the user "someone else changed this — please reload and try again."

Big Picture

THREE LEVELS OF PROTECTION, LAYERED
1. Implicit transaction — automatic, every single SaveChanges() call
2. Explicit transaction — opt-in, when you need MULTIPLE SaveChanges() calls to be atomic together
3. Optimistic concurrency — opt-in, per entity, for rows that can be edited by more than one actor at once

How It Works — Transactions

The implicit transaction — you already have it

If a single SaveChangesAsync() call needs to update three different entities, all three UPDATE statements EF Core generates are wrapped in one database transaction automatically. If the third one fails, the first two are rolled back — you never have to write BeginTransaction for this case at all:

order.Status = OrderStatus.Shipped; customer.LoyaltyPoints += 50; inventory.Stock -= order.Quantity; await context.SaveChangesAsync(); // All three updates commit together, or none of them do — automatically.

Explicit transactions — spanning multiple SaveChanges() calls

Sometimes a workflow genuinely needs to call SaveChangesAsync() more than once — say, saving a new Order first (to get its generated Id back from the database) before creating a related audit-log record that needs that Id. Without an explicit transaction, a failure after the first save leaves the order saved but the audit entry missing — half-done. Wrap both in an explicit transaction to make them atomic together:

await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(); try { context.Orders.Add(order); await context.SaveChangesAsync(); // 1st save — order.Id is now populated context.AuditLogs.Add(new AuditLog { OrderId = order.Id, Message = $"Order {order.Id} created" }); await context.SaveChangesAsync(); // 2nd save — same transaction as the 1st await transaction.CommitAsync(); // both saves become permanent together } catch { await transaction.RollbackAsync(); // undo both, as if neither ran throw; }

This is the exact same BeginTransaction / Commit / Rollback pattern from the ADO.NET transactions lesson — EF Core's Database.BeginTransactionAsync() simply gives you an IDbContextTransaction that every SaveChangesAsync() call made through this context automatically joins, instead of each getting its own separate implicit transaction.

How It Works — Optimistic Concurrency

Add a RowVersion property to any entity that might be edited concurrently, marked as a concurrency token:

public class Account { public int Id { get; set; } public decimal Balance { get; set; } [Timestamp] // SQL Server: an auto-updating rowversion column public byte[] RowVersion { get; set; } = null!; }

The database automatically changes this value every time the row is updated — you never set it yourself. EF Core includes it in every generated UPDATE's WHERE clause:

UPDATE Accounts SET Balance = @newBalance WHERE Id = @id AND RowVersion = @originalRowVersion;

If another update already changed the row (and therefore its RowVersion) since you read it, this statement matches zero rows — and EF Core detects that mismatch and throws DbUpdateConcurrencyException instead of quietly reporting success.

Simple Example — Handling the Conflict

Account account = await context.Accounts.FirstAsync(a => a.Id == accountId); account.Balance -= 100m; try { await context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { // Someone else changed this row since we read it. // Reload the current values and let the caller decide what to do. await context.Entry(account).ReloadAsync(); throw new InvalidOperationException( "This account was modified by someone else. Please review the current balance and try again."); }

Catching DbUpdateConcurrencyException is not optional plumbing — without it, an unhandled exception bubbles straight up and the user sees a raw error instead of a meaningful "someone else changed this" message. context.Entry(account).ReloadAsync() refreshes the entity with the current database values so the application (or the user) can see what actually changed before deciding to retry.

Real-World Example — Two Support Agents, One Account

The exact scenario from the "Why Does It Exist?" section, now with the protection in place. Two support agents load the same Account at nearly the same time, and both try to save a change:

[HttpPut("{id}/balance")] public async Task<IActionResult> UpdateBalance(int id, [FromBody] UpdateBalanceRequest request) { Account? account = await context.Accounts.FindAsync(id); if (account is null) return NotFound(); account.Balance = request.NewBalance; context.Entry(account).Property(a => a.RowVersion).OriginalValue = request.RowVersionAtLoadTime; try { await context.SaveChangesAsync(); return NoContent(); } catch (DbUpdateConcurrencyException) { return Conflict(new { Message = "This account was updated by someone else since you loaded it. Reload and try again." }); } }

Agent A's request runs first and succeeds — the RowVersion in the database updates. Agent B's request then arrives with the original RowVersion (from before Agent A's change) — the WHERE RowVersion = @original clause matches nothing, EF Core throws DbUpdateConcurrencyException, and the API returns 409 Conflict instead of silently letting Agent B erase Agent A's update. This is optimistic concurrency working exactly as intended: it doesn't prevent the conflict from happening, but it guarantees the conflict is detected and surfaced, rather than silently lost.

Analogy

Editing a Shared Document by Its Last-Saved Timestamp

Imagine an old-fashioned shared document with no live collaborative editing — you open it, it shows "last saved 2:00 PM," you make changes, and when you try to save, the system checks: is it still 2:00 PM's version? If someone else saved a change at 2:15 PM while you were editing, your save is rejected — not silently overwritten, not silently merged, just flatly refused with "this document changed since you opened it, please reload." That's optimistic concurrency: it assumes conflicts are rare enough not to lock the document while you edit (that would be pessimistic concurrency), but it always checks the timestamp before committing, and refuses rather than guesses when it's stale.

An explicit transaction, by contrast, is like stapling two separate save operations together and telling the system "these two saves are really one save — either file both pages, or file neither." It's about atomicity across steps, not about detecting who else touched the document.

Under the Hood

WHY IT'S CALLED "OPTIMISTIC" CONCURRENCY
1. Optimistic: no lock is held while you're editing
2. The alternative — pessimistic concurrency — locks the row up front
3. The RowVersion column is maintained entirely by the database, not your code

Common Confusion

1. "Transactions and optimistic concurrency solve the same problem"

They solve two genuinely different problems that happen to both live in this lesson because they're both about safe, multi-actor data changes. A transaction is about atomicity across statements within one logical operation — nothing to do with other users. Optimistic concurrency is about detecting when two different operations touched the same row — nothing to do with whether either individual operation was atomic. A banking transfer needs a transaction even if only one person on Earth ever uses the system; a shared, frequently-edited row needs a concurrency token even if every single update is, on its own, perfectly atomic.

2. "SaveChanges() has no transaction unless I write BeginTransaction myself"

This is backwards — every single SaveChanges() call already runs inside an implicit transaction automatically, with zero code from you. You only need BeginTransaction explicitly when multiple SaveChanges() calls need to be atomic together — a narrower, less common need than people often assume.

Common Mistakes

Mistake 1 — Wrapping a single SaveChanges() call in an explicit transaction "to be safe"

BeginTransaction(), one SaveChangesAsync() call, then Commit() — this adds complexity and a small amount of overhead for no benefit, since that single call was already fully atomic on its own via the implicit transaction. Reach for an explicit transaction only when more than one SaveChanges() call genuinely needs to succeed or fail together.

Mistake 2 — Adding a RowVersion token everywhere "just in case"

Putting a concurrency token on every single entity in the system, including reference data that's essentially never edited concurrently — this adds a column, a bit of overhead per update, and error-handling complexity for rows where conflicts basically never happen. Reserve concurrency tokens for rows that are genuinely likely to be edited by more than one actor around the same time — shared records, collaborative data, financial balances — not blanket-applied everywhere.

Mistake 3 — Not catching DbUpdateConcurrencyException at all

Adding a RowVersion token and assuming that's the whole job done — an uncaught DbUpdateConcurrencyException just becomes an unhandled 500 error, giving the user no useful information and no path to actually resolve the conflict. Always catch it explicitly and decide what to do — reload and show the current data, ask the user to retry, or apply a defined conflict-resolution rule — the token only detects the conflict; your code decides how to respond.

When Should I Use It?

SituationUse
One SaveChanges() call, any number of entity changesNothing — the implicit transaction already covers this
A workflow that must call SaveChanges() more than once, and all calls must succeed or fail togetherAn explicit transaction via Database.BeginTransactionAsync()
A row multiple users/processes could plausibly edit around the same time (shared accounts, collaborative records, inventory counts)A RowVersion concurrency token, with DbUpdateConcurrencyException handled explicitly
Reference/lookup data essentially never edited concurrentlyNeither — the overhead isn't worth it
Rule of thumb: Trust the implicit transaction for single SaveChanges() calls. Add an explicit transaction only when a workflow spans multiple calls that must succeed or fail as one unit. Add a concurrency token only to rows genuinely at risk of simultaneous edits — and always handle DbUpdateConcurrencyException once you do.

Mental Model

Implicit transaction = one SaveChanges() call is always all-or-nothing, automatically.
Explicit transaction = stapling several SaveChanges() calls together into one all-or-nothing unit.
Optimistic concurrency = "check the version before you commit" — refuses a stale save instead of silently overwriting someone else's work.

Remember: transactions are about atomicity within one operation; concurrency tokens are about noticing when two operations collided.

Key Takeaway


Check Your Understanding

You've seen how EF Core protects a single save automatically, and what it takes to protect multi-step saves and simultaneous edits. Let's confirm the reasoning stuck.

1. A method modifies three different tracked entities and calls SaveChangesAsync() exactly once. Does this code need an explicit transaction to guarantee all three changes commit together?

Show answer

Correct: B

Why B is correct: EF Core wraps every SaveChanges() call in an implicit transaction automatically, regardless of how many entities or statements it involves. No explicit transaction code is needed for atomicity within a single call.

Why A is incorrect: This is the common misconception the lesson calls out directly — the implicit transaction already provides this guarantee without any BeginTransactionAsync() call.

Why C is incorrect: Which DbSet properties the entities come from has no bearing on this — the implicit transaction covers every statement generated by one SaveChanges() call regardless of entity type.

Why D is incorrect: EF Core typically generates one SQL statement per changed entity (or batches them), not a single combined statement — but they're still wrapped in one transaction together, which is what actually provides the guarantee.

Reinforcement: Explicit transactions are for spanning multiple SaveChanges() calls — a single call is already atomic by default.

2. What specific problem does adding a RowVersion concurrency token to the Account entity solve, that an explicit transaction does not?

Show answer

Correct: B

Why B is correct: A concurrency token addresses the "two users editing the same row" problem specifically — it lets EF Core detect, via a WHERE clause check against the row's current version, that the row changed since it was read, and refuse the stale save with DbUpdateConcurrencyException instead of silently overwriting the other change.

Why A is incorrect: Crash protection during a save is what transactions (implicit or explicit) provide — a concurrency token addresses a completely different problem (simultaneous edits, not partial failure).

Why C is incorrect: A concurrency token adds a small amount of overhead (an extra WHERE clause condition) — it doesn't skip any validation or improve save performance.

Why D is incorrect: EF Core's optimistic concurrency does not merge conflicting changes automatically — it detects the conflict and throws, leaving the actual resolution (retry, show current data, merge manually) to your application code.

Reinforcement: Concurrency tokens detect conflicts between separate operations touching the same row — a fundamentally different concern from transactional atomicity within one operation.

3. Why is EF Core's approach called "optimistic" concurrency rather than locking the row the moment it's read?

Show answer

Correct: A

Why A is correct: Optimistic concurrency assumes conflicts are uncommon, so it doesn't lock the row while someone is editing — it only checks whether the row's version still matches at the moment of the actual update. This avoids the throughput cost of locking, which is the tradeoff pessimistic (lock-up-front) concurrency would make instead.

Why B is incorrect: There's no notion of one save being "more optimistic" than another — the term describes the strategy (check late, don't lock early), not an outcome-favoring rule.

Why C is incorrect: EF Core does not automatically retry a failed concurrency check — it throws DbUpdateConcurrencyException and leaves retry logic entirely to your application code.

Why D is incorrect: Concurrency tokens work alongside a primary key, not instead of one — every entity in this lesson's examples still has a normal primary key plus a separate RowVersion token.

Reinforcement: "Optimistic" describes not locking upfront and only detecting conflicts at save time — the alternative, locking rows on read, is pessimistic concurrency, which EF Core doesn't provide out of the box because it fits far fewer real applications.

You now know how to keep multi-step saves atomic and how to protect shared rows from silent overwrites. Next up: the capstone of this module — deciding whether a repository layer belongs on top of everything you've learned.


dotnetmadeeasy.com — Learn C# and .NET, the right way.