Leer en español · Lire en français · Auf Deutsch lesen
Section 7.3 of 7.8 · Advanced · 28 min read
Transactions, ACID and isolation levels
Video lesson
5 min · narrated, with captionsListen only (audio)
Read the transcript
Concurrency bugs pass every test on a quiet machine, then appear at the dinner-time peak. In this lesson you'll learn what transactions really guarantee when many run at once, and how to protect your rules.
Imagine two cashiers sharing one paper ledger for a customer's account. Each looks up the balance, works out the new one on a notepad, and writes it in. If they do this at the same moment, one cashier's change is lost. A transaction comes with rules about how the cashiers take turns: whether they may peek at each other's half-written entries, and whether one must wait. Stricter rules mean fewer mistakes but more waiting. And, as you'll see, the names for these rules mean slightly different things in different databases.
A transaction groups reads and writes into one unit, and you've probably heard the acronym ACID. Atomicity is about failure: if anything goes wrong part way, every change is undone, so you can safely retry. Consistency means your rules hold, such as debits equalling credits. The database enforces keys and constraints, but most business rules are kept only by your own transactions. Isolation means concurrent transactions don't interfere. In its strictest form, serializable, the result is as if they ran one at a time. Weaker levels allow some interference, for speed. And durability means that once a commit is acknowledged, the data survives a crash.
The most common concurrency bug in business code is the lost update. Here, two withdrawals hit an account holding one hundred pounds. The first transaction, withdrawing thirty, reads the balance and sees one hundred. Moments later, the second, withdrawing fifty, also reads one hundred. The first writes seventy: one hundred minus thirty. Then the second writes fifty, one hundred minus fifty, silently overwriting the first write. The balance should be twenty. A withdrawal has been lost, even though each transaction was correct on its own.
Most modern databases give each transaction a consistent snapshot, using multi-version concurrency control. Instead of overwriting a row, each write creates a new version, tagged with the transaction number that wrote it. A snapshot taken at transaction one hundred and five sees the newest version committed before then: a balance of seventy. A later snapshot, at one hundred and twenty, sees twenty. So readers never wait for writers, and writers never wait for readers. The cost is housekeeping: a transaction left open for hours keeps old versions alive, and storage bloats.
Reading from a snapshot is called snapshot isolation. It prevents lost updates, but it allows a subtler anomaly called write skew, so it is not fully serializable. Isolation level names are not portable, and the defaults differ. PostgreSQL and Oracle default to read committed, MySQL to repeatable read, and CockroachDB to serializable. Worse, repeatable read means different things in PostgreSQL and MySQL. MySQL's can still lose an update written as a read followed by a write. And Oracle's serializable is really snapshot isolation. Code that is correct on one database can be wrong on another with an identically named setting.
Here's write skew in action. A hospital rota has one rule: at least one doctor must be on call. Alice and Bob are on call, and both press go off call at almost the same moment. Each request counts the doctors on call. If there are at least two, it sets its own row to off call. Under snapshot isolation, both snapshots show two doctors on call, so both checks pass. Alice's transaction updates Alice's row, and Bob's updates Bob's. Different rows, so no conflict is detected. Both commit, and nobody is on call. Under serializable isolation, the database notices that each transaction relied on data the other then changed. One commit fails, and the retry counts again, finds only one doctor, and refuses. At a weaker level, you can lock the rows the decision depends on, using select for update. Now Bob's request waits for Alice's to finish.
Any code that reads a value, decides in the application and writes back is at risk. Name the rule it protects, and let constraints or a single conditional update do the work where possible. Pessimistic locking locks the rows you read until you commit. It suits short, busy operations, such as assigning a courier to an order, at the cost of waiting and possible deadlocks. Optimistic control takes no locks. Each row carries a version number, and the write succeeds only if the version hasn't changed. It suits a manager editing a menu in a browser for five minutes. Or use serializable isolation, and retry the whole transaction when it fails, so keep emails and payment calls outside it. Practitioners disagree on the best default; what matters is a deliberate, tested choice.
The full section has every anomaly, a comparison of real databases and a checklist. Next, in Section seven point four, we look at replication and partitioning. See you there.
What you need to know
- ACID: atomicity means all-or-nothing on failure; consistency means the application's invariants hold; isolation limits interference between concurrent transactions; durability means committed data survives crashes, usually through a write-ahead log and replicas.
- The SQL standard defines read uncommitted, read committed, repeatable read and serializable by the dirty reads, non-repeatable reads and phantoms they forbid; Berenson et al. (1995) showed these definitions are ambiguous and incomplete.
- Lost updates and write skew arise when transactions read data, decide in the application and write back; snapshot isolation prevents lost updates but allows write skew.
- MVCC keeps multiple row versions so that readers and writers do not block each other; long-running transactions bloat storage.
- Defaults and names differ: PostgreSQL and Oracle default to read committed, MySQL InnoDB to repeatable read, CockroachDB to serializable; Oracle's serializable is snapshot isolation.
- Protect read-modify-write paths with constraints, atomic conditional updates, SELECT ... FOR UPDATE (pessimistic) or version columns (optimistic), or use serializable isolation with whole-transaction retries.
What a transaction promises
A transaction groups several reads and writes into one unit that either takes effect completely (commits) or not at all (aborts and rolls back). Chapter 0 introduced the idea with a bank transfer. This section is about the part most developers misunderstand: what happens when many transactions run at the same time, which guarantees databases really give, and how to choose. The examples use SQL, but the reasoning applies to any store that offers transactions.
Transactions matter architecturally because they decide where business rules can be enforced cheaply. Inside one database, a transaction can keep a ledger balanced or stop a table being double-booked with a few lines of code. Across services there is no such tool, which is why data ownership and sagas are so important. Knowing exactly what a single database guarantees tells you which rules belong inside one owner's boundary.
ACID in detail
ACID was coined by Theo Härder and Andreas Reuter in 1983, building on Jim Gray's earlier work. Each letter is narrower than it sounds. Atomicity is about failure, not concurrency: if anything goes wrong part-way (a crash, a constraint violation, a deadlock, the application deciding to abort), every change made so far is undone, so the application can safely retry. Abortability would be a more accurate name.
Consistency means that a transaction takes the database from one valid state to another, where valid
means your invariants hold: debits equal credits, stock is never negative, every order has a customer. The database can enforce some invariants itself (keys, foreign keys, CHECK constraints, unique indexes), but most are business rules that only the application's transactions keep. Consistency is therefore a property of the application's use of atomicity and isolation, not something the database provides on its own. It is also unrelated to the C
in CAP, which means linearisability (Chapter 6).
Isolation means that concurrently running transactions do not interfere with each other. In its strictest form, serializability, the result of running transactions concurrently is the same as if they had run one at a time in some order. Weaker levels allow some interference in exchange for performance, and the rest of this section is about exactly which interference. Durability means that once a commit is acknowledged, the data survives crashes. Single-node databases achieve it with a write-ahead log flushed to disk before the commit returns; replicated databases add copies on other machines. Durability is never absolute: a disk can lie about a flush, and asynchronous replication can lose the last few acknowledged writes when the leader fails (see replication).
What goes wrong without isolation
Concurrency bugs are hard to find because they depend on timing: they pass every test on a quiet developer machine and appear at the dinner-time peak. The clearest way to understand isolation levels is through the specific anomalies (also called phenomena) they allow or prevent. The lost update below is the most common in business code.
A lost update, step by step
The anomalies, from mildest to subtlest
| Anomaly | What happens | Food-delivery or banking example |
|---|---|---|
| Dirty write | A transaction overwrites data another transaction has written but not yet committed. | Two transactions assigning a courier interleave so the order row names one courier and the courier row a different order. Every real isolation level prevents this. |
| Dirty read | A transaction reads data written by another transaction that has not committed (and may roll back). | A report counts a payment of 500 that is then rolled back. |
| Non-repeatable (fuzzy) read | A transaction reads the same row twice and gets different values because another transaction committed a change in between. | Checkout reads a dish price of 9.50, a restaurant edit commits 11.00, and checkout's second read sees 11.00. |
| Read skew | A transaction sees parts of the database at different points in time, so related values do not add up. | A customer with 500 split across two accounts sees 450 in total because a transfer of 50 committed between reading the two balances. |
| Phantom read | A transaction re-runs a query with a condition and finds rows added or removed by another committed transaction. | Counting a courier's open deliveries twice returns 2 and then 3. |
| Lost update | Two transactions read a value, each computes a new one and writes it, and one write silently overwrites the other. | Two withdrawals from the same account (diagram above). |
| Write skew | Two transactions read an overlapping set of data, make decisions based on it, and update different rows, so together they break a rule each one checked. | Two on-call doctors each see that someone else is still on call and both go off call (worked example below). |
The SQL standard isolation levels
The SQL standard (from SQL-92 onwards) defines four isolation levels by which of three phenomena they forbid: dirty reads, non-repeatable reads and phantoms. The standard makes serializable the default, but almost no mainstream product follows that; most default to read committed or repeatable read for performance.
The SQL standard levels and the phenomena they rule out
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read uncommitted | possible | possible | possible |
| Read committed | prevented | possible | possible |
| Repeatable read | prevented | prevented | possible |
| Serializable | prevented | prevented | prevented, and the result must equal some serial order |
In 1995, Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O'Neil and Patrick O'Neil published A Critique of ANSI SQL Isolation Levels. They showed that the standard's English definitions were ambiguous, that forbidding the three phenomena does not by itself give serializability, and that the standard's levels failed to describe isolation levels that real products used. They added phenomena the standard missed, notably the dirty write, the lost update, read skew and write skew, and defined snapshot isolation: each transaction reads from a consistent snapshot of the committed data as of its start, and a transaction cannot commit a write to a row that a concurrent transaction has already changed and committed (first committer wins
). Snapshot isolation prevents dirty reads, non-repeatable reads, read skew and lost updates, and the phantoms that affect reads, but it allows write skew, so it is not serializable. It fits awkwardly between the standard's levels, which is why products label it differently.
MVCC: how snapshots work
Most modern databases implement snapshot reads with multi-version concurrency control (MVCC). Instead of overwriting a row in place, a write creates a new version tagged with the writing transaction's identifier, and the old version is kept until no running transaction can still need it. Each reader is given a snapshot, essentially a list of which transactions had committed when it started, and sees only the versions those transactions created. The great benefit is that readers never wait for writers and writers never wait for readers; only two writers to the same row conflict.
Row versions under MVCC
The cost is housekeeping. Old versions must be cleaned up: PostgreSQL keeps them in the table and removes them with vacuum, while Oracle and MySQL InnoDB keep before-images in undo logs. A transaction left open for hours pins old versions, so tables and undo logs bloat and performance degrades. Long-running transactions are therefore an operational smell in any MVCC database, and a reason to keep analytical queries off the operational database. In read committed under MVCC, each statement gets a fresh snapshot; in snapshot isolation, the whole transaction uses one.
Real defaults, and why the names mislead
Isolation level names are not portable. The same word can mean different guarantees in different products, and the defaults differ. Martin Kleppmann's Hermitage project tested the actual behaviour of many databases against the anomalies above; vendor documentation confirms the defaults. Treat this table as a starting point and check the documentation for the version you run.
Same names, different behaviour: what real databases do (summarised from vendor documentation and the Hermitage tests)
| Database | Default level | What the levels really are | Notes |
|---|---|---|---|
| PostgreSQL | Read committed | Read uncommitted behaves as read committed. Repeatable read is snapshot isolation. Serializable is serializable snapshot isolation (SSI). | Repeatable read aborts a transaction that tries to update a row changed since its snapshot (could not serialize access due to concurrent update), so lost updates are prevented; write skew is not. |
| MySQL (InnoDB) | Repeatable read | Plain SELECTs read a snapshot taken at the first read; locking reads, UPDATE and DELETE act on the latest committed rows with record and gap locks. Serializable turns plain SELECTs into locking reads when autocommit is off. | At repeatable read, a read-modify-write written as SELECT then UPDATE can still lose an update or produce write skew unless the SELECT uses FOR UPDATE. |
| Oracle Database | Read committed | Offers read committed and serializable; its serializable is snapshot isolation. | Prevents lost updates at serializable but allows write skew. |
| Microsoft SQL Server | Read committed (locking) on SQL Server; Azure SQL Database turns on read committed snapshot by default | Locking read committed, read committed snapshot, repeatable read (locking), snapshot (snapshot isolation) and serializable (locking). | On SQL Server, snapshot isolation must be enabled per database (ALLOW_SNAPSHOT_ISOLATION) before use. |
| CockroachDB | Serializable | Serializable by default; read committed is also available. | Clients must retry transactions that fail with a serialization error. |
Achieving serializability
Databases that offer true serializability use one of three techniques. Actual serial execution runs transactions one at a time on a single thread, which is fast if every transaction is short and the data is in memory (VoltDB, and Redis scripts and transactions, work this way). Two-phase locking (2PL), used by SQL Server's and MySQL's serializable levels, makes readers take shared locks and writers exclusive locks, including locks on ranges to stop phantoms, and holds them to the end of the transaction; it is correct but readers and writers block each other, throughput drops under contention and deadlocks are common. Serializable snapshot isolation (SSI), described by Michael Cahill, Uwe Röhm and Alan Fekete in 2008 and used in PostgreSQL since version 9.1 (CockroachDB uses a related optimistic technique), runs transactions on snapshots without blocking, tracks read-write dependencies between them, and aborts one transaction when a dangerous pattern appears.
Whichever is used, serializable transactions can fail with a serialization error (SQLSTATE 40001 in PostgreSQL and CockroachDB) or a deadlock, and the application must retry the whole transaction. This is the hidden cost of the strongest level: every transactional code path needs a retry loop that is safe to repeat, which means no side effects such as sending emails or calling payment providers inside the transaction.
Pessimistic and optimistic concurrency control
In practice most applications run at read committed or repeatable read and protect specific read-modify-write paths explicitly. There are three tools. The best, when possible, is an atomic update that lets the database do the arithmetic and the check in one statement, such as UPDATE accounts SET balance = balance - 30 WHERE id = 7 AND balance >= 30, then checking that exactly one row changed. Under PostgreSQL's read committed, a concurrent update makes the second statement wait and then re-evaluate its condition against the new row, so no update is lost.
When the decision needs logic the database cannot express, use pessimistic locking: lock the rows you read with SELECT ... FOR UPDATE, so concurrent transactions that want them wait until you commit. It is simple and suits short, highly contended operations (assigning one courier to one order). The costs are waiting, the risk of deadlocks (lock rows in a consistent order, such as by ascending id) and reduced throughput. Variants include NOWAIT, which fails immediately instead of waiting, and SKIP LOCKED, which skips rows already locked and is the standard way to build a reliable job queue on a relational table.
BEGIN;
-- Lock the row: other FOR UPDATE readers and writers must wait
SELECT balance FROM accounts WHERE id = 7 FOR UPDATE;
-- application checks: balance (100) >= 30, so allow it
UPDATE accounts SET balance = 70 WHERE id = 7;
INSERT INTO ledger (account_id, amount, kind)
VALUES (7, -30, 'withdrawal');
COMMIT; -- lock released; the waiting transaction now sees 70Optimistic concurrency control takes no locks while the user or the code is thinking. Each row carries a version number (or a timestamp or hash). The writer updates only if the version is still the one it read, and increments it. If no row changes, someone else got there first, and the application reloads and retries or tells the user. This suits low-contention data and long think time
: a restaurant manager editing a menu in a browser for five minutes must not hold a database lock all that time. HTTP has the same idea built in, with ETags and the If-Match header.
-- Read (no lock); remember version = 12
SELECT id, name, price, version FROM menu_items WHERE id = 501;
-- Later, write only if the version is unchanged
UPDATE menu_items
SET price = 11.00, version = version + 1
WHERE id = 501 AND version = 12;
-- Rows affected = 1: success.
-- Rows affected = 0: a concurrent edit won; reload and retry
-- or show the user the other change.Worked example
Worked example: write skew with two on-call doctors
A hospital rota system enforces one rule: at least one doctor must be on call for each shift. Alice and Bob are both on call tonight. Both feel unwell and press Go off call
at almost the same moment. Each request runs this logic in a transaction: count the doctors on call for the shift; if there are at least two, set my own row to off call. The database runs at snapshot isolation (PostgreSQL repeatable read or Oracle serializable).
| Start | Table doctors: Alice on_call = true, Bob on_call = true. Rule: count(on_call) >= 1. |
|---|---|
| Step 1: T1 (Alice) begins | T1 takes a snapshot. |
| Step 2: T2 (Bob) begins | T2 takes a snapshot. Both snapshots show two doctors on call. |
| Step 3: T1 reads | SELECT count(*) FROM doctors WHERE shift = 1234 AND on_call returns 2. Check passes. |
| Step 4: T2 reads | The same query in T2's snapshot also returns 2. Check passes. |
| Step 5: T1 writes | UPDATE doctors SET on_call = false WHERE name = 'Alice' AND shift = 1234 |
| Step 6: T2 writes | UPDATE doctors SET on_call = false WHERE name = 'Bob' AND shift = 1234. A different row, so no write-write conflict is detected. |
| Step 7: both commit | Snapshot isolation sees no conflict: each transaction wrote a row the other did not. Result: nobody is on call. Each transaction was correct in isolation; together they broke the rule. |
| Under serializable (SSI) | The database notices that each transaction read data the other then changed (a dangerous cycle of read-write dependencies). One commit fails with SQLSTATE 40001; the retry recounts, finds one doctor and refuses. |
| Fix at a weaker level | Lock the rows the decision depends on: SELECT name FROM doctors WHERE shift = 1234 AND on_call FOR UPDATE, then count the rows returned. Now T2 waits for T1's locks. At read committed, T2 then re-reads, finds only Bob on call and refuses; at PostgreSQL repeatable read it fails with a serialization error instead, and the retry refuses. |
Write skew is a generalisation of the lost update: the transactions read the same data but write different rows, so snapshot isolation's first-committer-wins rule never fires.
The same pattern appears in booking systems (two people booking the last seat or overlapping meeting slots), usernames (two sign-ups checking that a name is free), and banking (two withdrawals from different accounts that together breach a joint overdraft limit).
When the rows that would prove a conflict do not yet exist, as with two overlapping bookings, FOR UPDATE has nothing to lock. Options are serializable isolation, a unique or exclusion constraint (PostgreSQL can forbid overlapping time ranges declaratively), or materialising the conflict: create rows such as one per room per time slot, purely so they can be locked.
Choosing an isolation strategy
There is no single right level; there is a right level for each piece of logic. A practical approach starts from the invariants, not from the settings.
Checklist: making a transactional code path safe
- Name the invariantWrite the rule the code protects (balance never below zero, one courier per order, at least one doctor on call). If there is none, the default level is probably fine.
- Prefer constraints and atomic statementsUnique indexes, foreign keys, CHECK and exclusion constraints, and single-statement conditional updates are enforced by the database whatever the isolation level.
- Spot read-modify-writeAny code that reads a value, decides in the application and writes back is a lost-update or write-skew risk. Protect it with FOR UPDATE, a version column or serializable isolation.
- Know your database's real semanticsCheck what your product's level names actually mean (PostgreSQL and MySQL repeatable read differ; Oracle serializable is snapshot isolation).
- Retry correctlySerializable and repeatable read can abort with serialization errors and deadlocks. Retry the whole transaction with back-off, and keep external side effects outside it.
- Keep transactions shortNo user think time, network calls or large reports inside a transaction; long transactions hold locks and bloat MVCC storage.
- Test under concurrencyRun the critical paths concurrently in an integration test against the real database engine, not an in-memory substitute with different semantics.
As a rule of thumb, read committed with explicit locking or version checks is a sound, fast default for most business applications, provided the team is disciplined about finding read-modify-write paths. Serializable is the safer default when the rules are complex, the team is large or the cost of a violated invariant is high (money, medicine, safety), and many teams now use it for the critical paths, accepting retries. Practitioners genuinely disagree here: some argue that serializable by default prevents a whole class of bugs that code review will never catch; others argue that its retries and contention cost too much and that explicit locking makes intent visible. Both positions are defensible if the choice is deliberate and tested.
Check your understanding
Why is snapshot isolation not serializable?
It allows write skew: two transactions can read overlapping data and write different rows, together breaking a rule each checked, without any write-write conflict being detected.
What does the C in ACID depend on?
Mostly on the application: the database enforces declared constraints, but business invariants hold only if the application's transactions preserve them.
When would you choose optimistic over pessimistic concurrency control?
When conflicts are rare or there is long think time between read and write, such as a user editing a form, so holding locks would be wasteful or impossible.
Why must code using serializable isolation be safe to retry?
Serializable transactions can be aborted with serialization failures or deadlocks, and the correct response is to rerun the whole transaction, so it must have no external side effects.
Sources and further reading
Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.
Ask: “Our booking system occasionally double-books a table even though we check availability first. Which isolation level or locking approach should we use?”