PostgreSQL supports three isolation levels: Read Committed, Repeatable Read, and Serializable. The SQL standard defines a fourth (Read Uncommitted), but PostgreSQL treats it identically to Read Committed – dirty reads are never allowed under MVCC.
The fundamental difference between the levels is when snapshots are taken and what additional conflict detection is performed.
| File | Purpose |
|---|---|
src/backend/access/transam/xact.c |
DefaultXactIsoLevel, XactIsoLevel GUC handling |
src/backend/utils/time/snapmgr.c |
Snapshot acquisition per isolation level |
src/backend/access/heap/heapam_visibility.c |
Visibility checks |
src/backend/access/heap/heapam.c |
EvalPlanQual for Read Committed |
src/backend/storage/lmgr/predicate.c |
SSI for Serializable |
| Phenomenon | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|
| Dirty read | Not possible | Not possible | Not possible |
| Non-repeatable read | Possible | Not possible | Not possible |
| Phantom read | Possible | Not possible | Not possible |
| Serialization anomaly | Possible | Possible | Not possible |
A new snapshot is acquired at the start of each SQL statement. This means:
Read Committed has a special behavior for UPDATE and DELETE: if the target row was modified by a concurrent transaction that committed after the current statement’s snapshot was taken, PostgreSQL does not simply skip the row. Instead, it:
This is called EvalPlanQual (EPQ) and is implemented in src/backend/executor/execMain.c. Without it, Read Committed would silently lose updates.
sequenceDiagram
participant T1 as Transaction 1
participant T2 as Transaction 2
participant Heap as Heap
T1->>Heap: UPDATE t SET x=x+1 WHERE id=1
Note over Heap: Row (id=1, x=10) locked by T1
T2->>Heap: UPDATE t SET x=x+1 WHERE id=1
Note over T2: Blocked -- row locked by T1
T1->>Heap: COMMIT
Note over Heap: New version (id=1, x=11) committed
Note over T2: Unblocked. EvalPlanQual re-checks
T2->>Heap: Re-evaluate WHERE on new version (x=11)
Note over T2: Still qualifies, proceed
T2->>Heap: Creates (id=1, x=12)
T2->>Heap: COMMIT
Because each statement sees a fresh snapshot, the following anomalies are possible:
A single snapshot is taken at the start of the first SQL statement in the transaction. All subsequent statements reuse this same snapshot. This guarantees a consistent view of the database throughout the transaction.
Unlike Read Committed, Repeatable Read does not use EvalPlanQual to re-evaluate rows. If an UPDATE or DELETE targets a row that was modified by a concurrent committed transaction:
ERROR: could not serialize access due to concurrent update
The application must catch this error and retry the transaction. This is the SQLSTATE 40001 serialization failure.
-- Table: doctors(name, on_call boolean)
-- Constraint: at least one doctor must be on call
-- T1 (snapshot sees both Alice and Bob on call):
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- T2 (snapshot also sees both on call):
UPDATE doctors SET on_call = false WHERE name = 'Bob';
-- Both commit. Nobody is on call. Constraint violated.
This is the gap that Serializable isolation fills.
Same as Repeatable Read – a single snapshot taken at the start of the first statement. The difference is that an additional layer of conflict detection (SSI) monitors for dangerous patterns.
Serializable adds predicate locks and rw-conflict tracking to detect patterns that could produce serialization anomalies. When a dangerous structure is detected, one of the involved transactions is aborted with:
ERROR: could not serialize access due to read/write dependencies among transactions
The full SSI algorithm is covered in Serializable Snapshot Isolation.
flowchart TD
subgraph "Read Committed"
RC_BEGIN["BEGIN"] --> RC_S1["Statement 1"]
RC_S1 -->|"new snapshot"| RC_SNAP1["Snapshot A"]
RC_S1 --> RC_S2["Statement 2"]
RC_S2 -->|"new snapshot"| RC_SNAP2["Snapshot B"]
RC_S2 --> RC_S3["Statement 3"]
RC_S3 -->|"new snapshot"| RC_SNAP3["Snapshot C"]
end
subgraph "Repeatable Read / Serializable"
RR_BEGIN["BEGIN"] --> RR_S1["Statement 1"]
RR_S1 -->|"take snapshot"| RR_SNAP["Snapshot X"]
RR_S1 --> RR_S2["Statement 2"]
RR_S2 -->|"reuse"| RR_SNAP
RR_S2 --> RR_S3["Statement 3"]
RR_S3 -->|"reuse"| RR_SNAP
end
In snapmgr.c, the logic is approximately:
if (IsolationUsesXactSnapshot())
{
/* Repeatable Read or Serializable: reuse first snapshot */
if (FirstSnapshotSet)
return CurrentSnapshot; /* already have one */
}
/* Read Committed: always get a fresh snapshot */
return GetSnapshotData(CurrentSnapshot);
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- or
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- or
SET default_transaction_isolation = 'serializable';
The isolation level is stored in XactIsoLevel (an integer taking values XACT_READ_UNCOMMITTED, XACT_READ_COMMITTED, XACT_REPEATABLE_READ, or XACT_SERIALIZABLE defined in xact.h).
| Use Case | Recommended Level |
|---|---|
| General OLTP | Read Committed (default) |
| Reports needing consistent reads | Repeatable Read |
| Financial / integrity-critical | Serializable |
| Bulk ETL / data loading | Read Committed |
Read Committed is the right choice for most applications because it avoids the retry logic needed at higher levels. Serializable is the safest but requires applications to handle SQLSTATE 40001 retries.
| Symbol | Location | Role |
|---|---|---|
XactIsoLevel |
xact.c |
Current transaction’s isolation level |
XACT_READ_COMMITTED |
xact.h |
Value 1 |
XACT_REPEATABLE_READ |
xact.h |
Value 2 |
XACT_SERIALIZABLE |
xact.h |
Value 3 |
IsolationUsesXactSnapshot() |
xact.h |
Macro: true if RR or Serializable |
IsolationIsSerializable() |
xact.h |
Macro: true if Serializable |