Transaction isolation levels

Every transaction runs at one of four isolation levels. The level decides one thing: how much of what other, concurrent transactions are doing your transaction is allowed to see.

Postgres implements only three of them for real — READ UNCOMMITTED is accepted for standards compliance and silently upgraded. Here's what each one actually guarantees, from the official docs:

Isolation levelWhat your transaction sees
READ UNCOMMITTEDAccepted, but treated exactly like READ COMMITTED. Postgres has no dirty reads.
READ COMMITTED (default)Every statement re-reads the latest committed data, even mid-transaction.
REPEATABLE READThe whole transaction sees one snapshot, frozen at its first query.
SERIALIZABLESame frozen snapshot, plus conflict detection: as if transactions ran one at a time.

The scenario

Alice and Bob are both on call. The rule: at least one engineer must always be on call. Before going off call, each of them checks "is someone else still covering?" — and only steps down if the answer is yes.

Check, then act

Run alone, Alice's script is completely ordinary: check, then update, then commit.

BEGIN;

-- Step 1: Check
SELECT count(*) AS other_on_call
FROM engineer
WHERE on_call = true AND name <> 'Alice';

-- Step 2: Update — nothing re-checks that count before writing. This just
-- trusts the Step 1 result and never looks at anyone else's row again.
UPDATE engineer SET on_call = false WHERE name = 'Alice';

COMMIT;

SELECT name, on_call FROM engineer ORDER BY name;

The race

Now imagine Bob's transaction interleaved with Alice's, in this order:

  1. Alice starts her transaction and runs Step 1: Check — sees 1 (Bob is on call).
  2. Bob starts his own transaction and runs his Step 1: Check — sees 1 (Alice is on call).
  3. Bob runs Step 2: Update and commits — Bob is off call.
  4. Alice, still holding her Step 1 answer, runs Step 2: Update and commits — Alice is off call too.

This playground can't run two sessions at once — the engine underneath is single-connection — so the diagram and scripts below show what two real, concurrent sessions would do, the same way the Postgres docs present two-session examples: as a script to read, not to run.

READ UNCOMMITTED · READ COMMITTED · REPEATABLE READboth commits succeed — on_call count = 0AliceBEGINStep 1: CheckStep 2: UpdateCOMMITBobBEGINStep 1: CheckStep 2: UpdateCOMMITSERIALIZABLEBob's commit is rejected — on_call count stays 1AliceBEGINStep 1: CheckStep 2: UpdateCOMMITBobBEGINStep 1: CheckStep 2: UpdateCOMMIT — rejected (40001)

READ UNCOMMITTED / READ COMMITTED

Both behave the same way here: neither Alice nor Bob ever look again after their first check, so both go off call.

-- SESSION A (Alice)
BEGIN ISOLATION LEVEL READ UNCOMMITTED;

-- Step 1: Check — gets its own fresh snapshot, taken right now (READ
-- COMMITTED's per-statement guarantee).
SELECT count(*) AS other_on_call
FROM engineer
WHERE on_call = true AND name <> 'Alice';
-- => 1  (Bob is on call, looks safe to go off call)

-- Meanwhile, SESSION B (Bob) runs the identical two steps for himself and
-- commits FIRST:
--
--   SESSION B (Bob):
--   BEGIN ISOLATION LEVEL READ UNCOMMITTED;
--   -- Step 1: Check
--   SELECT count(*) AS other_on_call
--   FROM engineer
--   WHERE on_call = true AND name <> 'Bob';
--   -- => 1  (Alice is on call, looks safe to go off call)
--   -- Step 2: Update
--   UPDATE engineer SET on_call = false WHERE name = 'Bob';
--   COMMIT;

-- Step 2: Update — gets its own fresh snapshot too, same as Step 1 — but
-- it only writes Alice's row and never reads on_call for anyone else, so
-- Bob's already-committed change is invisible to it anyway.
UPDATE engineer SET on_call = false WHERE name = 'Alice';
COMMIT;

-- Both commits succeed. Final state:
SELECT name, on_call FROM engineer ORDER BY name;
-- Alice    | false
-- Bob      | false
--
-- Nobody is on call. The bug isn't about visibility — Step 2 never looked
-- at Bob's row, fresh snapshot or frozen. It's about acting on a decision
-- (Step 1's result) that nothing re-verified before commit.

READ COMMITTED (default)

Same outcome — the default isolation level does not help here.

-- SESSION A (Alice)
BEGIN ISOLATION LEVEL READ COMMITTED;

-- Step 1: Check — gets its own fresh snapshot, taken right now (READ
-- COMMITTED's per-statement guarantee).
SELECT count(*) AS other_on_call
FROM engineer
WHERE on_call = true AND name <> 'Alice';
-- => 1  (Bob is on call, looks safe to go off call)

-- Meanwhile, SESSION B (Bob) runs the identical two steps for himself and
-- commits FIRST:
--
--   SESSION B (Bob):
--   BEGIN ISOLATION LEVEL READ COMMITTED;
--   -- Step 1: Check
--   SELECT count(*) AS other_on_call
--   FROM engineer
--   WHERE on_call = true AND name <> 'Bob';
--   -- => 1  (Alice is on call, looks safe to go off call)
--   -- Step 2: Update
--   UPDATE engineer SET on_call = false WHERE name = 'Bob';
--   COMMIT;

-- Step 2: Update — gets its own fresh snapshot too, same as Step 1 — but
-- it only writes Alice's row and never reads on_call for anyone else, so
-- Bob's already-committed change is invisible to it anyway.
UPDATE engineer SET on_call = false WHERE name = 'Alice';
COMMIT;

-- Both commits succeed. Final state:
SELECT name, on_call FROM engineer ORDER BY name;
-- Alice    | false
-- Bob      | false
--
-- Nobody is on call. The bug isn't about visibility — Step 2 never looked
-- at Bob's row, fresh snapshot or frozen. It's about acting on a decision
-- (Step 1's result) that nothing re-verified before commit.

REPEATABLE READ

This is the surprising one. Freezing the snapshot doesn't stop the bug — it just means neither transaction would have noticed the change even if it had looked again. The invariant still breaks.

-- SESSION A (Alice)
BEGIN ISOLATION LEVEL REPEATABLE READ;

-- Step 1: Check — takes the transaction's ONE snapshot, frozen from this
-- moment on; every later statement in this transaction reuses it.
SELECT count(*) AS other_on_call
FROM engineer
WHERE on_call = true AND name <> 'Alice';
-- => 1  (Bob is on call, looks safe to go off call)

-- Meanwhile, SESSION B (Bob) runs the identical two steps for himself and
-- commits FIRST:
--
--   SESSION B (Bob):
--   BEGIN ISOLATION LEVEL REPEATABLE READ;
--   -- Step 1: Check
--   SELECT count(*) AS other_on_call
--   FROM engineer
--   WHERE on_call = true AND name <> 'Bob';
--   -- => 1  (Alice is on call, looks safe to go off call)
--   -- Step 2: Update
--   UPDATE engineer SET on_call = false WHERE name = 'Bob';
--   COMMIT;

-- Step 2: Update — reuses the frozen snapshot from Step 1, but that
-- makes no difference here: it only writes Alice's row and never reads
-- on_call for anyone else.
UPDATE engineer SET on_call = false WHERE name = 'Alice';
COMMIT;

-- Both commits succeed. Final state:
SELECT name, on_call FROM engineer ORDER BY name;
-- Alice    | false
-- Bob      | false
--
-- Nobody is on call. The bug isn't about visibility — Step 2 never looked
-- at Bob's row, fresh snapshot or frozen. It's about acting on a decision
-- (Step 1's result) that nothing re-verified before commit.

SERIALIZABLE

The only level that catches it. Postgres notices both transactions read a row set the other one wrote to, and refuses the second commit.

-- SESSION A (Alice)
BEGIN ISOLATION LEVEL SERIALIZABLE;

-- Step 1: Check — same frozen snapshot as REPEATABLE READ, but Postgres
-- also starts tracking which rows this transaction's reads depended on.
SELECT count(*) AS other_on_call
FROM engineer
WHERE on_call = true AND name <> 'Alice';
-- => 1  (Bob is on call, looks safe to go off call)

-- Meanwhile, SESSION B (Bob) runs the identical two steps for himself and
-- commits FIRST:
--
--   SESSION B (Bob):
--   BEGIN ISOLATION LEVEL SERIALIZABLE;
--   -- Step 1: Check
--   SELECT count(*) AS other_on_call
--   FROM engineer
--   WHERE on_call = true AND name <> 'Bob';
--   -- => 1  (Alice is on call, looks safe to go off call)
--   -- Step 2: Update
--   UPDATE engineer SET on_call = false WHERE name = 'Bob';
--   COMMIT;  -- succeeds, Bob is now off call

-- Step 2: Update — still never reads Bob's row directly. SERIALIZABLE
-- isn't relying on that: it tracks that Step 1 depended on rows Bob just
-- wrote to, and checks that dependency at commit time.
UPDATE engineer SET on_call = false WHERE name = 'Alice';
COMMIT;
-- ERROR:  could not serialize access due to read/write dependencies
--         among transactions
-- SQLSTATE: 40001
--
-- Postgres refused this commit — not because Step 2 read something
-- stale, but because it detected the read/write conflict between the two
-- transactions as a whole. The app is expected to catch 40001 and retry —
-- on retry, Alice's Step 1 would see Bob is already off call and correctly
-- refuse to go off call herself.

Choosing a level

Stricter isolation doesn't just add guarantees — it adds cost, usually in the form of more aborted transactions your application has to retry.

Isolation levelProtects againstCost
READ UNCOMMITTEDNothing extra — identical to READ COMMITTED in Postgres.None. It's free, but also pointless to ask for.
READ COMMITTED (default)Nothing beyond "don't read uncommitted data."Cheapest: no extra locking, never aborts a transaction for isolation reasons.
REPEATABLE READNon-repeatable reads, phantom reads.Holds a snapshot open for the whole transaction; a concurrent write to a row you also wrote aborts your transaction immediately.
SERIALIZABLEEverything above, plus write skew — a real "as if run one at a time" guarantee.Highest overhead: tracks read/write dependencies across every concurrent SERIALIZABLE transaction. More aborts (SQLSTATE 40001) — your app must catch and retry them.

Default to READ COMMITTED unless you have a specific reason not to — it's what most queries run at anyway, including in this lesson's own scenario, where it made no difference. Reach for REPEATABLE READ when you need a consistent multi-statement read, like a report or an export. Reach for SERIALIZABLE only when you have a genuine cross-row invariant like this lesson's write-skew case — and only if your application actually retries on 40001, since without that, SERIALIZABLE just trades silent corruption for silent failure.

Try Yourself

Loaded Database:transaction-isolation-levels.database-init

The database for this lesson is already loaded. Write any query you want and run it directly in your browser.

Exercises

0 OF 2 DONE
1.Return the names of the engineers currently on call, alphabetically.
2.Start a transaction at the SERIALIZABLE isolation level and confirm it with SHOW.

What We Learned

  • READ COMMITTEDPostgres's default. Each statement gets a fresh view of committed data, even inside an open transaction.
  • REPEATABLE READThe whole transaction sees one snapshot taken at its first query — later commits from other transactions stay invisible to it.
  • SERIALIZABLEAdds real conflict detection on top of REPEATABLE READ's snapshot: if the result wouldn't match some one-at-a-time ordering of the transactions, Postgres rejects one of the commits instead of allowing it.
  • Write skewTwo transactions each read a shared fact, each act on what they read, and each commit successfully — but only because neither saw the other's change. Individually valid, together wrong.
  • SQLSTATE 40001The error code for a SERIALIZABLE conflict. An application using SERIALIZABLE is expected to catch it and retry the transaction.
  • SET TRANSACTION ISOLATION LEVELSets the isolation level for the current transaction; also settable as BEGIN ISOLATION LEVEL ....