Module P-1·26 min read

Read-committed vs repeatable-read vs serializable, and what lost updates and write skew look like when they hit real money.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-1 — ACID, Transactions, and Isolation Levels

What this module covers: Transactions are the feature engineers trust most and understand least, and the gap surfaces as a single-use coupon redeemed twice or a refund total exceeding the order. This module covers what each ACID letter guarantees — including the vocabulary collision hiding in the C — the three read anomalies plus lost update and write skew, which isolation levels PostgreSQL actually implements versus what the standard describes, what snapshot isolation does and does not prevent, the real price of SERIALIZABLE, and the four-tool toolkit for a correct concurrent write. It also settles the check-then-insert race Module F-13 left open.


What ACID Promises, and the Letter That Misleads

The characteristic transaction bug passes every test, then appears the day a campaign goes out as a number that shouldn't exist: 1,003 redemptions of a single-use code, stock = -4, two refunds against one payment summing to more than the charge. Each needs two transactions to overlap in a window microseconds wide — a window load buys you thousands of times a second. Only one of ACID's four letters is involved, and it's the only one you have to make a decision about.

Atomicity — all of it or none of it. On abort every change is discarded. Note what it is not: atomicity says nothing about concurrency. It covers the failure path — a crash, a constraint violation, an exception between two UPDATEs. Without it the symptom is money that left one account and never arrived at the other, found by a 3am reconciliation job.

Consistency — your declared constraints hold at commit. The weakest letter, and the source of the worst vocabulary collision in the field.

The collision, stated once so it stops confusing you: the C in ACID means constraint preservation — your NOT NULL, CHECK, FOREIGN KEY, and UNIQUE declarations are true at commit. It is unrelated to the "consistency" of Module P-11 (Consistency Models) or the C in CAP (Module P-10), which concern whether different readers of a replicated system see the same value. A single-node Postgres delivers ACID's C completely and has no distributed consistency to speak of; a globally replicated store can be eventually consistent in P-11's sense while enforcing every constraint you declared. Establish which one someone means before designing anything.

Isolation — concurrent transactions behave as if they ran one at a time. A dial rather than a boolean, and the rest of this module.

Durability — a commit survives a crash, at whichever point on Module F-2's spectrum you chose: page cache, fsynced to device, or acknowledged by a replica in another AZ (Module P-5).

A and D the engine largely handles; C follows from the constraints you bothered to declare (Module F-13). I is where the default is not the safe option.


The Anomalies, and the Two That Take Your Money

Dirty read — reading another transaction's uncommitted write; if it rolls back you acted on a value that never existed. A report reads an in-flight import and publishes totals for rows later discarded.

Non-repeatable read — reading one row twice in a transaction and getting different values. A fee calculation reads account.tier to pick a rate, then reads it again to write the audit line; the customer upgraded in between, so the audit record disagrees with the charge.

Phantom read — re-running the same query and getting a different set of rows. An invoice job counts billable events, then sums them; four arrived in between, so the count and the sum disagree.

Those three are reads going strange. The next two silently discard writes, and they are the ones on the incident review.

Lost update — two transactions each read a value, compute from it, and write back; the second overwrites the first, which is simply gone. The read-modify-write cycle, and the most common concurrency bug in application code:

typescript

With 500 concurrent buyers, dozens read the same stock and all write stock - 1 computed from it. You sell 40 units, the counter drops by 3, and the warehouse finds the oversell.

Write skew — two transactions each read a set of rows, each check an invariant spanning that set, each see it satisfied, and each write to a different row. Nothing conflicts, and the invariant breaks anyway. The rule is "total refunded must not exceed the order total," and refunds are separate rows. Two agents each open a £60 refund against a £100 order; each sums the existing refunds (£0), sees £60 ≤ £100, inserts its own row. £120 refunded. The invariant was never "this row must not change" — it was a statement about a set, and set-level statements are what write skew defeats.

Why this matters in production: these two survive the isolation level you are almost certainly running now, while dirty reads are a solved problem nobody meets on Postgres. If a postmortem says "race condition" and the affected number was money, inventory, or entitlement, it was one of these — and the fix differs for each.


What Postgres Implements Is Not What the Standard Describes

LevelStandard permitsPostgres actually permits
READ UNCOMMITTEDdirty, non-repeatable, phantombehaves as READ COMMITTED — no dirty reads
READ COMMITTED (default)non-repeatable, phantomnon-repeatable, phantom, lost update, write skew
REPEATABLE READphantomno phantoms — snapshot isolation; write skew remains
SERIALIZABLEnothingnothing, at the cost of serialisation failures

You cannot get a dirty read in Postgres at any level. READ UNCOMMITTED is accepted and silently gives you READ COMMITTED. Not laziness: MVCC (Module F-13) hands a reader a row version committed as of some instant, so no code path exposes an uncommitted one. Tuning down for performance changes nothing.

Postgres's REPEATABLE READ is stronger than the standard requires — one snapshot for the whole transaction, so phantoms can't occur either, putting it between the standard's REPEATABLE READ and its SERIALIZABLE.

That snapshot is the mechanism, and when it's taken is the only difference between the two useful levels. READ COMMITTED takes a fresh one at the start of every statement, which is why two SELECTs can disagree. REPEATABLE READ takes one at the first statement and holds it for the whole transaction — consistent, and increasingly stale.

Analogy: isolation levels are the rules of a shared workshop with one whiteboard of measurements. READ COMMITTED means glancing up at the board each time you need a figure: every glance is current, and two glances may disagree, because colleagues keep rubbing things out. REPEATABLE READ hands you a photograph of the board taken as you walked in — internally consistent all day, quietly out of date by lunch. SERIALIZABLE adds a supervisor who watches everyone's photographs and, when two people have acted on figures the other has since changed, tears up one person's work and sends them back to the door. Nobody's work comes out corrupted; somebody's work gets thrown away, and noticing that is your job.


Why REPEATABLE READ Doesn't Save the Balance Check

Snapshot isolation is a large upgrade and an incomplete one.

It fixes non-repeatable reads and phantoms. Every read agrees; your count and your sum cover the same rows.

It fixes lost update on the same row — by aborting you. Postgres's REPEATABLE READ is first-updater-wins: if your UPDATE targets a row another transaction modified and committed since your snapshot, Postgres raises could not serialize access due to concurrent update (SQLSTATE 40001) and your transaction is dead. Correct, and underappreciated — REPEATABLE READ already obliges you to write retry logic. Teams that switch for the consistent reads and skip retries have converted a corruption bug into a user-visible 500: an improvement, but not the one they expected.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.