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.
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.
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.
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.
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."
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.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Situation | Use |
|---|---|
| One SaveChanges() call, any number of entity changes | Nothing — the implicit transaction already covers this |
| A workflow that must call SaveChanges() more than once, and all calls must succeed or fail together | An 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 concurrently | Neither — the overhead isn't worth it |
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?
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?
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?
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.