Skip to content

ACID and Isolation Levels Explained

CoreConceptJuly 21, 20268 min read

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.

ACID: four distinct guarantees a transaction provides
ACID: four distinct guarantees a transaction provides

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."

ACID's Consistency (your constraints) vs CAP's Consistency (linearizability)
ACID's Consistency (your constraints) vs CAP's Consistency (linearizability)

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.

Skimmer: which anomalies each isolation level actually prevents.
Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadLost Update / Write Skew
Read CommittedPreventedPossiblePossiblePossible
Repeatable ReadPreventedPreventedPrevented in Postgres (snapshot)Write skew still possible
SerializablePreventedPreventedPreventedPrevented
Four anomalies, calibrated away by isolation level
Four anomalies, calibrated away by isolation level

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.

Read Committed → Repeatable Read → Serializable: more guarantees, less concurrency
Read Committed → Repeatable Read → Serializable: more guarantees, less concurrency

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.
Repeatable Read — write skew still possible
1-- Two on-call engineers, two sessions, same constraint: at least one must be on call.2-- Session A                          Session B3BEGIN ISOLATION LEVEL REPEATABLE READ;4SELECT count(*) FROM on_call WHERE active; -- sees 25                                      BEGIN ISOLATION LEVEL REPEATABLE READ;6                                      SELECT count(*) FROM on_call WHERE active; -- sees 27UPDATE on_call SET active = false8  WHERE engineer = 'alice';9                                      UPDATE on_call SET active = false10                                        WHERE engineer = 'bob';11COMMIT;                               COMMIT;12-- Both commits succeed — count() was 2 for both, each thought one active engineer remained.13-- Result: zero engineers on call. Neither transaction violated what IT individually read.
Serializable — SSI detects the conflict, one side must retry
1-- Same scenario under SERIALIZABLE2BEGIN ISOLATION LEVEL SERIALIZABLE;3-- ... same statements ...4COMMIT;5-- ERROR: could not serialize access due to read/write dependencies among transactions6-- Application catches the serialization_failure error class and retries the whole transaction.

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.

MVCC: UPDATE creates a new row version instead of overwriting in place
MVCC: UPDATE creates a new row version instead of overwriting in place

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.

Lost update: two withdrawals from the same stale balance read
Lost update: two withdrawals from the same stale balance read

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.
Read-modify-write in application code — the actual bug
1// Two concurrent requests, same account, same stale read2const row = await db.query("SELECT balance FROM accounts WHERE id = $1", [id]);3const newBalance = row.balance - 50; // both compute 100 - 50 = 504await db.query("UPDATE accounts SET balance = $1 WHERE id = $2", [newBalance, id]);5// Both writes succeed. Final balance: 50, not 0. One withdrawal vanished.
Atomic in-SQL arithmetic — the fix
1-- Single statement: the database reads the current row and computes from it,2-- not from a value the application read and cached earlier.3UPDATE accounts SET balance = balance - 50 WHERE id = $1;4 5-- Alternative when the check can't be expressed in one statement:6BEGIN;7SELECT balance FROM accounts WHERE id = $1 FOR UPDATE; -- locks the row8-- application logic here sees a balance no concurrent transaction can also lock9UPDATE accounts SET balance = balance - 50 WHERE id = $1;10COMMIT;

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.

Share:

Related Articles

A selective lookup that once scanned most of a large table can become an index walk plus a few heap fetches — often the

Read

"The index exists, why is this query still slow?" is one of the most common database escalations — and the answer is alm

Read

A multi-tenant analytics platform partitions incoming events by tenant_id, hashed onto 16 shards — a design that looked

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.