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

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

Relational databases define isolation in terms of three specific anomalies a lower isolation level permits and a higher one prevents:

AnomalyWhat it means
Dirty readReading another transaction's uncommitted changes — data that might still be rolled back and never actually happen.
Non-repeatable readReading 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 readRe-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.

Why Does It Exist?

The Problem — Perfect Isolation Between Every Concurrent Transaction Is Expensive

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."

The Solution — a Menu of Isolation Levels, Chosen Deliberately Per Operation

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.

Big Picture

Isolation LevelDirty ReadsNon-Repeatable ReadsPhantom ReadsConcurrency Cost
Read Committed (SQL Server default) Prevented Possible PossibleLow — the sensible default
Repeatable Read Prevented Prevented PossibleModerate — read locks held longer
Serializable Prevented Prevented PreventedHigh — 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.

How It Works — Explicit Transactions Across Multiple SaveChanges() Calls, In Depth

"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.

How It Works — Choosing an Isolation Level

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 LevelGuaranteesStill AllowsUse when
ReadCommittedYou never see another transaction's uncommitted, in-progress dataRe-reading the same row can return a different (but always committed) value; re-running a query can return different rowsThe vast majority of ordinary reads and writes — the correct default
RepeatableReadEverything ReadCommitted guarantees, plus: a row you've already read within this transaction cannot be changed by another transaction until yours finishesNew rows can still appear or disappear from a re-run query (phantoms) — only rows you've already read are protectedMulti-step logic that reads the same specific row more than once and needs it to stay consistent across those reads
SerializableEverything RepeatableRead guarantees, plus: even a re-run query returns the same set of rows — no phantomsNothing from the three classic anomalies — this is the strongest of the threeRare, high-stakes multi-step logic where even a changing row-set between reads would be genuinely unacceptable

Simple Example — Seeing the Difference an Isolation Level Makes

Two concurrent transactions, both against the same Account row. Transaction A reads the balance twice, with Transaction B committing an update in between:

Under Read Committed

Under Repeatable Read

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.

Real-World Example — TransactionScope and the Outbox Pattern, in Context

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.

Nested Transactions and Savepoints in EF Core

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.

Analogy

How Much of the Room Can Others Rearrange While You're In 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.

Under the Hood

HOW THE ENGINE ACTUALLY ENFORCES EACH LEVEL
1. Read Committed only ever exposes committed data
2. Repeatable Read holds read locks (or row versions) for the whole transaction, not just the instant of the read
3. Serializable extends the lock to the query's whole matching range, not just the rows it found
4. Savepoints are markers within the same underlying transaction log

Common Confusion

1. "Serializable is the 'correct' isolation level, and everything below it is a compromise cutting corners"

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.

2. "Optimistic concurrency (RowVersion) and isolation levels solve the same problem"

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.

Common Mistakes

Mistake 1 — Defaulting to Serializable everywhere "to be extra safe"

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.

Mistake 2 — Reaching for TransactionScope across multiple resources without understanding the cost

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.

Mistake 3 — Holding a high-isolation transaction open across slow, unrelated work

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.

When Should I Use It?

SituationReach for
The overwhelming majority of ordinary reads and writesRead Committed — the default, no action needed
Multi-step logic that reads the same specific row(s) more than once and needs consistency across those readsRepeatable Read
Rare, high-stakes logic where even the row-set matching a re-run query must not change mid-transactionSerializable — 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 stepsA savepoint via CreateSavepointAsync
Rule of thumb: Start at Read Committed. Raise the isolation level only for the specific operation that needs a stronger guarantee, and only after naming exactly which anomaly you're protecting against — never as a blanket, "safer by default" setting.

Mental Model

Read Committed = never see uncommitted data — but re-reads can still change.
Repeatable Read = rows you've read stay put — but new rows can still appear.
Serializable = nothing changes underneath you at all — at the highest concurrency cost.
TransactionScope = coordinating across multiple resources — real distributed complexity; Outbox is the modern alternative.
Savepoint = a partial rollback point inside one transaction, not a separate nested transaction.

Remember: every step up in isolation trades concurrency for a stronger guarantee — pick the level the operation actually needs, not the strongest one available.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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.