studyDDIAChapter 8 · Transactions

Postgres Won't Dirty-Read Even When You Beg It To

The SQL standard's weakest level, READ UNCOMMITTED, is defined by permitting dirty reads. Ask Postgres for it explicitly, have one session issue an uncommitted UPDATE, and read it from another — and you still get the old committed value. Postgres honors the level by name (SHOW transaction_isolation answers read uncommitted) but runs READ COMMITTED underneath. The one anomaly the level is named for is un-reproducible here.


Concept

The SQL standard defines a weakest isolation level, READ UNCOMMITTED, whose defining property is that it permits dirty reads: a transaction may see another transaction's writes before they commit. So ask Postgres for it explicitly, have one session issue an uncommitted UPDATE, and a second session — also at READ UNCOMMITTED — should be able to read the uncommitted value. It can't. Postgres accepts the level by name (SHOW transaction_isolation even answers read uncommitted), yet the second session still reads the old committed value. The one anomaly the SQL standard's weakest level is named for is un-reproducible on Postgres: READ UNCOMMITTED behaves exactly like READ COMMITTED.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 8 — Transactions, §"Read Committed" (the read-uncommitted paragraph) and §"Implementing read committed":

There are some databases that don't even implement read committed... some that do implement "read uncommitted" but which actually behave like read committed.

The book notes that the standard's isolation levels are defined by which anomalies they forbid, and that real databases diverge from those names. Postgres is the clean example: it offers READ UNCOMMITTED for standards compatibility but never actually exposes uncommitted data. Here you try to force the dirty read and watch it refuse.

Prerequisites

The surprise is about the gap between the named isolation levels and what a given engine actually implements. These help; each line notes where it shows up.

  1. The SQL standard's four isolation levels — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE, each defined by which anomalies it forbids. READ UNCOMMITTED is the only one that permits dirty reads. It shows up as the level Session A and B both request.
  2. What Postgres actually implements — only three distinct behaviors: READ COMMITTED (the default), REPEATABLE READ (snapshot isolation), and SERIALIZABLE (SSI). READ UNCOMMITTED is accepted but silently mapped to READ COMMITTED. It shows up as the dirty read never happening.
  3. Dirty read — reading a row another transaction has written but not yet committed. It shows up as the value Session B is trying (and failing) to see: A's uncommitted 400.
  4. MVCC (multi-version concurrency control) — Postgres gives each statement a snapshot of rows as of the last commit; an uncommitted UPDATE writes a new row version that is simply invisible to other snapshots. It shows up as why the dirty read is impossible, not merely disallowed.
Where to learn the prerequisites The four standard levels and the anomalies (#1, #3): DDIA §"Weak Isolation Levels" and §"Read Committed" — and free, the PostgreSQL manual chapter Transaction Isolation (https://www.postgresql.org/docs/16/transaction-iso.html), whose first table maps each requested level to the behavior Postgres gives it. MVCC (#4): DDIA §"Implementing read committed" / §"Snapshot Isolation and Repeatable Read"; PostgreSQL manual Concurrency Control (https://www.postgresql.org/docs/16/mvcc.html). If only one is new, make it #2 — the whole aha is that the name is honored while the behavior is a stronger level's.
Environment these transcripts came from The anomaly's absence is 100% reproducible; there is no timing to catch. PostgreSQL 16.14 (Debian, aarch64) in Docker on macOS 26.5.2 (Darwin 25.5.0), arm64; transcripts captured via psycopg[binary] on Python 3.12 (uv), two sessions against one database; deterministic.

Mental model

Two psql sessions against one database, one row: accounts(id=1, balance=500), committed. Session A is the writer: it opens a READ UNCOMMITTED transaction and runs UPDATE accounts SET balance = balance - 100 — but does not commit, so 400 exists only inside A's transaction. Session B is the reader: it also opens a READ UNCOMMITTED transaction and SELECTs the balance while A's write is still pending. Under the SQL standard's definition of READ UNCOMMITTED, B is allowed to see A's uncommitted 400. The question is whether Postgres lets it.

The friction is the point: you type this in two terminals by hand and watch one session's uncommitted write stay invisible to the other. The Python file code/dirty_read_impossible.py is the capture harness the maintainer re-runs to verify the transcripts — it is not the path you take here.

Setup

Start a throwaway Postgres and seed one committed row:

docker run -d --name ddia-ch08 \
  -e POSTGRES_PASSWORD=study -e POSTGRES_HOST_AUTH_METHOD=trust \
  -p 5432:5432 postgres:16

# seed accounts(id=1, balance=500), committed:
psql -h localhost -U postgres -d postgres \
  -c "CREATE TABLE accounts (id int PRIMARY KEY, balance int);" \
  -c "INSERT INTO accounts VALUES (1, 500);"

Now open two terminals:

# terminal 1 -- Session A (the writer):
psql -h localhost -U postgres -d postgres

# terminal 2 -- Session B (the reader):
psql -h localhost -U postgres -d postgres

Do not read ahead — make each prediction before you run the step.


Step 1 · Session A writes, without committing

Predict. In Session A, open a READ UNCOMMITTED transaction and subtract 100, but do not commit. Does Postgres accept the READ UNCOMMITTED level, or reject it / warn?

Do (Session A):

BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
Does Postgres accept READ UNCOMMITTED?
postgres=# BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN postgres=*# UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE 1

Postgres accepts READ UNCOMMITTED without complaint. A now privately holds balance = 400; nothing is committed. Leave this transaction open and switch to terminal 2.

Step 2 · Session B reads while A's write is pending (the surprise)

Predict. In Session B, also at READ UNCOMMITTED, first confirm the level with SHOW transaction_isolation, then read the balance while A's UPDATE is still uncommitted. What does SHOW report — and does B see 400 (A's uncommitted write — the dirty read) or 500 (the old committed value)?

Do (Session B):

BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation;
SELECT balance FROM accounts WHERE id = 1;
What level does SHOW report, and does B see 400 or 500?
postgres=# BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN postgres=*# SHOW transaction_isolation; transaction_isolation ----------------------- read uncommitted (1 row) postgres=*# SELECT balance FROM accounts WHERE id = 1; balance --------- 500 (1 row)

500, not 400 — no dirty read. And the twist: SHOW transaction_isolation cheerfully answers read uncommitted, so Postgres isn't rejecting or rewriting your request — it accepted the weakest level by name. Yet A's uncommitted write is still completely invisible. The level you asked for is the one thing that should let this dirty read through, and it doesn't.

Step 3 · A commits; B reads again

Predict. Back in Session A, COMMIT. Then in Session B (a new statement), SELECT the balance again. Now what does B see?

Do (Session A):

COMMIT;

Do (Session B):

SELECT balance FROM accounts WHERE id = 1;
After A commits, what does B's next read return?
-- Session A: postgres=*# COMMIT; COMMIT -- Session B: postgres=*# SELECT balance FROM accounts WHERE id = 1; balance --------- 400 (1 row)

Now B sees 400. The write was never lost or hidden by a bug — it was simply uncommitted, and Postgres only ever shows other transactions committed data. The moment A commits, B's next statement sees it. That is precisely READ COMMITTED behavior — which is what you got the whole time, under the name READ UNCOMMITTED.


What you should see

Why

The SQL standard defines its four isolation levels phenomenologically — by which of three anomalies (dirty read, non-repeatable read, phantom) each one is allowed to exhibit. READ UNCOMMITTED sits at the bottom: it is the level defined by being permitted to dirty-read. But "permitted" is not "required." A database is free to forbid more anomalies than the level demands, and if forbidding them is cheaper or simpler than allowing them, it will.

That is exactly Postgres's situation, and it falls out of MVCC. Under multi-version concurrency control, an UPDATE never overwrites a row in place; it writes a new version of the row stamped with the writing transaction's id, leaving the old version intact. Every statement reads against a snapshot that includes only versions from transactions that had committed as of when the snapshot was taken. A's uncommitted 400 is a row version whose creating transaction hasn't committed, so it fails the snapshot's visibility test for every other transaction — there is no code path that would return it. To support dirty reads, Postgres would have to add machinery to deliberately reach past the snapshot and expose uncommitted versions. Nobody wants that, so it was never built: Postgres implements only three distinct behaviors (read committed, repeatable read, serializable), and maps the fourth name onto the nearest one it has. SHOW still echoes read uncommitted because Postgres faithfully records the level you asked for — it just runs read-committed semantics underneath.

So the "downgrade" isn't a policy decision bolted on top; it's that the weakest standard level asks for less isolation than Postgres's cheapest implementation already provides. You cannot buy a weaker guarantee here because there is no weaker product on the shelf.

The boundary — this is a Postgres fact, not a SQL fact On engines whose storage does read live/in-place rows, READ UNCOMMITTED means what the standard says. Older MySQL/InnoDB is the classic counter-demo: run the identical two sessions with SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED and Session B will read Session A's uncommitted 400 — a real dirty read. Same SQL, same level name, opposite result. The isolation-level name is a contract about anomalies; the behavior is whatever the engine's concurrency-control implementation happens to deliver, and only the manual's own mapping table tells you which. Never reason about isolation from the level name alone.

Go deeper

Sources: DDIA 2e, Ch. 8, §"Read Committed", §"Implementing read committed" · PostgreSQL 16 manual, Transaction Isolation and Concurrency Control.