Isolation Levels
Isolation levels control what a transaction can see of other transactions' changes. PostgreSQL supports four levels, matching the SQL standard.
The Mnemonic
"Uncle Carl Rarely Showers"
| Level | Short | Mnemonic Word |
|---|---|---|
| READ UNCOMMITTED | RU | Uncle |
| READ COMMITTED | RC | Carl |
| REPEATABLE READ | RR | Rarely |
| SERIALIZABLE | S | Showers |
The Google Docs Analogy
Think of a shared Google Doc being edited by multiple people:
| Isolation Level | Google Docs Version |
|---|---|
| READ UNCOMMITTED | You see everyone's keystrokes in real time -- even half-typed words and things they're about to undo |
| READ COMMITTED | You only see saved versions of the doc. But the doc can update between your reads -- you might see different content each time you look |
| REPEATABLE READ | You downloaded a PDF when your transaction started. Your copy never changes, no matter what others do to the live doc |
| SERIALIZABLE | Everyone takes turns editing. Only one person can work at a time. Maximum consistency, minimum throughput |
The Three Problems
Each isolation level protects against progressively more anomalies. Think of it as a staircase -- each level adds protection on top of the previous one.
| Problem | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| Dirty reads | Possible | Prevented | Prevented | Prevented |
| Non-repeatable reads | Possible | Possible | Prevented | Prevented |
| Phantom reads | Possible | Possible | Possible* | Prevented |
PostgreSQL bonus: its REPEATABLE READ implementation also* prevents phantom reads (the SQL standard doesn't require this, but PostgreSQL does it anyway). Great interview tidbit.
graph TD
RU["READ UNCOMMITTED<br/>No protection"] -->|"+ prevents dirty reads"| RC["READ COMMITTED<br/>(PostgreSQL default)"]
RC -->|"+ prevents non-repeatable reads"| RR["REPEATABLE READ"]
RR -->|"+ prevents phantom reads<br/>(and serialization anomalies)"| S["SERIALIZABLE"]
style RU fill:#4a1a1a,stroke:#ff6666
style RC fill:#4a3a1a,stroke:#ffaa44
style RR fill:#2a3a1a,stroke:#aaff44
style S fill:#1a3a1a,stroke:#66ff66
Problem 1: Dirty Reads
Reading data that another transaction hasn't committed yet. That transaction might rollback, meaning you acted on data that never officially existed.
-- Transaction A
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1; -- was 1000
-- A has NOT committed yet
-- Transaction B (READ UNCOMMITTED)
SELECT balance FROM accounts WHERE id = 1;
-- Returns 200 (the uncommitted value!) -- this is a dirty read
-- Transaction A
ROLLBACK; -- A undoes the change
-- B just acted on a balance of $200 that never actually happened
sequenceDiagram
participant A as Txn A
participant DB as PostgreSQL
participant B as Txn B
A->>DB: BEGIN
A->>DB: UPDATE balance = 200<br/>(was 1000)
Note over DB: Uncommitted change<br/>exists in DB
B->>DB: BEGIN
B->>DB: SELECT balance
DB-->>B: Returns 200<br/>(dirty read!)
A->>DB: ROLLBACK
Note over A,DB: Change was undone!<br/>B acted on phantom data
Note over B: B thinks balance is $200<br/>but it was never committed
PostgreSQL note: READ UNCOMMITTED is not actually implemented. If you set it, PostgreSQL silently upgrades you to READ COMMITTED. So dirty reads cannot happen in PostgreSQL.
Problem 2: Non-Repeatable Reads
Same query, same transaction, different results. Another transaction committed a change between your two reads.
The classic bank balance example:
-- Transaction A (READ COMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- Returns $1000
-- Transaction B (concurrent)
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT; -- B commits the change
-- Back to Transaction A
SELECT balance FROM accounts WHERE id = 1;
-- Returns $200! Same query, different result.
-- If A was computing something based on the first read, it's now inconsistent.
sequenceDiagram
participant A as Txn A (READ COMMITTED)
participant DB as PostgreSQL
participant B as Txn B
A->>DB: BEGIN
A->>DB: SELECT balance WHERE id=1
DB-->>A: $1,000
B->>DB: BEGIN
B->>DB: UPDATE balance = 200
B->>DB: COMMIT
A->>DB: SELECT balance WHERE id=1
DB-->>A: $200 (different!)
Note over A: Same query, same transaction<br/>but got different results.<br/>This is a non-repeatable read.
How READ COMMITTED works in PostgreSQL: each statement gets its own snapshot.
So the second SELECT sees B's committed change because it takes a fresh snapshot.
How REPEATABLE READ prevents this: the entire transaction gets one snapshot at the start. All reads see the database as it was at that moment, regardless of what commits happen after.
Problem 3: Phantom Reads
New rows appear (or disappear) between two identical queries. Not about a row changing, but about the set of rows changing.
-- Transaction A (READ COMMITTED)
BEGIN;
SELECT count(*) FROM orders WHERE customer_id = 42;
-- Returns 5
-- Transaction B (concurrent)
BEGIN;
INSERT INTO orders (customer_id, amount) VALUES (42, 75.00);
COMMIT;
-- Back to Transaction A
SELECT count(*) FROM orders WHERE customer_id = 42;
-- Returns 6! A "phantom" row appeared.
sequenceDiagram
participant A as Txn A (READ COMMITTED)
participant DB as PostgreSQL
participant B as Txn B
A->>DB: BEGIN
A->>DB: SELECT count(*) FROM orders<br/>WHERE customer_id = 42
DB-->>A: 5 rows
B->>DB: BEGIN
B->>DB: INSERT INTO orders<br/>(customer_id=42, amount=75)
B->>DB: COMMIT
A->>DB: SELECT count(*) FROM orders<br/>WHERE customer_id = 42
DB-->>A: 6 rows (phantom!)
Note over A: A new row appeared between<br/>two identical queries.<br/>This is a phantom read.
How SERIALIZABLE prevents this: it uses predicate locking -- it doesn't just lock
existing rows, it locks the condition (WHERE customer_id = 42). Any INSERT that
matches that condition will conflict. This is fundamentally different from row-level
locks, which can only lock rows that already exist.
Snapshot Behavior: Per-Statement vs Per-Transaction
This is the key implementation detail that differentiates the levels.
| Level | Snapshot Taken | What You See |
|---|---|---|
| READ COMMITTED | At the start of each statement | Latest committed data at time of each query |
| REPEATABLE READ | At the start of the transaction | Frozen view from transaction start |
| SERIALIZABLE | At the start of the transaction + conflict detection | Frozen view + abort if conflict detected |
graph LR
subgraph "READ COMMITTED"
S1["Statement 1<br/>snapshot @ T1"] --> S2["Statement 2<br/>snapshot @ T2"]
S2 --> S3["Statement 3<br/>snapshot @ T3"]
end
subgraph "REPEATABLE READ"
R1["Statement 1"] --> R2["Statement 2"]
R2 --> R3["Statement 3"]
R1 -.- SNAP["Single snapshot<br/>@ transaction start"]
R2 -.- SNAP
R3 -.- SNAP
end
Tradeoffs: Why READ COMMITTED is the Default
If REPEATABLE READ and SERIALIZABLE are safer, why doesn't everyone use them?
The Core Tradeoff: Wait vs Abort
| READ COMMITTED + FOR UPDATE | REPEATABLE READ | |
|---|---|---|
| On conflict | Waits for the other transaction to finish | Aborts your transaction (serialization error) |
| App code needed | None -- the transaction just waits in line | Retry loop required to handle aborts |
| Throughput | Lower when conflicts are frequent (transactions queue up) | Higher when conflicts are rare (no waiting) |
| Stale data risk | Minimal (each statement sees latest committed data) | Possible (you see a snapshot from transaction start) |
| When to use | Most OLTP workloads, short transactions | Long-running reads, reporting, financial consistency |
Why RC + FOR UPDATE Wins for Most Teams
-
Most transactions are short. A web request that takes 50ms is unlikely to have another transaction sneak in between reads.
-
No retry logic. With REPEATABLE READ, your app needs to catch serialization errors and retry. That's extra code, extra testing, extra things to get wrong.
-
Surgical precision. Instead of upgrading isolation for the entire transaction, teams use
SELECT ... FOR UPDATEon the specific rows that need protection. This locks only what matters and leaves everything else at READ COMMITTED. -
VACUUM pressure. REPEATABLE READ keeps old row versions alive longer (your snapshot needs them). More old versions means more work for VACUUM, which means more I/O overhead.
flowchart TD
Q{"Do concurrent transactions<br/>conflict on the same rows?"} -->|Rarely| RC["READ COMMITTED<br/>(default, no special handling)"]
Q -->|"Yes, specific rows"| RCFU["READ COMMITTED<br/>+ FOR UPDATE on those rows"]
Q -->|"Yes, complex patterns<br/>across many rows"| RR["Consider REPEATABLE READ<br/>(with retry logic)"]
Q -->|"Need full correctness<br/>guarantees"| S["SERIALIZABLE<br/>(with retry logic)"]
style RC fill:#1a3a1a,stroke:#66ff66
style RCFU fill:#2a3a1a,stroke:#aaff44
style RR fill:#4a3a1a,stroke:#ffaa44
style S fill:#4a1a1a,stroke:#ff6666
Quick Reference
graph TD
subgraph "What protects data integrity?"
RL["Row-Level Locks<br/>(FOR UPDATE, FOR SHARE)<br/>Explicit coordination"]
MVCC["MVCC<br/>(multi-version rows)<br/>Readers never block"]
IL["Isolation Levels<br/>(RC, RR, S)<br/>What can you see?"]
end
RL -->|"Controls"| WW["Writer vs Writer<br/>conflicts"]
MVCC -->|"Enables"| RW["Reader vs Writer<br/>no blocking"]
IL -->|"Controls"| VIS["Visibility of<br/>other txns' changes"]
style RL fill:#1a3a4a,stroke:#44aaff
style MVCC fill:#1a3a1a,stroke:#66ff66
style IL fill:#4a3a1a,stroke:#ffaa44
| Situation | Tool to Reach For |
|---|---|
| Two transactions updating the same row | SELECT ... FOR UPDATE (or just rely on UPDATE's implicit lock) |
| Protecting a row from deletion while referencing it | SELECT ... FOR SHARE |
| Need consistent reads across multiple statements | REPEATABLE READ isolation |
| Need full serializability guarantees | SERIALIZABLE isolation (with retry logic) |
| Plain reads alongside writes | Just use SELECT -- MVCC handles it, no lock needed |