PostgreSQL transaction isolation levels in practice

PostgreSQL implements three distinct isolation levels — Read Committed, Repeatable Read and Serializable. (READ UNCOMMITTED exists for SQL-standard compatibility but behaves exactly like Read Committed: PostgreSQL never shows you uncommitted data.) The differences only matter under concurrency, which is exactly when they're hardest to debug — so it pays to know them before the incident.

Read Committed: the default, and its blind spot

Each statement sees a snapshot of the database as of the moment that statement began. Two consequences:

The idiomatic fixes at this level:

-- make the read-modify-write atomic in one statement:
UPDATE accounts SET balance = balance - 10 WHERE id = 1;

-- or lock the row at read time:
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;

A useful detail: when an UPDATE at Read Committed finds a row already changed by a concurrent committed transaction, it re-evaluates its WHERE clause on the new version and proceeds. You don't get an error — which is convenient, and occasionally the source of very subtle bugs.

Repeatable Read: one snapshot for the whole transaction

The snapshot is taken at the first query and kept until commit: every statement sees the same data. In PostgreSQL this also prevents phantom reads (stronger than the SQL standard requires). The price appears on writes:

ERROR:  could not serialize access due to concurrent update

If you try to modify a row that a concurrent transaction changed and committed after your snapshot was taken, PostgreSQL cannot pretend both orders happened — it aborts you. Your application must be prepared to retry the whole transaction. Repeatable Read is the natural level for consistent multi-query reads: reports, exports, backups (pg_dump runs at this level).

Serializable: as if transactions ran one at a time

Serializable in PostgreSQL is Repeatable Read plus SSI (serializable snapshot isolation): the engine tracks read/write dependencies between concurrent transactions and aborts one whenever a cycle would make the outcome impossible under any serial order. It catches anomalies Repeatable Read cannot — the textbook case is write skew:

-- Two doctors, rule: at least one must stay on call.
-- T1: SELECT count(*) FROM oncall;  -- sees 2, so it's safe to leave
-- T2: SELECT count(*) FROM oncall;  -- sees 2, so it's safe to leave
-- T1: DELETE FROM oncall WHERE doctor = 'alice'; COMMIT;
-- T2: DELETE FROM oncall WHERE doctor = 'bob';   COMMIT;
-- Repeatable Read: both commit, zero doctors on call.
-- Serializable: one of them gets error 40001 and must retry.

Costs: some CPU and memory for dependency tracking, and — importantly — false positives: transactions can be aborted "just in case". All transactions in the workload must run Serializable for its guarantees to hold, and every one of them needs retry logic.

Retrying correctly

Choosing in practice

SituationReasonable choice
Typical OLTP with targeted row updatesRead Committed + atomic updates / SELECT FOR UPDATE where it matters
Multi-statement reads that must be mutually consistentRepeatable Read
Invariants spanning multiple rows ("at least one", "sum must not exceed", double-booking)Serializable with retries — or explicit locking if the hot spots are few and known
Job queuesRead Committed + FOR UPDATE SKIP LOCKED

Set the level per transaction (BEGIN ISOLATION LEVEL SERIALIZABLE) or per session; mixing levels is fine except that, as noted, Serializable's guarantees require all related transactions to use it.

🔍 Related reading: VACUUM & bloat — long transactions at any isolation level hold back cleanup for the whole database.