BeginTransaction, Commit, Rollback was never the whole story — isolation levels decide exactly what a transaction lets other transactions see while it's running.
You already know the shape of a transaction cold: begin, run statements, commit or roll back. Intermediate's transactions lesson taught you that, and "Transactions and Concurrency" showed you EF Core's implicit per-SaveChanges() transaction plus how to wrap several saves in one explicit transaction. Here's the question neither of those lessons answered: while your transaction is running, exactly what can a different, concurrent transaction see of your in-progress, uncommitted work? "Isolation" was always the fourth letter of ACID — but "each transaction behaves as if it's running alone" was the entire depth you got. It turns out that promise comes in several distinct strengths, each with its own real trade-off, and picking the right one is a decision every production system with real concurrent traffic eventually has to make deliberately, not by accident.
In this lesson, you'll go deeper on explicit transactions spanning multiple SaveChanges() calls, learn the three classic isolation levels — Read Committed, Repeatable Read, and Serializable — precisely, meet System.Transactions.TransactionScope for coordinating across multiple resources, and see EF Core's savepoint support for nested transactions.
An isolation level is a dial that controls exactly how much a running transaction is shielded from the effects of other transactions running at the same time. Higher isolation means fewer surprising anomalies but more locking and less concurrent throughput. Lower isolation means more throughput but more of a specific, well-defined set of anomalies become possible. It's a genuine trade-off — there's no setting that gives you both maximum safety and maximum throughput for free.
Relational databases define isolation in terms of three specific anomalies a lower isolation level permits and a higher one prevents:
| Anomaly | What it means |
|---|---|
| Dirty read | Reading another transaction's uncommitted changes — data that might still be rolled back and never actually happen. |
| Non-repeatable read | Reading the same row twice within one transaction and getting different values, because another transaction committed a change to it in between your two reads. |
| Phantom read | Re-running the same query twice within one transaction and getting a different set of rows — new rows now match your filter, or previously-matching rows are gone — because another transaction inserted or deleted rows in between. |
Each of the three classic isolation levels this lesson covers is defined precisely by which of these three anomalies it prevents and which it still allows.
The theoretically "safest" possible isolation would run every transaction as if it were the only one happening — full, strict serial execution, one at a time, no exceptions. On a real production database handling hundreds or thousands of concurrent transactions, running every single one in strict isolation from every other would mean an enormous amount of blocking: transactions queuing up behind locks, throughput collapsing under load. Database engines instead offer a menu of isolation levels, each trading away protection against specific anomalies in exchange for real, measurable concurrency gains — and it's your job, as the application developer, to pick the level that matches what your specific operation actually needs, not to reflexively reach for the strictest setting "to be safe."
Read Committed, Repeatable Read, and Serializable each draw the line between "protected" and "allowed" at a different point along the three anomalies above. None of them is universally "the right one" — Read Committed is the sensible default for the overwhelming majority of ordinary operations; Repeatable Read and Serializable exist for the narrower set of operations where a specific stronger guarantee genuinely matters more than the concurrency it costs.
| Isolation Level | Dirty Reads | Non-Repeatable Reads | Phantom Reads | Concurrency Cost |
|---|---|---|---|---|
| Read Committed (SQL Server default) | Prevented | Possible | Possible | Low — the sensible default |
| Repeatable Read | Prevented | Prevented | Possible | Moderate — read locks held longer |
| Serializable | Prevented | Prevented | Prevented | High — real concurrency cost, more blocking |
Notice the progression: each level up prevents everything the level below it prevents, plus one more specific anomaly — and each step up trades away more concurrency to get there. Nothing about this table is arbitrary; it's the actual, standard definition every major relational database implements against.
"Transactions and Concurrency" showed you the basic shape — Database.BeginTransactionAsync(), several SaveChangesAsync() calls, then Commit or Rollback. A realistic multi-step workflow makes the value of this concrete: a subscription upgrade that needs to (1) close out the old subscription's billing period, (2) create a new subscription record, and (3) log a billing event referencing the new subscription's generated id — three logically-dependent steps, each with its own reason to be a separate SaveChanges() call rather than one giant batched change:
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync();
try
{
oldSubscription.EndedAtUtc = DateTime.UtcNow;
await context.SaveChangesAsync(); // 1st save
var newSubscription = new Subscription { CustomerId = customerId, Plan = newPlan };
context.Subscriptions.Add(newSubscription);
await context.SaveChangesAsync(); // 2nd save — newSubscription.Id now populated
context.BillingEvents.Add(new BillingEvent
{
SubscriptionId = newSubscription.Id,
Type = BillingEventType.Upgrade
});
await context.SaveChangesAsync(); // 3rd save — same transaction as the first two
await transaction.CommitAsync(); // all three become permanent together
}
catch
{
await transaction.RollbackAsync(); // undo all three, as if none ran
throw;
}Without the explicit transaction, a crash after the second save leaves a customer with a closed old subscription, a new subscription they were never billed an event for — a genuinely confusing, hard-to-reconcile state. With it, all three saves are governed by the exact same all-or-nothing guarantee a single SaveChanges() call already gets for free.
EF Core lets you specify an isolation level when beginning an explicit transaction:
await using IDbContextTransaction transaction =
await context.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
try
{
// Read a row, do some in-memory calculation based on it, read it again —
// RepeatableRead guarantees the second read matches the first.
Account account = await context.Accounts.FirstAsync(a => a.Id == accountId);
decimal projectedBalance = account.Balance - pendingCharge;
// ... some logic that decides whether to proceed ...
Account accountAgain = await context.Accounts.FirstAsync(a => a.Id == accountId);
// Under RepeatableRead, accountAgain.Balance is guaranteed identical to account.Balance —
// no other transaction's commit could have changed it in between.
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}| Isolation Level | Guarantees | Still Allows | Use when |
|---|---|---|---|
| ReadCommitted | You never see another transaction's uncommitted, in-progress data | Re-reading the same row can return a different (but always committed) value; re-running a query can return different rows | The vast majority of ordinary reads and writes — the correct default |
| RepeatableRead | Everything ReadCommitted guarantees, plus: a row you've already read within this transaction cannot be changed by another transaction until yours finishes | New rows can still appear or disappear from a re-run query (phantoms) — only rows you've already read are protected | Multi-step logic that reads the same specific row more than once and needs it to stay consistent across those reads |
| Serializable | Everything RepeatableRead guarantees, plus: even a re-run query returns the same set of rows — no phantoms | Nothing from the three classic anomalies — this is the strongest of the three | Rare, high-stakes multi-step logic where even a changing row-set between reads would be genuinely unacceptable |
Two concurrent transactions, both against the same Account row. Transaction A reads the balance twice, with Transaction B committing an update in between:
Neither outcome is "wrong" in the sense of a bug — both are the isolation level doing exactly what it promises. The real question is which behavior your specific operation actually needs.
Every transaction so far has coordinated changes within one database. Real systems sometimes need a change to a database and a change to a completely different resource — a message queue, another database, a separate service — to succeed or fail together. System.Transactions.TransactionScope is .NET's mechanism for that: wrap operations against multiple resource managers in one TransactionScope, and — if those resource managers support it — the runtime coordinates a distributed commit or rollback across all of them together, conceptually via a two-phase commit protocol.
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
await using (var context = new AppDbContext(options))
{
context.Orders.Add(order);
await context.SaveChangesAsync();
}
await messageQueue.PublishAsync(new OrderCreatedEvent(order.Id)); // a different resource entirely
scope.Complete(); // only now are BOTH the DB write and the publish considered committed
}Know this exists conceptually, and know its honest cost: coordinating a real commit across genuinely separate resources is distributed transaction territory — meaningfully more complex, often slower, and dependent on whether each resource manager actually supports being coordinated this way (many modern message queues don't participate in a classic two-phase commit at all). This is exactly the kind of complexity that has pushed most modern systems toward a different, more resilient pattern for this specific problem — the Outbox pattern — which a later Advanced part on distributed systems covers properly. For now, just recognize the name and the shape of the problem it solves: reliably getting a database change and an external side effect to agree, without a full distributed transaction's cost and fragility.
Within a single database, EF Core supports savepoints — a marker inside an already-open transaction that you can roll back to, without discarding the entire transaction:
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync();
try
{
context.Orders.Add(order);
await context.SaveChangesAsync();
await transaction.CreateSavepointAsync("AfterOrderInsert");
context.OrderLines.AddRange(lines);
await context.SaveChangesAsync();
// If something about the line items specifically goes wrong:
// await transaction.RollbackToSavepointAsync("AfterOrderInsert");
// — undoes only the line items, keeps the order insert intact, still inside the same transaction
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}A savepoint is narrower than beginning a whole new nested transaction (which most relational databases don't truly support as independent, separately-committable units) — it's a partial-rollback point within one single transaction, useful when a later step in a multi-step transaction might fail in a way you want to recover from without discarding everything that came before it.
Read Committed is like other people being free to rearrange furniture in the room between your glances at it — every time you look, you see the current, real, finished state of the room (never a half-moved chair mid-carry), but you can't assume the couch is still where it was the last time you looked.
Repeatable Read is like putting a "do not touch" sticky note on any specific piece of furniture the moment you look at it — nobody can move that item again until you leave, though someone could still walk in and drop a brand-new chair in the corner you hadn't looked at yet.
Serializable is like locking the whole room the moment you walk in — nothing in it, seen or unseen, can change until you're done, guaranteeing the room looks exactly the same on your way out as it did coming in, at the cost of everyone else waiting outside the door.
Serializable isn't "more correct" in some absolute sense — it's the right choice for a narrow set of operations that genuinely need that specific guarantee, and an expensive, throughput-hurting overcorrection for everything else. Read Committed is the deliberate, sensible default for a reason: most operations never encounter the anomalies it allows in any way that matters to their correctness, and paying Serializable's concurrency cost everywhere would be a real, unnecessary tax on the entire system.
They address genuinely different layers of the same broader "concurrent access" concern. An isolation level governs what one transaction can see of other concurrent transactions while it's running. Optimistic concurrency (covered in full in the next lesson) governs what happens when two separate operations both try to write to the same row — detecting that conflict at save time, regardless of isolation level. You can — and often should — use both together; neither replaces the other.
Wrapping every transaction in IsolationLevel.Serializable without a specific reason, then wondering why the application's throughput collapses under real concurrent load. Start at Read Committed (the sensible default) and only raise the isolation level for a specific operation once you've identified a specific anomaly (non-repeatable or phantom reads) that would actually cause a real problem for that operation.
Wrapping a database write and a message-queue publish in a TransactionScope as a quick fix, without realizing this may require the transaction to escalate to a full distributed transaction coordinator, or that many modern queues don't even support participating in a two-phase commit at all — silently defeating the atomicity you thought you were getting. Understand this is genuine distributed-transaction complexity before reaching for it, and know that the Outbox pattern — covered later — is the modern, more resilient answer to this exact problem for most applications.
Beginning a Repeatable Read or Serializable transaction, then doing slow, unrelated work (an external API call, user think-time) before committing — every extra second extends exactly how long other transactions are blocked. The "keep transactions short" rule from Intermediate matters even more at higher isolation levels — the locks held are broader and the concurrency cost of holding them longer is correspondingly higher.
| Situation | Reach for |
|---|---|
| The overwhelming majority of ordinary reads and writes | Read Committed — the default, no action needed |
| Multi-step logic that reads the same specific row(s) more than once and needs consistency across those reads | Repeatable Read |
| Rare, high-stakes logic where even the row-set matching a re-run query must not change mid-transaction | Serializable — accepted only with eyes open about the concurrency cost |
| A change spanning a database and a genuinely separate resource (a queue, another service) | Understand TransactionScope conceptually; prefer the Outbox pattern in practice |
| A multi-step transaction where a later step might need to be undone without discarding earlier, already-successful steps | A savepoint via CreateSavepointAsync |
You've gone deep on isolation levels and the tools around multi-step, multi-resource transactions. Let's confirm it clicked.
1. A transaction reads a row's value, and later in the same transaction reads that same row again — getting a different value because another transaction committed a change to it in between. Which anomaly is this, and which isolation level would have prevented it?
Correct: B
Why B is correct: Reading the same row twice within one transaction and getting two different (both committed) values is the textbook definition of a non-repeatable read. Repeatable Read specifically prevents this by holding a lock or version pin on any row your transaction has already read, blocking other transactions from changing it until yours finishes. Serializable, being strictly stronger, prevents it too.
Why A is incorrect: A dirty read is seeing another transaction's uncommitted data — here, the other transaction's change was already committed by the time the second read happened, which rules out a dirty read.
Why C is incorrect: A phantom read is about a query's row-set changing between re-runs (new/missing rows), not about a single already-read row's value changing — and Read Committed does not prevent this anomaly at all, it's the level that explicitly allows it.
Why D is incorrect: This is exactly the anomaly Read Committed (the default) permits — re-reading the same row can absolutely return a different value under Read Committed.
Reinforcement: Match the anomaly to its precise definition — dirty (uncommitted), non-repeatable (same row, different value), phantom (different row set) — each is prevented starting at a specific isolation level.
2. Why does Serializable carry a higher concurrency cost than Repeatable Read, even though both prevent non-repeatable reads?
Correct: B
Why B is correct: To prevent phantom reads, the engine must guard against new rows being inserted into the range a query matched against — not just protect rows that were already read, which is all Repeatable Read does. Guarding a range is a broader, more expensive form of locking than pinning specific already-read rows.
Why A is incorrect: Serializable doesn't lock the whole database — it guards the specific ranges relevant to the transaction's queries, which is still narrower than a whole-database lock, though broader than Repeatable Read's per-row locking.
Why C is incorrect: There's a genuine mechanical difference — range-guarding versus per-row locking — not just a naming distinction.
Why D is incorrect: Isolation levels have no relationship to connection pooling — pooling operates entirely independently of what isolation level a transaction uses.
Reinforcement: Each step up in isolation level enforces a broader guarantee through broader locking — that's precisely why it costs more concurrency.
3. A team wants a database write and a message-queue publish to succeed or fail together. What does this lesson say about reaching for TransactionScope for this?
Correct: B
Why B is correct: TransactionScope genuinely can coordinate a commit across a database and another resource manager, but doing so is real distributed-transaction territory — more complex, and not every resource (many modern queues included) even supports participating in it. The lesson names the Outbox pattern as the more modern, resilient answer most systems reach for instead.
Why A is incorrect: The lesson is explicit that this introduces real complexity and cost — it is not presented as a simple, trade-off-free solution.
Why C is incorrect: TransactionScope's whole purpose is coordinating across multiple resource managers, potentially including things beyond a single database — the limitation is whether each resource manager actually supports participating, not a hard restriction to one database.
Why D is incorrect: EF Core's implicit transaction only ever covers the database changes within one SaveChanges() call — it has no awareness of, or effect on, an entirely separate resource like a message queue.
Reinforcement: Recognize TransactionScope's real purpose and real cost, and recognize the Outbox pattern by name as where this problem is more properly solved later in the curriculum.
You now understand isolation levels precisely, and the honest shape of multi-resource transaction coordination. Next up: concurrency, taken deeper than a RowVersion token and an exception type.
dotnetmadeeasy.com — Learn C# and .NET, the right way.