ACID and Isolation Levels Explained
Two support agents both open the same account balance, both see $100, both independently process a $50 withdrawal, and the balance ends up at $50 instead of $0 — one withdrawal simply vanished. The database never crashed, no constraint was violated, and every individual statement succeeded. The bug is a lost update, and it happens at the isolation level of ACID — the property responsible for what one transaction is allowed to see of another's concurrent work.
This guide defines each ACID letter precisely (including the one most often confused with an unrelated CAP-theorem concept of the same name), maps the specific anomalies Read Committed, Repeatable Read, and Serializable each allow or prevent, and explains the MVCC mechanism that lets Postgres avoid blocking readers on writers at all. Then it traces the lost-update bug above to its actual fix. For how these guarantees change once data is sharded or replicated, see Strong vs Eventual Consistency.
The Four ACID Properties, Precisely
Atomicity means a transaction's writes are all-or-nothing — if any part fails, every part rolls back, leaving no partial write visible to anyone. Durability means once a transaction commits, its writes survive a crash — a write-ahead log flushed to disk before the commit acknowledgment returns is the usual mechanism. Isolation governs what one in-progress transaction can see of another concurrent transaction's uncommitted or recently committed work — and it's the one with the most real variation, covered in depth below.
Consistency is the one worth pausing on, because it means something different here than the C in the CAP theorem. ACID's consistency means a transaction takes the database from one state that satisfies its declared invariants (foreign keys, unique constraints, check constraints) to another state that also satisfies them — it is a property the application's schema defines and the database enforces mechanically, not a guarantee about concurrent visibility. CAP's consistency (more precisely, linearizability) is about whether concurrent reads across replicas observe operations in a single global order — a completely different axis, and conflating the two is a common source of confused conversations about "consistency."
Quick reference
- Atomicity: all-or-nothing — a failed step rolls back every step in the same transaction.
- Durability: a committed transaction survives a crash, typically via a write-ahead log flushed before acknowledgment.
- Isolation: what one transaction can see of another's concurrent work — the property with real per-engine variation.
- Consistency (ACID): the database enforces your declared constraints across a transaction — it does not mean 'no stale reads,' that's a different, isolation-level concern.
- Do not conflate ACID's Consistency with CAP's Consistency (linearizability) — same word, unrelated concepts, common source of confusion in design discussions.
Remember this
ACID's Consistency means your declared schema constraints hold across a transaction — it is not the same concept as CAP's Consistency, and isolation, not consistency, is what actually governs concurrent visibility.
Isolation Levels and the Anomalies They Allow
Running transactions one at a time would remove every concurrency bug, at the cost of throughput no real system accepts. Isolation levels are the calibrated trade-off: each one forbids some anomalies and tolerates others in exchange for more concurrency. A dirty read sees another transaction's uncommitted write — if that transaction rolls back, you read data that never actually existed. A non-repeatable read re-reads the same row within one transaction and gets a different value because another transaction committed a change in between. A phantom read re-runs the same range query and gets a different set of rows because another transaction inserted or deleted a matching row. A lost update happens when two transactions each read the same row, compute a new value from that stale read, and write it back — one write silently overwrites the other with no error from either side.
The SQL standard defines four isolation levels — Read Uncommitted, Read Committed, Repeatable Read, Serializable — as a matrix of which anomalies each forbids. In practice, almost no production database implements Read Uncommitted's dirty reads (Postgres's Read Uncommitted behaves identically to Read Committed), so the three levels that actually matter day to day are Read Committed, Repeatable Read, and Serializable.
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read | Lost Update / Write Skew |
|---|---|---|---|---|
| Read Committed | Prevented | Possible | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Prevented in Postgres (snapshot) | Write skew still possible |
| Serializable | Prevented | Prevented | Prevented | Prevented |
Quick reference
- Dirty read: seeing another transaction's uncommitted write — forbidden by every level except Read Uncommitted.
- Non-repeatable read: the same row changes value on a second read within one transaction.
- Phantom read: the same range query returns a different row set on a second execution.
- Lost update: two transactions each do read-modify-write on stale data; one update silently disappears.
- Write skew: a subtler version where two transactions each check a different-but-related invariant and both proceed when only one should have — Serializable is the only level that closes this.
Remember this
Each isolation level is a specific, named trade — Read Committed permits non-repeatable reads and phantoms for speed; only Serializable closes every anomaly including write skew.
Read Committed vs Repeatable Read vs Serializable in Postgres
Read Committed is Postgres's default: each individual statement within a transaction sees a fresh snapshot taken at that statement's start, so one statement never sees another's uncommitted data, but two statements in the same transaction can see different committed states if something changed in between. This is fast and matches what most CRUD request handlers actually need.
Repeatable Read takes one snapshot for the entire transaction, not per statement — every statement sees the same consistent view regardless of what commits elsewhere in the meantime. Because Postgres implements this via MVCC snapshot isolation rather than range-locking, it happens to prevent phantom reads too, which is stronger than the SQL standard's minimum requirement for the name "Repeatable Read" — a genuine case where the same isolation-level name means different guarantees on different engines (MySQL's InnoDB Repeatable Read has its own distinct locking behavior). What Postgres's Repeatable Read still permits is write skew: two transactions can each read a value, each conclude their own write is safe based on that read, and both commit — because neither one individually violated the constraint each was checking. Serializable (Postgres uses Serializable Snapshot Isolation, SSI) detects this pattern and aborts one of the transactions with a serialization failure, which the application must catch and retry.
Quick reference
- Read Committed: fresh snapshot per statement — fast, permits non-repeatable reads and phantoms.
- Repeatable Read (Postgres): one snapshot per transaction, prevents phantoms via MVCC — but write skew remains possible.
- Serializable (Postgres SSI): detects serialization conflicts and aborts one transaction — the application must retry on that specific error.
- The same isolation-level name can mean a different actual guarantee on a different database engine — verify against your specific engine's docs, not the SQL standard's minimum definition alone.
- Retrying a serialization failure is expected, normal behavior, not an error condition to alert on unless the retry rate spikes.
Remember this
Repeatable Read in Postgres is stronger than the SQL standard requires — it still permits write skew, which only Serializable's conflict detection and application-level retry actually closes.
MVCC: Why Readers Don't Block Writers
Multi-Version Concurrency Control is the mechanism that makes Postgres's isolation levels affordable. Instead of a reader waiting for a writer's lock to release, every row carries hidden version metadata (xmin/xmax transaction ids in Postgres), and an UPDATE doesn't overwrite a row in place — it inserts a new row version and marks the old one as superseded for transactions that started after the update. A transaction's snapshot determines which row versions it can see: any version created by a transaction that committed after your snapshot was taken is invisible to you, as if it hadn't happened yet from your point of view.
This is why, under Postgres's default Read Committed, a long-running report query never blocks a concurrent UPDATE, and vice versa — they're each working from their own consistent view of row versions, not fighting over the same lock. The cost is that old row versions accumulate until VACUUM reclaims them; a long-running transaction holding an old snapshot open can prevent cleanup and bloat the table.
Quick reference
- Each row version carries the transaction id that created it and (once superseded) the one that replaced it.
- A transaction's snapshot defines which committed versions are visible — later commits are invisible until a new snapshot is taken.
- Readers never block writers and writers never block readers under MVCC — they operate on different row versions, not a shared lock.
- VACUUM reclaims superseded row versions once no active snapshot can still see them — a long-idle transaction can block this and bloat tables.
- MVCC does not replace explicit row locking (SELECT ... FOR UPDATE) — that's still how you serialize a specific read-modify-write against other writers when the isolation level alone isn't enough.
- A long-idle transaction blocking VACUUM is a common source of the bloat covered in Database Scaling Techniques — the fix there is closing transactions promptly, not just adding hardware.
Remember this
MVCC lets Postgres give every transaction its own consistent snapshot of row versions instead of making readers and writers contend for the same lock — the trade is that superseded versions need VACUUM to reclaim.
Recover One Lost Update End to End
Trigger. Application code reads balance = 100 for an account, computes 100 - 50 = 50 in the application layer, and issues UPDATE accounts SET balance = 50. A second concurrent request does the identical read-compute-write for the same account at nearly the same time, also computing 50 from the same stale 100. Symptom. Both updates commit under Read Committed — neither statement conflicted with the other, because each was a plain UPDATE ... SET balance = 50, not a conditional check against the value it read. The account should be at 0 after two $50 withdrawals; it's at 50. One withdrawal is silently gone.
Root mechanism. The bug isn't the isolation level failing — it's that the read and the write are two separate statements with application logic in between, so nothing ties the write to the specific value that was read. Recovery: the fix is doing the arithmetic inside the SQL statement — UPDATE accounts SET balance = balance - 50 is a single, row-locked, atomic operation regardless of isolation level, because the database computes the new value from whatever the current row actually holds at write time, not from a value the application cached earlier. Verification: run both withdrawals concurrently against a test account seeded at 100, and confirm the final balance is exactly 0, not 50. Prevention: for any read-then-conditionally-write pattern that can't be expressed as one atomic statement, use SELECT ... FOR UPDATE to lock the row at read time, or raise the transaction to Serializable and handle the resulting retry.
Quick reference
- Trigger: application-layer read-compute-write, not a single atomic SQL statement.
- Symptom: two concurrent updates both succeed; the final value reflects only one of them.
- Root mechanism: nothing ties the write to the specific row version the application's arithmetic was based on.
- Recovery: express the update as one atomic in-SQL statement, or lock the row explicitly with SELECT ... FOR UPDATE.
- Prevention: treat any read-then-write-based-on-that-read pattern as a lost-update risk by default, not an edge case.
Remember this
A lost update almost always traces back to a read and a write as two separate steps with application logic between them — the fix is making the database compute the new value from the current row, not from a value the application cached.
Key takeaway
Atomicity, Consistency, Isolation, and Durability are four distinct guarantees — and Isolation is the one with real per-engine, per-level variation worth knowing precisely rather than by name alone. Read Committed permits stale reads within a transaction; Repeatable Read closes that but still allows write skew; only Serializable closes every anomaly, at the cost of application-level retry logic. MVCC is why Postgres can offer these levels without readers and writers blocking each other.
Practice (30 min): create an accounts table with a balance column, seed one row at 100. Reproduce the lost-update bug with two concurrent read-compute-write requests under Read Committed, and confirm the final balance is wrong. Fix it with a single atomic UPDATE ... SET balance = balance - amount, and separately with SELECT ... FOR UPDATE, confirming both produce the correct final balance under concurrent load. Then reproduce one write-skew scenario under Repeatable Read and show that raising the transaction to Serializable produces a serialization-failure error instead of a silently wrong result. Pass when you can name, for each fix, exactly which anomaly it closes and which isolation level guarantee it relies on.
Related Articles
Explore this topic