Skip to main content
ANVISoftware Solutions
Lesson 16 of 22Intermediate20 min

Transactions and Isolation

By the end of this lesson

Keep related changes consistent and choose an isolation level deliberately.

Some changes only make sense together. Reducing stock and recording the order line are two statements describing one event. If the first succeeds and the second fails, your stock figures are wrong and nothing says so.

A transaction makes several statements one unit. Either all of them take effect, or none do.

One unit of work
SQL
BEGIN TRANSACTION;

INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (1043, 318, 5, 11.49);

UPDATE products
SET units_in_stock = units_in_stock - 5
WHERE product_id = 318;

COMMIT;
  • Between BEGIN and COMMIT the changes are visible only to this session. Other sessions see the previous state.
  • COMMIT makes them permanent and visible to everyone, together.
  • ROLLBACK instead of COMMIT discards both. A failure part-way cannot leave stock reduced for a line that was never inserted.

ACID, in plain language

Four guarantees a transactional database gives you. The acronym is less useful than the meanings:

Atomic — all or nothing
The transaction is indivisible. Half of it can never be left applied, whether the failure is an error, a constraint violation, or the server losing power.
Consistent — rules always hold
A transaction moves the database from one valid state to another. Constraints, foreign keys and checks are satisfied at the end; a transaction that would break one is rejected.
Isolated — concurrent work does not interfere
Transactions running at the same time behave as though they were not aware of each other. How strictly this holds is exactly what isolation levels control, and it is the part with real trade-offs.
Durable — committed means committed
Once COMMIT returns, the change survives a crash. The database writes the change to a log on disk before reporting success, which is why a commit is not instant.

What goes wrong without isolation

Two transactions running at once can interfere in three specific ways. Each has a name, and each isolation level is defined by which of them it prevents. Understanding the anomalies first makes the levels obvious.

The three anomalies, each with the sequence that produces it:

Dirty read
Transaction A updates a product price to 20.00 but has not committed. Transaction B reads 20.00. A then rolls back. B acted on a price that never existed.
Non-repeatable read
Transaction B reads a product price and gets 11.49. Transaction A updates it to 12.99 and commits. B reads the same row again and gets 12.99. The same row gave two answers inside one transaction.
Phantom read
Transaction B counts pending orders and gets 40. Transaction A inserts a new pending order and commits. B runs the same count and gets 41. No row B read has changed — a new row appeared that matches its filter.

The four standard levels, from least to most strict. Each prevents more and permits less concurrency:

 Level and what it preventsWhat it costs
READ UNCOMMITTEDPrevents nothing. Dirty, non-repeatable and phantom reads are all possible.Almost no read locking, so it interferes least. You may read data that is rolled back moments later.
READ COMMITTEDPrevents dirty reads. You only ever see committed data.Low cost, and the default in SQL Server, PostgreSQL and Oracle. Non-repeatable and phantom reads remain possible.
REPEATABLE READAlso prevents non-repeatable reads. A row you read will not change before you commit.Holds locks on every row read until the transaction ends, so other writers wait longer. Phantoms are still possible.
SERIALIZABLEPrevents all three, including phantoms. Results are as though transactions ran one after another.Locks ranges rather than rows, so it blocks inserts into ranges you have read. Lowest concurrency and the highest chance of blocking or deadlock.
SNAPSHOT (SQL Server) / REPEATABLE READ via MVCC (PostgreSQL)Readers see a consistent point-in-time view and are not blocked by writers.Requires version storage — tempdb in SQL Server — and write conflicts must be handled by retrying the transaction.

The pattern is a single trade: stricter isolation means more locking, which means less work happening at the same time. There is no level that is correct for everything, which is why the default is a middle position rather than the strictest one.

Start with the default, READ COMMITTED. Raise the level for a specific transaction where you can state which anomaly you are preventing and why it matters. Raising it globally because it sounds safer buys blocking you did not need.

Setting a level for one transaction, and why this one needs it
SQL
-- Stock check then reserve: the count must not change underneath us
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

BEGIN TRANSACTION;

DECLARE @available INT;

SELECT @available = units_in_stock
FROM products
WHERE product_id = 318;

IF @available >= 5
BEGIN
    UPDATE products
    SET units_in_stock = units_in_stock - 5
    WHERE product_id = 318;

    INSERT INTO order_items (order_id, product_id, quantity, unit_price)
    VALUES (1043, 318, 5, 11.49);

    COMMIT;
END
ELSE
BEGIN
    ROLLBACK;
END;
  • Reading the stock and then acting on it is a read-then-write sequence. Under READ COMMITTED, another transaction can reduce the stock between those two statements, so both sessions pass the check and stock goes negative.
  • REPEATABLE READ holds a lock on the row from the moment it is read, so the second session waits. The cost is exactly that waiting.
  • There is a simpler alternative worth preferring here: skip the read, and write UPDATE products SET units_in_stock = units_in_stock - 5 WHERE product_id = 318 AND units_in_stock >= 5, then check @@ROWCOUNT. One atomic statement, no elevated isolation, no lock held across two statements.
  • That is the general lesson. Before raising an isolation level, check whether the sequence can be expressed as one statement.

Deadlocks

A deadlock is two transactions each waiting for a lock the other holds. A updates products then orders; B updates orders then products. Neither can proceed and neither will give up.

The database detects this and kills one of them, which receives an error. Nothing is corrupted — the victim is rolled back completely.

Two things reduce deadlocks. Touch tables in the same order everywhere, so two transactions cannot hold each other's next lock. And keep transactions short, because a lock held for 50 milliseconds collides with far less than one held for 5 seconds.

Application code that writes to the database should be prepared to retry a deadlock victim. It is not a bug to eliminate entirely; it is a condition to handle.

Summary

  • A transaction makes several statements one all-or-nothing unit; a single statement is already atomic
  • ACID means all-or-nothing, rules always satisfied, concurrent work kept apart, and committed changes surviving a crash
  • Three anomalies define the isolation levels: dirty read, non-repeatable read and phantom read
  • Each step up in isolation prevents one more anomaly and costs concurrency through more locking
  • Keep transactions short, touch tables in a consistent order, and be ready to retry a deadlock victim

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Think about it

A nightly job reads every pending order, calculates a total for each, and writes the totals to a summary table. It runs in one transaction at SERIALIZABLE and takes 20 minutes. Support reports that the website hangs while it runs.

Explain the link, then suggest two changes.

Show solution

SERIALIZABLE locks the ranges it reads, and the transaction holds those locks for its full 20 minutes. Website traffic inserting new pending orders is blocked, because inserting into a range the job has read is exactly what SERIALIZABLE prevents.

First change: lower the level. This job reads and summarises; it does not need protection against phantoms, because a new order arriving mid-run appearing in tomorrow's summary instead of tonight's is not a correctness problem. READ COMMITTED would remove most of the blocking.

Second change: break the work into batches, each its own short transaction — a thousand orders at a time. Locks are then held for seconds rather than 20 minutes, and website traffic interleaves with it.

There is a trade-off to state, not hide: batching means the summary table is partially updated while the job runs, so a reader mid-run sees an incomplete picture. If that is unacceptable, write to a staging table and swap it in at the end. That is a deliberate design choice, and it is the kind of decision this lesson is preparing you to make.

Try it yourself

Try it yourself

Write a transaction that moves an order item from one order to another: it must change the order_id on the item and record the move in a log table. Handle the case where the target order does not exist or is not pending.

Show solution

Both writes belong in one transaction. An item moved with no log entry, or a log entry for a move that did not happen, are both worse than the operation failing cleanly.

Validating the target order before the update prevents moving an item onto a shipped order. A foreign key confirms the order exists; it cannot check the status, so that rule needs a statement.

Checking @@ROWCOUNT after the UPDATE distinguishes "moved" from "there was no such item". Without it, a mistyped order_item_id logs a move that never occurred.

The transaction is short — two statements and a validation — which is what keeps locks brief. Doing the validation inside the transaction rather than before it is deliberate: it means the status cannot change between the check and the update.

SQL
BEGIN TRY
    BEGIN TRANSACTION;

    IF NOT EXISTS (
        SELECT 1 FROM orders WHERE order_id = 1088 AND status = 'pending'
    )
        THROW 50020, 'Target order does not exist or is not pending.', 1;

    UPDATE order_items
    SET order_id = 1088
    WHERE order_item_id = 5002;

    IF @@ROWCOUNT = 0
        THROW 50021, 'Order item not found.', 1;

    INSERT INTO order_item_moves (order_item_id, from_order_id, to_order_id, moved_at)
    VALUES (5002, 1043, 1088, SYSUTCDATETIME());

    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    IF XACT_STATE() <> 0
        ROLLBACK TRANSACTION;
    THROW;
END CATCH;

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Which anomaly does READ COMMITTED prevent?
What is the main cost of raising the isolation level to SERIALIZABLE?

Saved in this browser only.