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

Two updates that are supposed to happen together will, eventually, only half-happen — unless you explicitly tell the database to treat them as one.

Imagine a "transfer money" feature between two bank accounts. The logic is simple enough: subtract $500 from Account A, add $500 to Account B. Two SQL statements, run one after another:

UPDATE Accounts SET Balance = Balance - 500 WHERE Id = @fromAccountId; UPDATE Accounts SET Balance = Balance + 500 WHERE Id = @toAccountId;

Now imagine the server loses power, the process crashes, or the network connection drops between those two statements — after the first one commits, before the second one runs. Account A is now $500 lighter. Account B never received it. $500 has vanished from the system entirely, with no error message, no exception even — just quietly gone.

This isn't a rare, exotic failure. Processes crash, connections drop, servers restart for maintenance, and it happens at the worst possible moment purely by bad luck. Any time your application logic requires more than one database change to happen together, you have exactly this risk — and it's a risk with a very well-established, very reliable fix: transactions.

In this lesson, you'll learn what a database transaction is, the ACID properties that describe what it guarantees, and how to use DbTransaction with BeginTransaction, Commit, and Rollback to make multi-step operations safe.

What Is It?

The Simple Explanation

A transaction is a way of telling the database: "treat everything I'm about to do as a single, indivisible unit. Either all of it happens, or none of it happens — never leave it half-done." You start one, run your statements, and then either commit (make every change permanent, all at once) or roll back (undo every change made since the transaction started, as if none of it ever ran).

The Technical Definition

In ADO.NET, a transaction is represented by DbTransaction (concretely, SqlTransaction for SQL Server). You obtain one by calling connection.BeginTransaction() on an already-open connection, attach it to every DbCommand you run as part of that unit of work via command.Transaction = transaction, and finish with either transaction.Commit() or transaction.Rollback().

await using SqlTransaction transaction = (SqlTransaction)await connection.BeginTransactionAsync(); try { // every command that should be part of this all-or-nothing unit: command.Transaction = transaction; await command.ExecuteNonQueryAsync(); await transaction.CommitAsync(); // make it all permanent } catch { await transaction.RollbackAsync(); // undo everything since BeginTransaction throw; }

Why Does It Exist?

The Problem — Multi-Step Operations Can Fail Halfway Through

Without a transaction, every SQL statement you send is its own independent, immediately-permanent change. If your business operation genuinely requires several statements to succeed together — debit one account and credit another, decrement stock and create an order row, insert a parent row and its child rows — then any interruption between those statements (a crash, a dropped connection, an unhandled exception, even another concurrent process interfering) can leave the database in a state your application logic never intended to produce and never accounted for.

The Solution — Group Statements Into One Atomic Unit

A transaction groups multiple statements so the database treats them as a single unit for the purpose of durability and visibility: either every statement's effect becomes permanent together (a commit), or none of them do (a rollback, or the transaction simply never being committed before the connection drops). There is no state in between where only some of the statements have taken effect.

The Four Guarantees — ACID

Transactions are formally described by four properties, commonly abbreviated ACID:

PropertyGuarantee
AtomicityAll statements in the transaction succeed together, or none of them take effect — no partial result.
ConsistencyA transaction moves the database from one valid state to another, never violating constraints (foreign keys, unique indexes, check constraints) along the way.
IsolationConcurrent transactions don't see each other's uncommitted, in-progress changes — to a large extent, each transaction behaves as if it's running alone.
DurabilityOnce a transaction is committed, its changes survive — even a server crash immediately afterward won't lose a committed change.

You don't implement ACID yourself — the database engine provides it. Your job is just to correctly mark the boundaries: where a transaction starts, and whether it ends in Commit() or Rollback().

Big Picture

Without a Transaction

With a Transaction

The crash is identical in both scenarios. The outcome is completely different, because the transaction ensured the debit was never made permanent on its own.

How It Works

TRANSACTION LIFECYCLE, STEP BY STEP
Step 1 — Open a connection, then begin a transaction on it
await connection.OpenAsync();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
Step 2 — Attach the transaction to every command that belongs to this unit of work
command.Transaction = transaction;
Step 3 — Run the statements; nothing is permanent yet
Step 4a — All succeeded → Commit()
Step 4b — Something failed → Rollback()

Simple Example

public async Task TransferAsync(SqlConnection connection, int fromAccountId, int toAccountId, decimal amount) { await using SqlTransaction transaction = (SqlTransaction)await connection.BeginTransactionAsync(); try { await using SqlCommand debit = connection.CreateCommand(); debit.Transaction = transaction; debit.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id AND Balance >= @amount"; debit.Parameters.AddWithValue("@amount", amount); debit.Parameters.AddWithValue("@id", fromAccountId); int rowsDebited = await debit.ExecuteNonQueryAsync(); if (rowsDebited == 0) throw new InvalidOperationException("Insufficient funds or account not found."); await using SqlCommand credit = connection.CreateCommand(); credit.Transaction = transaction; credit.CommandText = "UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @id"; credit.Parameters.AddWithValue("@amount", amount); credit.Parameters.AddWithValue("@id", toAccountId); await credit.ExecuteNonQueryAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; // let the caller know the transfer didn't happen } }

Two details worth pausing on: the debit's WHERE clause includes AND Balance >= @amount so an account can never go negative, and checking rowsDebited == 0 (recall ExecuteNonQuery's return value from the previous lesson) is what triggers the rollback for insufficient funds — before the credit ever runs.

Real-World Example

An e-commerce checkout is another textbook transaction scenario: creating an Order row, inserting one or more OrderLine rows, and decrementing Products.Stock for each item all need to succeed together. If stock decrement fails for one item (say, a concurrent sale just sold the last unit), the order and any order lines already inserted must be rolled back too — otherwise the customer has a confirmed order for an item that's no longer actually available, or an order that's missing half its line items.

await using SqlTransaction transaction = (SqlTransaction)await connection.BeginTransactionAsync(); try { int orderId = await InsertOrderAsync(connection, transaction, customerId); foreach (var item in cartItems) { await InsertOrderLineAsync(connection, transaction, orderId, item); int rowsUpdated = await DecrementStockAsync(connection, transaction, item.ProductId, item.Quantity); if (rowsUpdated == 0) throw new InvalidOperationException($"Product {item.ProductId} is out of stock."); } await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }

Notice this is the exact same all-or-nothing structure as the bank transfer — a different domain, the same underlying need. This pattern comes up constantly: any time "step 2 only makes sense if step 1 also happened," you're looking at a transaction boundary.

Analogy

A Draft, Not Yet Published

Think of a transaction like editing a shared document in draft mode. While you're making changes, nobody else sees them — they're still visible only to you, in your draft. If you decide the edit was a mistake halfway through, you can discard the whole draft and the document reverts to exactly what it was before you started — none of your half-finished edits leak through.

Only when you hit "Publish" (Commit()) do all your changes become visible to everyone at once, as a single, complete update. There's no moment where other readers see half your edit — that's isolation and atomicity working together, the same guarantee a database transaction gives your SQL statements.

Where the analogy stops: a real "discard draft" in a document editor is a manual choice you make. A database transaction is discarded (rolled back) automatically the moment something goes wrong and you never called Commit() — you don't have to remember to clean up the partial changes yourself.

Under the Hood

HOW THE ENGINE MAKES ROLLBACK POSSIBLE
1. Every change is written to a transaction log first
2. Locks (or row versions) provide isolation
3. If the connection simply drops without a Commit(), the transaction is rolled back automatically

Common Confusion

1. "A transaction means my C# code runs atomically too"

No — a transaction only governs what the database does. If your C# method sends an email, calls another web API, or writes a file in the middle of a database transaction, none of that is rolled back if the transaction later fails. Keep non-database side effects (emails, external API calls) outside the transaction, or trigger them only after a successful Commit().

2. "Every database call needs an explicit transaction"

A single standalone statement is already atomic on its own — SQL Server implicitly wraps every individual statement in its own transaction even if you never call BeginTransaction. You only need an explicit transaction when two or more statements must succeed or fail together as one unit.

Common Mistakes

Mistake 1 — Forgetting to attach the transaction to a command

Calling connection.BeginTransaction() but forgetting command.Transaction = transaction; on one of the commands. That command runs outside the transaction and commits immediately on its own, regardless of what happens to the rest — a subtle bug that's easy to miss until it causes exactly the kind of partial update transactions exist to prevent. Set Transaction on every command sharing the unit of work — some teams write a small helper method that always sets it, precisely to avoid forgetting.

Mistake 2 — Not rolling back in a catch block (or not using try/catch at all)

Letting an exception propagate up without calling Rollback() first, and without disposing the transaction. If the connection stays open elsewhere, the transaction can be left dangling, holding locks other operations are now waiting on. Always wrap transaction work in try/catch, roll back explicitly on failure, and use await using on the transaction so it's disposed even if you forget the explicit rollback.

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

Beginning a transaction, then calling a slow external API or waiting on user input before running the remaining statements and committing. The longer a transaction stays open, the longer it holds locks on the affected rows — blocking other operations that need those same rows. Keep the time between BeginTransaction and Commit/Rollback as short as possible — do slow, non-database work before starting the transaction or after it ends.

Mistake 4 — Wrapping a single statement in a transaction "just in case"

Adding BeginTransaction/Commit around one single UPDATE statement. It adds ceremony and a small amount of overhead without adding any real safety — that one statement was already atomic on its own. Reach for an explicit transaction only when two or more statements genuinely need to succeed or fail together.

When Should I Use It?

ScenarioNeed an explicit transaction?
A single INSERT/UPDATE/DELETE statementNo — it's already atomic by itself
Debiting one account and crediting anotherYes — both must succeed or neither should
Inserting an order plus its order lines plus a stock decrementYes — a partial insert would corrupt the order
A read-only SELECT queryNo — nothing is being changed
Two unrelated updates to two unrelated tables that have no logical connectionUsually no — forcing them together adds coupling and lock contention for no real benefit
Rule of thumb: Ask "if step 2 fails, should step 1's effect still be permanent?" If the honest answer is no, those steps belong inside one transaction.

Mental Model

BeginTransaction() = start a draft — nothing is visible to anyone else yet.
Commit() = publish the whole draft at once, permanently.
Rollback() = discard the draft entirely — as if none of it happened.

Remember: ACID is the promise the database keeps for you — Atomicity (all or nothing), Consistency (never an invalid state), Isolation (others don't see it mid-flight), Durability (once committed, it survives a crash).

Key Takeaway


Check Your Understanding

You've seen why the bank-transfer scenario needs a transaction, and how ACID describes the guarantee. Let's confirm it clicked.

1. In the bank transfer example, the debit statement succeeds and the process crashes before the credit statement runs. If both statements ran inside a transaction that was never committed, what state is the database in after the crash?

Show answer

Correct: B

Why B is correct: Because the changes were never committed, atomicity guarantees that none of them became permanent. The crash leaves the uncommitted transaction rolled back — both accounts are exactly as they were before the transfer began.

Why A is incorrect: This is exactly the outcome a transaction prevents — it would be the result if the two statements had run without any transaction wrapping them.

Why C is incorrect: The database isn't corrupted — rollback is a normal, built-in recovery mechanism, not a manual repair process.

Why D is incorrect: The credit statement never even ran, since the crash happened before it — there's no way its effect could appear.

Reinforcement: An uncommitted transaction is treated as if it never happened at all — that's exactly the safety property atomicity provides.

2. Which ACID property specifically guarantees that once a transaction's Commit() call returns successfully, its changes will survive even an immediate server crash?

Show answer

Correct: D

Why D is correct: Durability is specifically the promise that once a transaction is committed, its changes are permanent — a crash immediately afterward cannot undo them, because the change was already durably recorded before Commit() returned.

Why A is incorrect: Atomicity is about all-or-nothing execution of the statements within the transaction, not about survival after a crash post-commit.

Why B is incorrect: Consistency is about the database never landing in a state that violates its rules/constraints — it's not specifically about crash survival.

Why C is incorrect: Isolation is about concurrent transactions not seeing each other's uncommitted changes — unrelated to what happens after a successful commit.

Reinforcement: Each ACID letter maps to a distinct guarantee — durability specifically covers "survives a crash after commit."

3. A developer begins a transaction, runs an UPDATE, then calls a slow third-party payment API, and only afterward runs a second UPDATE and commits. What's the main problem with this design?

Show answer

Correct: B

Why B is correct: A transaction holds locks on the rows it has modified for as long as it stays open. Doing slow, unrelated work (like an external API call) in the middle of a transaction needlessly extends how long those rows stay locked, increasing contention for anything else that needs them.

Why A is incorrect: Transactions can contain any number of statements — that's the whole point of grouping multiple statements into one unit.

Why C is incorrect: A transaction only governs database changes — an external API call is not part of the database and is never rolled back by a database transaction, regardless of when it happens relative to the transaction.

Why D is incorrect: Ordinary C# code can run between statements inside a transaction — the problem isn't that it's disallowed, it's that doing so while the transaction is open extends lock duration.

Reinforcement: Keep transactions short — do slow or external work before starting one or after it ends, not in the middle.

4. Why does a single, standalone UPDATE statement not need an explicit BeginTransaction()/Commit() call?

Show answer

Correct: B

Why B is correct: The database engine already wraps every individual statement in its own implicit transaction — it either fully applies or doesn't apply at all. Explicit transactions become necessary only when multiple statements must succeed or fail together as one unit.

Why A is incorrect: UPDATE statements are fully covered by ACID guarantees — there's no exemption; a single UPDATE is simply already atomic without needing an explicit wrapper.

Why C is incorrect: Explicit transactions work with any combination of statements — INSERT, UPDATE, DELETE, and even SELECT can all participate in a transaction.

Why D is incorrect: An UPDATE inside an explicit transaction absolutely can be rolled back — that's exactly what makes transactions useful in the first place.

Reinforcement: Reach for an explicit transaction specifically when two or more statements need to succeed or fail as a single unit — not for every single statement by default.

You now understand transactions, ACID, and how to keep multi-step database operations safe. Next up: leaving raw ADO.NET behind and meeting Entity Framework Core.


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