You're at a desk with two envelopes of cash. Alice's holds 1,000 and Bob's holds 500, and you've been asked to move 100 from Alice's to Bob's. You take 100 out of Alice's envelope and, before you've put it into Bob's, the phone rings and you're called away for the rest of the day. That evening someone counts the desk and finds 1,400. Nobody stole anything. The transfer was two steps and only one of them happened.
A bank's database has the same two steps: subtract 100 from Alice's row, add 100 to Bob's row. It also has a worse version of the phone call, because a bank doesn't have one clerk at one desk. Thousands of other programs are reading and changing the same rows at the same moment. Between your two steps, someone adding up every balance would find 100 missing, even though nothing crashed. And if two programs add money to Alice's balance at once, one of the deposits can quietly disappear.
Databases handle this with the transaction, a group of steps the database treats as a single unit. A transaction comes with promises about what happens if it crashes, and about what other transactions are allowed to see of it while it runs. We'll follow the Alice-to-Bob transfer through the whole chapter and keep asking one question: while my transfer is running, what can everyone else see, and what can I rely on? We start with the crash, which turns out to be the easy half, and spend the rest of the chapter on the hard half.
01Crash a transfer, with and without a transaction
1.1Half a transfer
Let's reproduce the phone call. The script below uses SQLite, a small database that comes with Python, and keeps it in memory. It creates two accounts, Alice with 1,000 and Bob with 500, and then tries to move 100 from one to the other. Between the two UPDATE statements it raises an exception, which stands in for a crash. It does this twice: once with the two updates loose, and once with them wrapped in a transaction.
The new pieces are small. BEGIN starts a transaction. ROLLBACK ends it by undoing every write made since BEGIN. (The other way to end one is COMMIT, which makes the writes permanent, and it's the ending every later example aims for.) The argument isolation_level=None stops Python from quietly starting transactions on our behalf, so the BEGIN you see is the only one. In a real crash the program would be dead and couldn't call ROLLBACK at all; the database would clean up the half-finished transaction itself when it restarted. The script calls it explicitly so we can watch the result without killing a process.
import sqlite3
def fresh():
db = sqlite3.connect(":memory:", isolation_level=None) # we control BEGIN/COMMIT ourselves
db.execute("CREATE TABLE acct (name TEXT PRIMARY KEY, balance INT)")
db.execute("INSERT INTO acct VALUES ('alice', 1000), ('bob', 500)")
return db
def show(label, db):
rows = dict(db.execute("SELECT name, balance FROM acct"))
print(f"{label:<28} alice={rows['alice']:>5} bob={rows['bob']:>5} total={sum(rows.values())}")
def transfer(db, use_txn):
if use_txn: db.execute("BEGIN")
db.execute("UPDATE acct SET balance = balance - 100 WHERE name = 'alice'")
raise RuntimeError("crash between the two updates")
db.execute("UPDATE acct SET balance = balance + 100 WHERE name = 'bob'") # never reached
for use_txn in (False, True):
db = fresh()
try: transfer(db, use_txn)
except RuntimeError:
if use_txn: db.execute("ROLLBACK")
show("with a transaction" if use_txn else "no transaction", db)no transaction alice= 900 bob= 500 total=1400
with a transaction alice= 1000 bob= 500 total=1500Both accounts started with 1,500 between them. In the first run, the crash left Alice at 900 and Bob unchanged, so the total is 1,400: 100 vanished, exactly as with the envelopes. In the second run the database rolled back the half-finished transfer, so Alice is back at 1,000 and the total is still 1,500.
1.2All or nothing
That promise is called atomicity: all of a transaction's writes happen, or none of them do. An atom was once thought to be the smallest piece of matter, something that couldn't be cut in half, and a transaction can't be cut in half either. SQLite is fine for one program on one machine, but most of this chapter uses Postgres (PostgreSQL), a widely used open-source database server that many programs connect to at once. Section 5 shows how Postgres keeps the all-or-nothing promise without doing anything that looks like undoing.
Atomicity covers one kind of trouble, a crash in the middle of our own transfer. It says nothing about another program looking at the two accounts while the transfer is half done, and that is the trouble the rest of the chapter is about. To talk about it properly we need to know what a transaction promises overall, and which of those promises is the hard one.
02What a transaction promises
2.1ACID, letter by letter
Atomicity is the first of four promises that databases usually advertise together under the acronym ACID. The second, consistency, is about invariants, rules that must always be true of the data, such as "a transfer never changes the total money" or "a balance never goes below zero". Two of the rows below mention the write-ahead log, or WAL: a file where the database writes down every change before it touches the tables, so that after a crash it can tell what was finished and what wasn't. The durability row mentions fdatasync, a system call that forces data onto the disk and waits until the operating system confirms it got there.
| Letter | Promise | How Postgres keeps it |
|---|---|---|
| Atomicity | All of the transaction's writes happen, or none do | A commit record in the WAL. Without one, the writes are invisible (chapter 18) |
| Consistency | Invariants hold before and after | Mostly your job: constraints, foreign keys, and the transactions you write |
| Isolation | Concurrent transactions don't see each other's partial work | Locks on rows, or keeping old copies of rows (section 4), at the strength the transaction asks for |
| Durability | A committed transaction survives a crash | The commit waits for fdatasync of the WAL |
The database can enforce some invariants itself. A constraint is a rule declared alongside the table, such as CHECK (balance >= 0), and the database rejects any write that breaks it; a foreign key is a constraint that a value must match a row in another table. "A transfer never changes the total" can't be written as a constraint on one row, though. It holds only because the transfer's code subtracts and adds the same amount, which is why the table says consistency is mostly your job.
Atomicity and durability are about crashes, and they're more or less yes-or-no: either the transfer happened or it didn't, either the commit survived or it didn't. Isolation is about what happens when transactions overlap in time. It comes in strengths, and it's where the surprises live.
2.2The gold standard: serializable
The strongest form of isolation is called serializable: the result of running transactions at the same time is the same as running them one at a time, in some order. If every transaction is correct on its own, a serializable database keeps them correct together. Go back to the program from the opening that adds up every balance while the transfer runs, and call it the auditor. Under serializable isolation the auditor sees either the whole transfer or none of it, and so always gets 1,500, because as far as the result goes the auditor ran entirely before the transfer or entirely after it.
?Why doesn't every database just do that?
Because the obvious ways to get it cost throughput, the number of transactions the database can finish per second. Running one transaction at a time works (Redis, an in-memory data store, and the VoltDB database do exactly that), but only if every transaction is short and its data is in memory, because everyone else waits while it runs. Making each transaction wait until nobody else is changing the rows it reads has the same effect on a smaller scale: readers and writers spend their time waiting for each other. So databases offer weaker settings that allow some odd outcomes in exchange for more concurrency, and leave it to you to know which ones you can live with. A transaction picks one of these settings, called its isolation level, which says which odd outcomes the database must rule out.
To choose a level sensibly we need a vocabulary for the odd outcomes themselves, and the transfer is a good place to find them.
03What goes wrong when transactions overlap
An outcome that no one-at-a-time order of the same transactions could produce is called an anomaly. There are about half a dozen that matter in practice, and each isolation level is defined by which of them it rules out.
3.1Watching the transfer from outside
Suppose that while the transfer from Alice to Bob is running, the auditor's transaction adds up every balance. The right answer is 1,500 whichever side of the transfer the auditor lands on. Here is how the auditor can get something else.
If the auditor can see changes the transfer has written but not yet committed, it might read Alice's balance after the debit (900) and Bob's before the credit (500). The total is 1,400. This is a dirty read: seeing another transaction's uncommitted writes. It's worse if the transfer then rolls back, because the auditor has reported balances that never existed.
Now suppose the database refuses dirty reads, so the auditor only ever sees committed data. The auditor reads Bob's balance first (500). Then the transfer commits. Then the auditor reads Alice's balance and gets 900. Every number the auditor saw was committed, and the total is still 1,400. The auditor read two related rows at two different moments, which is called read skew.
Two transfers can also collide with each other. Say two deposits of 100 arrive for Alice at once. Each reads her balance (1,000), adds 100 in the application, and writes back 1,100. The second write overwrites the first, and one deposit has vanished without any error. This is a lost update.
3.2The catalogue
Those three, and four more, are the ones people name. In the table, T1 and T2 are two transactions running at the same time, and to abort is to end a transaction by undoing it, as ROLLBACK did in section 1.
| Anomaly | What happens | Example |
|---|---|---|
| Dirty write | T2 overwrites a value T1 wrote but hasn't committed | Two transfers interleave writes to two accounts; the final balances match neither |
| Dirty read | T2 reads T1's uncommitted write, then T1 aborts | A report includes an order that was rolled back |
| Non-repeatable read | T1 reads a row twice and gets different values, because T2 committed in between | A check passes on the first read and fails on the second |
| Phantom | T1 runs a query twice and gets a different set of rows | A count of matching rows (count(*)) changes mid-transaction because T2 inserted one |
| Lost update | T1 and T2 both read x, both write x+1; one increment disappears | A view counter, a stock level, a balance |
| Read skew | T1 reads two related rows at different points in time | A transfer between two accounts looks like money vanished |
| Write skew | T1 and T2 read overlapping data, then write different rows, and together they break an invariant | Two doctors each see the other on call and both go off |
Write skew, the last row, is the strangest, and it's the hardest to prevent. Sections 6 and 7 reproduce lost update and write skew on Postgres, because those two are the ones application code runs into most.
Adya's 1999 thesis made these definitions precise. It draws each anomaly as a graph whose arrows say which transaction read or overwrote what another wrote, and gives each bad shape a short name: G0 for dirty write, G1 for dirty reads and their relatives, G2-item for write skew, and G2 for the wider family that also covers phantoms. Jepsen, a company that tests databases for correctness, and Hermitage, a set of scripted anomaly tests we'll meet in section 10, use those names, and so will the rest of this chapter where it matters.
Knowing the names, the next question is how a database can stop any of them from happening. There are two basic answers.
04Two ways to isolate: locks and versions
Every isolation level is built from one of two mechanisms, or a mix of both. The first is to make transactions wait for each other, and the second is to let them work on separate copies.
4.1Locks and two-phase locking
A lock is a marker on a row that says "I'm using this; wait your turn". A shared lock lets other readers in but keeps writers out, and an exclusive lock keeps everyone out. Classic databases use two-phase locking (2PL): a transaction takes a shared lock on everything it reads and an exclusive lock on everything it writes, and releases nothing until it commits. The "two phases" are growing (acquiring locks) and shrinking (releasing them). Holding every lock until the very end, as here, is called strict 2PL, and it's the form databases use.
Row locks alone leave one hole. A query such as "count the accounts with a balance over 1,000" can't lock a row that doesn't exist yet, so another transaction could insert a matching row halfway through, which is a phantom. Strict 2PL is serializable once it also locks ranges of an index (the gap where a matching row would go), so that nobody can insert a row a query would have matched. For our auditor, it means the transfer's exclusive locks on Alice's and Bob's rows block the auditor until the transfer commits, and the auditor's shared locks would block a transfer that tried to start halfway through the audit. Nobody sees a half-done transfer, and you pay for that: readers block writers, writers block readers, and transactions that take locks in different orders deadlock, each waiting for a lock the other holds.
Here's a real deadlock on Postgres. Transaction A moves money from Alice (account 1) to Bob (account 2), and at the same moment transaction B moves money from Bob to Alice. Each debits its own source first.
Postgres detects the cycle and aborts one transaction, so the waiting does end, but a full second has gone by and one transfer has to be run again. Postgres uses row locks like this for writes at every level. What it doesn't do is lock what you read, which is where the second mechanism comes in.
4.2Multi-version concurrency control
Locking readers is expensive because most transactions read far more than they write. So suppose we never overwrite a row. When the transfer changes Alice's balance, we keep the old row, 1,000, and add a new one, 900. A reader that started before the transfer committed can keep looking at the old row, and the transfer never has to wait for it.
This is multi-version concurrency control, or MVCC: the database keeps several versions of each row, and a writer creates a new version instead of overwriting the old one. Each reader picks a fixed moment when it starts looking and sees the versions that were committed as of that moment. That fixed view is the reader's snapshot. Readers never wait for writers, and writers never wait for readers. Writers still wait for each other, because two writers can't both create the next version of the same row.
| Two-phase locking | MVCC (snapshot) | |
|---|---|---|
| Reader vs writer | Block each other | Never block |
| Writer vs writer | Block | Block (row locks), then rules for who wins (section 6) |
| Serializable on its own? | Yes, with range locks | No: section 7 shows what slips through |
| Price | Lock waits, deadlocks | Old versions to store and clean up |
| Used by | MySQL and SQL Server at SERIALIZABLE, Db2 | Postgres, Oracle, ordinary reads in MySQL's InnoDB engine, CockroachDB |
"A snapshot" and "a version" sound vague, and the useful thing about Postgres is that both are small enough to follow all the way down. Let's do that.
05Row versions and snapshots in Postgres
Every row version in Postgres carries two transaction IDs, and every reader carries a snapshot. Whether you see a row version is a comparison between the two.
5.1xmin, xmax and the commit log
Postgres calls the file holding a table's rows the heap, and one stored row version in it a heap tuple, or just a tuple. Every tuple has a header with two fields. xmin is the ID of the transaction that created this version, and xmax is the ID of the transaction that deleted or replaced it, or 0 if none has. They're hidden columns: SELECT * leaves them out, but you can ask for them by name. An UPDATE is therefore an insert of a new version plus setting xmax on the old one; chapter 18 shows both versions sitting in the same page of the table file.

Transaction IDs (XIDs) are 32-bit counters, handed out when a transaction first writes. Whether each XID committed or aborted is kept in a separate structure, the commit log (pg_xact), at two bits per transaction. That tiny record turns out to be all Postgres needs for atomicity. A session is one program's connection to the database, and every transaction runs inside one. Here is our transfer in one session, with T standing for its transaction ID:
xmax: 0 means nobody has replaced it.The commit also writes a commit record to the WAL, which lets the entry survive a crash. So atomicity needs no undoing at all. A rolled-back or crashed transaction just never gets a committed entry, and every version it wrote stays invisible. The same log gives us the visibility rule for readers, and it needs one more idea: a way to freeze what "committed" means while a reader is in the middle of a scan.
5.2A snapshot is three numbers and a list
A reader can't just ask the log "did the creator of this version commit?" for each version as it goes. A transaction might commit halfway through the reader's scan, and the reader would see some rows from before the commit and some from after, which is read skew again. The reader needs to fix the answer once, at the start. It does that by recording which transactions count as finished for it. That record is the snapshot, and here is how Postgres defines it:
typedef struct SnapshotData
{
SnapshotType snapshot_type; /* type of snapshot */
/*
* An MVCC snapshot can never see the effects of XIDs >= xmax. It can see
* the effects of all older XIDs except those listed in the snapshot. xmin
* is stored as an optimization to avoid needing to search the XID arrays
* for most tuples.
*/
TransactionId xmin; /* all XID < xmin are visible to me */
TransactionId xmax; /* all XID >= xmax are invisible to me */
/*
* For normal MVCC snapshot this contains the all xact IDs that are in
* progress, unless the snapshot was taken during recovery ...
*
* note: all ids in xip[] satisfy xmin <= xip[i] < xmax
*/
TransactionId *xip;
uint32 xcnt; /* # of xact ids in xip[] */
/* ... */So a snapshot is a lower bound xmin (everything below it has finished), an upper bound xmax (everything from it up hadn't started yet), and a list xip of the transactions in between that were still running. The names clash with section 5.1: a tuple's xmin and xmax say who created and deleted that version, while a snapshot's xmin and xmax bound which transaction IDs count as finished. Let's watch one being taken in the middle of a crowd. We'll use a small acct table that starts with three accounts of 100 each and gains a fourth along the way. To keep the numbers readable, each writer here moves just 1 instead of 100.
The script below does exactly this on Postgres 16. slow, fast, auto and reader are four separate sessions, each its own connection to the database; auto is in autocommit mode, so each of its statements is its own transaction. The reader uses a setting that keeps its snapshot fixed for the whole transaction (section 5.4 gives it a name). pg_current_snapshot() prints the snapshot as xmin:xmax:xip, and xmin and xmax in the select are the hidden columns from section 5.1.
slow.execute("update acct set bal=bal-1 where id=2") # xid 2051525, stays open
fast.execute("update acct set bal=bal-1 where id=1") # xid 2051526
fast.commit()
auto.execute("insert into acct values (4, 100)") # xid 2051527, autocommit
reader.execute("select pg_current_snapshot()")
reader.execute("select id, bal, xmin, xmax from acct order by id")
slow.commit()
reader.execute("select id, bal from acct order by id") # same snapshot (repeatable read)reader snapshot 2051525:2051528:2051525
reader sees [(1, 99, '2051526', '0'),
(2, 100, '2051524', '2051525'),
(3, 100, '2051524', '0'),
(4, 100, '2051527', '0')]
after slow writer commits, same snapshot: [(1, 99), (2, 100), (3, 100), (4, 100)]The snapshot line reads xmin:xmax:xip, matching the fourth frame of the animation. In the rows, account 1 shows the fast writer's update (xid 2051526, not on the list), account 4 shows the insert, and account 2 still shows its old version: its xmax is the slow writer, which the snapshot treats as uncommitted. Even after the slow writer commits, this snapshot keeps seeing 100, and that fixed view is what the snapshot is for.
5.3The visibility check
For every row version a scan finds, Postgres has to decide whether this snapshot sees it. In simplified form, the function HeapTupleSatisfiesMVCC asks two questions: did the creator commit before my snapshot, and if someone deleted this version, did that commit before my snapshot? The steps below follow one row version through the check.
xmin. If it's my own transaction, the row is visible (unless I deleted it).Step 2's snapshot test is short, because most XIDs are settled by the two range checks at the top and never reach the search through the list:
XidInMVCCSnapshot(TransactionId xid, Snapshot snapshot)
{
/* Any xid < xmin is not in-progress */
if (TransactionIdPrecedes(xid, snapshot->xmin))
return false;
/* Any xid >= xmax is in-progress */
if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
return true;
/* ... then search snapshot->subxip[] and snapshot->xip[] ... */?Why the hint bits?
Because checking pg_xact for every tuple on every scan would be slow and contended. Whichever reader first learns that a transaction committed records it on the tuple itself, in a bit that says "my creator committed". This is also why a plain SELECT straight after a bulk load can write to disk: it's setting hint bits on every page it reads.
5.4Read committed and repeatable read: when the snapshot is taken
Postgres lets each transaction choose among three isolation levels. (It also accepts the name read uncommitted, but treats it as read committed, so dirty reads never happen.) Read committed is the default, repeatable read is the next step up, and serializable is the top, which sections 8 and 9 cover. The difference between the two lower ones is a single decision: when the snapshot is taken.
| Level | Snapshot taken | Consequence |
|---|---|---|
| Read committed | At the start of each statement | Each statement sees everything committed before it began; two statements can disagree |
| Repeatable read | At the first statement of the transaction | Every statement sees the same frozen view |
You can check this with a counter that starts at 0. Read it, let another session change it to 42 and commit, and read it again. Under read committed the second read returns 42. Under repeatable read and serializable it returns 0 both times.
That matters for our auditor. At read committed, two separate SELECT statements, Bob's balance and then Alice's, take two snapshots, and a transfer that commits between them produces the 1,400 from section 3. A single SELECT sum(balance) takes one snapshot and gets 1,500. At repeatable read the whole transaction shares one snapshot, so the auditor can read the accounts in any order and any number of statements and always see a consistent state.
5.5The cost: versions that can't be cleaned up
Every UPDATE leaves an old version behind, as the transfer left Alice's 1,000. Those dead versions take up space and slow scans, so Postgres runs a cleanup job called VACUUM that removes versions no one can see any more. The catch is the phrase "no one": VACUUM can only remove a version if no running snapshot could still need it. So the oldest snapshot still open in the database sets the cutoff, and every version that died after that snapshot was taken has to stay.
This script keeps one repeatable-read transaction open, updates all 1,000 rows of a larger acct table so there are 1,000 dead versions, and runs vacuum (verbose), which reports what it removed. Then it ends the old transaction and vacuums again. old and auto are two sessions, as before.
old.execute("select count(*) from acct") # repeatable read, snapshot now fixed
auto.execute("update acct set bal=bal+1") # 1,000 rows -> 1,000 dead versions
auto.execute("vacuum (verbose) acct")
old.commit()
auto.execute("vacuum (verbose) acct")tuples: 1 removed, 2000 remain, 1000 are dead but not yet removable
...
tuples: 1000 removed, 1000 remain, 0 are dead but not yet removableIn the first report, 2000 remain is the 1,000 current versions plus the 1,000 old ones, and those 1,000 are dead but not yet removable: the old transaction's snapshot predates the update, so as far as VACUUM can tell, that transaction might still want to read them. Once it committed, all 1,000 went on the next run.
With snapshots, readers see a steady picture, and the transfer's atomicity comes from one log entry. What snapshots don't do is settle a fight between two transactions that both want to change the same row, and that's the situation behind the lost deposit from section 3.
06Lost updates
Lost updates are probably the anomaly application code hits most, because the code is so natural to write: read a value, compute a new one in the application, write it back.
6.1Reproducing it
Think of the two deposits arriving for Alice at once. To keep the numbers small, the experiment uses a counter that starts at 0, and each session reads it, adds 1 in the application, writes the result back, and tries to commit. The UPDATE each session sends is SET n = 1, a value it computed itself.
Two sessions read the counter (0) at read committed, each runs UPDATE … SET n = 1, and both commit. What is the final value, and does either session get an error?
Here is the same experiment at each level on Postgres 16:
| Level | Session A | Session B | Final value (two increments) |
|---|---|---|---|
| Read committed | Commits | Commits | 1: one increment lost |
| Repeatable read | Commits | 40001 could not serialize access due to concurrent update | 1, and B knows it failed |
| Serializable | Commits | 40001 could not serialize access due to concurrent update | 1, and B knows it failed |
40001 is the standard error code for a serialization failure, which means "your transaction conflicted with another and has been aborted; run it again". The table's last two rows end at 1 because B's attempt was thrown away, and the retry in the final frame is what brings the value to 2.
?Why does repeatable read catch it but read committed doesn't?
Both levels make B's UPDATE wait for A's row lock. The difference is what happens when A commits. Under read committed, B re-reads the newest version of the row, checks it still matches the WHERE clause, and applies its write, which was computed from a stale value. Under repeatable read, the newest version is newer than B's snapshot, so Postgres refuses: first updater wins, and the second gets a serialization failure.
Repeatable read in Postgres is a snapshot for the whole transaction plus this first-updater-wins rule. That combination has a name, snapshot isolation, and it's the level most multi-version databases implement under various names. Sections 7 and 10 say what it still allows.
6.2Four ways to not lose it
There are several fixes for the deposit problem, and which one fits depends on where the new value comes from. In the table, SELECT … FOR UPDATE reads rows and takes the same exclusive lock an UPDATE would, so rivals wait. The optimistic check adds a version column to the row: $seen is the version number the application read, and $new the value it computed.
| Fix | How | When to use it |
|---|---|---|
| Atomic update | UPDATE counter SET n = n + 1 | Whenever the new value can be computed in SQL. Read committed re-evaluates n on the newest version |
| Lock the row | SELECT … FOR UPDATE, then compute, then UPDATE | When the logic has to run in the application |
| Optimistic check | UPDATE … SET n = $new, version = version + 1 WHERE id = $id AND version = $seen; zero rows means retry | Long think time between read and write, such as a form submitted minutes later |
| Raise the level | Repeatable read or serializable, plus a retry loop | When many code paths do read-modify-write and you can't audit them all |
For Alice's deposits, the first row is the natural one: UPDATE acct SET balance = balance + 100 WHERE name = 'alice' hands the database an instruction to add, not a number it computed from a stale read, and Postgres applies it to the newest version of the row.
Every one of these fixes works because two deposits write the same row, so they collide. A transaction that changes a different row from the one a rival changed gets no such help, and that's the next problem.
07Write skew
Snapshot isolation stops lost updates because two transactions writing the same row conflict. Write skew is the case where they write different rows, so nothing conflicts, and an invariant breaks anyway.
7.1Two doctors, one rule
Our bank can hit this. Suppose Alice and Bob share an overdraft rule: each account may go negative, but their two balances together must never go below zero. Alice (1,000) and Bob (500) each withdraw 1,000 at the same moment, from their own account. Each transaction adds up both balances, sees 1,500, decides a 1,000 withdrawal is safe, and subtracts it from its own row. Alice ends at 0, Bob at −500, and the total is −500. Neither transaction wrote the row the other wrote.
The same shape is usually taught with a hospital, as in Ports and Grittner's paper on Postgres's implementation, and that's the version we'll run. The hospital requires at least one doctor on call. Two doctors, who happen to be called Alice and Bob too (a new table, doctors, unrelated to the bank), are both on call, and both feel ill at the same moment. Each one's transaction checks the rule and takes themselves off call:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call; -- 2, so it's safe to leave
UPDATE doctors SET on_call = false WHERE name = 'alice'; -- Bob's says 'bob'
COMMIT;BEGIN ISOLATION LEVEL REPEATABLE READ is how a transaction asks for a level other than the default. Each transaction reads the count, sees 2, and writes its own row. Neither writes the row the other writes.
Alice's and Bob's transactions run concurrently at repeatable read (snapshot isolation) on Postgres. Both read 2 doctors on call. What happens at commit?
?Why can't snapshot isolation see it?
Because its only conflict rule is about writes: two transactions that wrote the same row can't both commit. Here each transaction read a row the other one wrote. That's a read-write dependency in each direction, a cycle, and no serial order could produce the result. Snapshot isolation doesn't track reads, so the cycle is invisible to it.
It seems contrived, but the same shape turns up well outside hospitals, including in our bank:
| System | Invariant | Write skew |
|---|---|---|
| Meeting rooms | No overlapping bookings for a room | Two bookings check for overlaps, see none, and insert |
| Usernames | Unique username (without a unique index) | Two sign-ups check, see it's free, both insert |
| Spending limits | Total across accounts stays under a limit | Two withdrawals from different accounts each check the total |
| Inventory | Don't oversell the last item across warehouses | Two orders each reserve from a different warehouse |
7.2Fixing write skew without serializable
Since the lost-update fixes worked by making the two writers touch the same row, an obvious idea is to make the doctors touch the same rows too, by locking what each one reads.
?Why doesn't SELECT FOR UPDATE always fix it?
It fixes the doctors case: SELECT … FROM doctors WHERE on_call FOR UPDATE locks both rows, so the second transaction waits and then sees the change. But FOR UPDATE only locks rows that exist. In the meeting-room and username cases the check is for the absence of a row, so there's nothing to lock. That's a phantom, and row locks can't stop it.
| Fix | Works for | How |
|---|---|---|
| Constraint | Uniqueness, non-overlap | A unique index; an exclusion constraint, which rejects a booking that overlaps an existing one (EXCLUDE USING gist (room WITH =, during WITH &&)) |
| Lock the rows read | Checks over existing rows | SELECT … FOR UPDATE on everything the decision depends on |
| Materialise the conflict | Checks for absence | Pre-create a row per room-and-slot and lock it, so there is something to conflict on |
| Serializable | Everything | Let the database find the cycle, and retry |
The first three need you to recognise the shape of each rule and pick a fix for it. The fourth asks the database to find every such case itself. That is what serializable means, and how a database can do it is the next question.
08Serializable
There are three ways to make every execution serializable, and databases use all three.
8.1Three implementations
| Approach | How | Who | Cost |
|---|---|---|---|
| Actual serial execution | One thread runs one transaction at a time | Redis, VoltDB | Transactions must be short and in memory; one CPU core per slice of the data |
| Strict 2PL | Shared locks on reads, plus locks on ranges of an index so nobody can insert a phantom | MySQL SERIALIZABLE, SQL Server, Db2 | Readers block writers; deadlocks |
| Serializable snapshot isolation (SSI) | Snapshot isolation plus tracking of read-write conflicts; abort when a dangerous pattern appears | Postgres 9.1+ | Aborts, including some false positives; memory for read tracking |
In MySQL, the strict-2PL row means that reads take locks: at SERIALIZABLE, InnoDB turns every plain SELECT into SELECT … FOR SHARE when autocommit is off (MySQL 8.4 manual). Snapshot isolation on its own isn't serializable, as the doctors showed, and the third row is how Postgres closes the gap without going back to locking every read.
8.2How SSI finds the cycle
SSI builds on a rw-conflict: transaction T1 read a row that a concurrent transaction T2 then wrote. Cahill, Röhm and Fekete's Serializable Isolation for Snapshot Databases (SIGMOD 2008) proved that every snapshot-isolation anomaly contains a particular shape, two consecutive rw-conflicts. Postgres's implementation notes put it like this:
SSI is based on the observation [2] that each snapshot isolation
anomaly corresponds to a cycle that contains a "dangerous structure"
of two adjacent rw-conflict edges:
Tin ------> Tpivot ------> Tout
rw rw
SSI works by watching for this dangerous structure, and rolling
back a transaction when needed to prevent any anomaly. This means it
only needs to track rw-conflicts between concurrent transactions, not
wr- and ww-dependencies. It also means there is a risk of false
positives, because not every dangerous structure is embedded in an
actual cycle.The quote's wr- and ww-dependencies are the other two kinds of arrow, where one transaction reads or overwrites what another has written, and SSI doesn't need to watch them. To see rw-conflicts, a serializable transaction records what it read as SIREAD locks. They never block anyone; they're markers that are checked when someone writes. Here's the two-doctors example under SSI. Two more Postgres words appear in the first message. Postgres calls a table a relation. A query that reads every row of a table from start to end, instead of finding rows through an index, is doing a sequential scan, and since it has read everything, its SIREAD lock covers the whole relation.

How much each SIREAD lock covers matters. Listing them (section 11.1 has the query) shows the difference between the scan above and a query that finds one doctor through the primary-key index, which locks only one page of the index and one row (a tuple):
count(*) with a seq scan: [('relation', 'doctors', None, None)]
WHERE name = 'alice': [('page', 'doctors_pkey', 1, None), ('tuple', 'doctors', 0, 1)]?Why do false positives happen?
Two reasons. SSI aborts on the dangerous structure without checking that it closes into a real cycle. And coarse SIREAD locks (a whole relation, from a sequential scan, or promoted from many tuple locks once max_pred_locks_per_transaction fills) conflict with writes that didn't touch anything the reader used. Indexes on the columns you filter by keep the locks fine-grained.
8.3Serializable is only as good as the implementation
In 2020, Jepsen tested PostgreSQL 12.3 and found that "serializable" allowed G2-item, the write-skew class, during normal operation. The bug was in conflict detection with three concurrent transactions. Jepsen confirmed it in 9.5.22, 10.13, 11.8, 12.3 and 13, and it was fixed in the next minor releases. Jepsen's other point was about names: at the time the documentation didn't use the term "snapshot isolation", and saying that Postgres's repeatable read is snapshot isolation "would immediately clarify matters". We come back to that in section 10.
First, though, there's the price of all this tracking, and it turns out to be small in one place and large in another.
09What serializable costs
9.1The published numbers
Ports and Grittner's VLDB 2012 paper on the Postgres implementation puts SSI at "less than 7%" below snapshot isolation, and well ahead of strict 2PL on read-heavy workloads. One of their benchmarks is RUBiS, which imitates an auction website:
| Isolation | Throughput (req/s) | Serialization failures |
|---|---|---|
| Snapshot isolation | 435 | 0.004% |
| SSI | 422 | 0.03% |
| Strict 2PL | 208 | 0.76% |
SSI's tracking costs about 3% of throughput here, and locking costs more than half. In your own system, though, the cost you're more likely to notice is retries.
9.2Retries on hot rows
A hot row is one that most transactions want to change. Every transaction that collides with another at repeatable read or serializable is aborted and has to run again, so a hot row turns into a pile of retries. pgbench, Postgres's built-in benchmark tool, comes with a banking-style transaction called TPC-B-like: like our transfer it changes an account balance, and it also adds the amount to one row of a small branches table, which makes that row hot. pgbench's scale factor sets how many branch rows there are, so scale 1 means a single branch that every transaction updates. The runs below used 8 clients, --max-tries=1000 so failed transactions retry, 15 seconds per run, median of three:
| Setup | Level | tps (transactions per second) | Transactions that needed a retry |
|---|---|---|---|
| 1 branch row (every txn collides) | Read committed | 1,581 | 0% |
| Repeatable read | 669 | 23.2% | |
| Serializable | 672 | 26.4% | |
| 10 branch rows | Read committed | 5,827 | 0% |
| Repeatable read | 3,480 | 38.2% | |
| Serializable | 4,105 | 38.4% |
The throughput figures swing a lot from run to run and from machine to machine, while the retry percentages stayed within about three points. With a single client, nothing was ever retried at any level.
| Read committed, 1 hot row | from the table | 1,581 tps |
| Repeatable read, 1 hot row | from the table | 669 tps |
| Throughput lost to retries | 1,581 ÷ 669 | ≈ 2.4× |
| Same comparison with 10 branch rows | 5,827 ÷ 3,480 | ≈ 1.7× |
| what one hot row costs at repeatable read | ≈ 2.4× fewer tps | |
?Why does read committed win so clearly here?
Because on a write-write conflict it waits and then applies the update to the newest row version, while repeatable read and serializable throw the transaction away and start again. On this workload serializable cost roughly the same as repeatable read: the failures were almost all first-updater-wins conflicts, not SSI aborts.
9.3The retry loop is not optional
Because aborts are a normal result at the higher levels, the application has to expect them. At repeatable read and serializable, 40001 (serialization failure) is a normal outcome, not an error to log and give up on. Deadlocks (40P01) can happen at any level. Both mean "run the whole transaction again", from the first statement, because every read it did may be stale.
import psycopg2, random, time
from psycopg2 import errorcodes
RETRYABLE = {errorcodes.SERIALIZATION_FAILURE, errorcodes.DEADLOCK_DETECTED}
def run_tx(conn, work, attempts=10):
for attempt in range(attempts):
try:
with conn: # BEGIN ... COMMIT, or ROLLBACK on error
with conn.cursor() as cur:
return work(cur) # all reads *and* writes, every time
except psycopg2.Error as e:
if e.pgcode not in RETRYABLE or attempt == attempts - 1:
raise
time.sleep(random.uniform(0, 0.01 * 2 ** attempt)) # jittered backoffrun_tx runs work inside a transaction. If Postgres reports a serialization failure or a deadlock, it waits a random, growing delay (so two colliding transactions don't retry in lockstep) and runs work again from the top, up to ten times. Any other error is raised immediately.
All of this assumed that "repeatable read" and "serializable" mean what Postgres says they mean. They don't mean that everywhere.
10What the level names mean in each database
10.1The standard's four levels
SQL-92, the SQL standard of the early 1990s, defines four isolation levels by which of three phenomena they prevent: dirty reads, non-repeatable reads and phantoms. Berenson, Bernstein, Gray and colleagues showed in A Critique of ANSI SQL Isolation Levels (1995) that those definitions are ambiguous, miss lost updates and write skew entirely, and have no place for snapshot isolation, the level most MVCC databases implement.
?So what does "repeatable read" mean in your database?
Whatever the vendor decided. Martin Kleppmann's Hermitage project ran the same anomaly tests against each database and recorded what each level is. The table uses one name we haven't met. Monotonic atomic view is a level slightly stronger than read committed: once you see any of another transaction's writes, you see all of them, but two statements in your transaction can still see different sets of committed data. Read committed built on per-statement snapshots, as in section 5.4, gives exactly that.
| Database | Level asked for | What you get | Lost update? | Write skew? |
|---|---|---|---|---|
| Postgres | read committed (default) | Monotonic atomic view | Allowed | Allowed |
| Postgres | repeatable read | Snapshot isolation | Prevented | Allowed |
| Postgres | serializable | Serializable | Prevented | Prevented |
| MySQL / InnoDB | repeatable read (default) | Monotonic atomic view | Allowed | Allowed |
| MySQL / InnoDB | serializable | Serializable (by locking) | Prevented | Prevented |
| Oracle | serializable | Snapshot isolation | Prevented | Allowed |
| SQL Server | snapshot | Snapshot isolation | Prevented | Allowed |
| CockroachDB | serializable (default) | Serializable | Prevented | Prevented |
11Working with isolation levels
11.1Where to look
Each question the chapter raised has a query that answers it on a running Postgres.
-- Who is holding back vacuum? Long-running and idle-in-transaction sessions,
-- oldest snapshot first (section 5.5)
select pid, state, xact_start, age(backend_xmin) as xmin_age, left(query, 60)
from pg_stat_activity where backend_xmin is not null order by xmin_age desc;
-- How often are deadlocks happening? (section 4.1)
-- Serialization failures (40001) aren't counted here; count them where you retry (section 9.3)
select datname, deadlocks from pg_stat_database;
-- Who is waiting on whom right now? (sections 4.1 and 6)
select pid, pg_blocking_pids(pid), wait_event_type, left(query, 60)
from pg_stat_activity where cardinality(pg_blocking_pids(pid)) > 0;
-- SIREAD locks held by serializable transactions (section 8.2)
select locktype, relation::regclass, page, tuple from pg_locks where mode = 'SIReadLock';Set log_lock_waits = on so any wait longer than deadlock_timeout is logged, with the waiting query and the process IDs holding the lock.
11.2Rules that hold up
- Do read-modify-write in SQL.
UPDATE … SET n = n + 1is correct at every level; "select, compute, update" is not (section 6). - Wrap every transaction at repeatable read or serializable in a retry loop that re-runs the reads and keeps side effects outside (section 9.3).
- Keep transactions short, and never wait on a user or a network call inside one. An idle-in-transaction session holds back vacuum for the whole database, so set
idle_in_transaction_session_timeout(section 5.5). - Lock rows in a consistent order, such as sorted by ID, so two transfers going opposite ways can't deadlock (section 4.1).
- Prefer a constraint to a check in code for uniqueness and non-overlap (section 7.2).
- Look for the hot row before raising the level. A global counter or per-tenant total costs retries at repeatable read and serializable (section 9.2).
- Read the Hermitage row for your database before trusting a level's name (section 10).
11.3What you trade for what
| You get | You pay | When the bill arrives |
|---|---|---|
| Read committed: no retries, readers never wait | Two statements in a transaction can disagree, and read-modify-write loses updates | As counters or balances drifting low under load |
| Repeatable read: a frozen view, lost updates become errors | Retries after 40001, and old row versions kept for as long as the transaction lives | As errors reaching users, or table bloat |
| Serializable: no anomalies | Retries, read tracking in memory, and some false-positive aborts | As 40001 on low-contention data when scans take coarse SIREAD locks |
| MVCC: readers and writers don't block | Old versions to store and clean up | As bloat when an old snapshot holds back vacuum |
| Locking (2PL): serializable without a versioned snapshot | Readers and writers wait for each other, and deadlocks | As stalls and 40P01 under load |
11.4Symptom, cause, fix
| Symptom | Likely cause | Fix |
|---|---|---|
| Counters or balances drift low under load | Lost update at read committed | Atomic UPDATE … SET n = n + 1, or FOR UPDATE |
| An invariant across rows breaks rarely | Write skew under snapshot isolation | Constraint, lock the rows read, or serializable with retries |
Sporadic 40001 errors reach users | Repeatable read or serializable without a retry loop | Retry the whole transaction with backoff |
deadlock detected in logs | Transactions lock rows in different orders | Lock in a consistent order (sort IDs); keep transactions short |
| Tables and indexes bloat; vacuum reports "dead but not yet removable" | An old snapshot: idle in transaction, a long report, or a replication slot (a bookmark kept for a copy of the database) nobody reads any more | Find backend_xmin; idle_in_transaction_session_timeout; run reports on a replica, a read-only copy |
Many 40001 under serializable on low-contention data | Coarse SIREAD locks from sequential scans | Index the filtered columns; declare read-only transactions READ ONLY |
12Summary
- A transaction makes a group of steps all-or-nothing. The crashed transfer left 1,400 without one and 1,500 with one.
- Atomicity and durability are about crashes; isolation is about overlap. Isolation is the ACID letter that comes in strengths.
- Anomalies are outcomes no one-at-a-time order could produce, from a dirty read of the half-done transfer to read skew, lost update and write skew. Each isolation level is defined by which it rules out.
- MVCC means readers and writers don't block each other. Writers still lock rows and wait for each other, and locking databases pay in waits and deadlocks instead.
- A Postgres transfer is new row versions plus one commit-log entry, and a snapshot is
xmin:xmax:xip. A crashed transaction never gets a committed entry, so its versions stay invisible; a reader compares each version'sxminandxmaxagainst its snapshot, withpg_xactfor commit status. - Read committed takes a snapshot per statement, repeatable read per transaction. That's the whole difference between them in Postgres, and old snapshots stop vacuum: one open repeatable-read transaction kept 1,000 dead rows from being removed.
- Read committed loses updates done in application code. Use atomic
UPDATEs orFOR UPDATE; repeatable read turns the loss into a40001error. - Snapshot isolation allows write skew. Two transactions writing different rows can break an invariant together; only serializable, a constraint or explicit locking stops it.
- SSI tracks reads with non-blocking SIREAD locks and aborts on two consecutive rw-conflicts, with some false positives.
- Serializable's real cost is retries on hot rows, which repeatable read pays too: roughly a quarter of transactions retried on a single hot row.
- Level names don't carry across databases. MySQL's repeatable read allows lost updates, Postgres's doesn't, and Oracle's serializable allows write skew.
13Build this
Your own Hermitage.
- Write a harness that opens two or three connections and runs a scripted interleaving of statements, recording each result and error code.
- Encode seven tests: dirty write, dirty read, non-repeatable read, phantom, lost update, read skew and write skew. Run each at every level Postgres offers and check your table against section 10.
- If you can, run the same tests against MySQL. The lost-update result at repeatable read is the one that surprises people.
- Add a contention sweep: pgbench's TPC-B-like script at scale 1, 10 and 100 with 8 clients, at each level, recording tps and retry rate. Plot retry rate against scale.
14Interview questions
beginnerWhat's the difference between read committed and repeatable read in Postgres?›
When the snapshot is taken. Read committed takes a new one for every statement, so two queries in one transaction can see different data. Repeatable read takes one at the first statement and uses it for the whole transaction, and a write to a row changed since then fails with a serialization error instead of overwriting it.
beginnerWhat is a lost update, and how do you prevent it?›
Two transactions read the same value, each computes a new one, and the second write overwrites the first. Prevent it with an atomic UPDATE … SET n = n + 1, a SELECT … FOR UPDATE before computing, an optimistic version column in the WHERE, or by running at repeatable read or serializable with retries.
intermediateWhat is write skew? Give an example snapshot isolation allows.›
Two transactions read overlapping data, make decisions based on it, and write different rows, so there's no write-write conflict to detect. Two on-call doctors each see the other on call and both go off: at Postgres repeatable read both commit and nobody is on call. Serializable, a constraint or locking the rows read prevents it.
intermediateHow does Postgres decide whether a row version is visible?›
It compares the version's xmin (creator) and xmax (deleter) against the snapshot: XIDs below the snapshot's xmin are settled, XIDs at or above its xmax or listed in xip are treated as still running. A version is visible if its creator committed before the snapshot and its deleter, if any, didn't. Commit status comes from pg_xact, cached on the tuple as hint bits.
intermediateWhy can a long-running transaction hurt a database it's only reading from?›
Its snapshot may still need old row versions, so vacuum can't remove any version newer than the oldest running snapshot in that database. Dead tuples pile up, tables and indexes bloat, and scans slow down. The worst case is a session idle in transaction for hours.
deepHow does serializable snapshot isolation work, and why does it have false positives?›
Each serializable transaction runs on a snapshot and records its reads as non-blocking SIREAD locks. When a write hits a row another concurrent transaction read, that's a rw-conflict edge. Every SI anomaly contains two consecutive rw-edges (Tin → Tpivot → Tout), so SSI aborts when it sees that structure, without proving a full cycle exists. That, and SIREAD locks coarsened to pages or whole relations, causes aborts where no anomaly would have happened.
deepYour team wants to switch Postgres to serializable everywhere. What do you check first?›
That every transaction runs inside a retry loop that re-executes it from the first read, with side effects outside the loop. Then hot rows: serializable and repeatable read both abort on write-write conflicts, which on a single hot row meant roughly a quarter of transactions retrying in a pgbench run. Then indexes on filtered columns, so SIREAD locks stay at tuple level, and marking read-only transactions READ ONLY (or DEFERRABLE for long reports).
15Go deeper
A snapshot reads 2051525:2051528:2051525. Is a row with xmin 2051526 visible?›
Yes, if 2051526 committed. It's between xmin and xmax and not in the in-progress list, so the snapshot counts it as finished.
Which Postgres level first prevents lost updates?›
Repeatable read. The second writer gets a 40001 serialization failure.
Two transactions write different rows. Can snapshot isolation abort either?›
No. Its only conflict rule is two writes to the same row. That's why write skew gets through.
Why did a count(*) take a relation-level SIREAD lock?›
It used a sequential scan, which reads the whole relation, so any insert or update to the table conflicts with it.
Why the standard's definitions are ambiguous, and where snapshot isolation and write skew come from. Short and still the best starting point.
The G0, G1 and G2 definitions that Jepsen, Hermitage and most modern papers use.
The SSI algorithm: dangerous structures and why checking for them is enough.
How SSI was built into a production database: the predicate lock manager, read-only optimisations, bounded memory and benchmarks.
Scripted tests of each anomaly against Postgres, MySQL, Oracle, SQL Server and others, with the table of what each level provides.
The official statement of what each level does, including the retry advice and the serializable caveats.
16Related chapters
Where row versions live on the page, and how the commit record in the WAL provides atomicity. Chapter 18.
The mutexes and wait queues under row locks, and deadlock in general. Chapter 13.
Why one hot row caps throughput, and what retries do to the tail. Chapter 16.
Why the same query takes a tuple-level or relation-level SIREAD lock: the planner's choice of index or sequential scan. Chapter 20.