What is the Default Isolation Level in PostgreSQL
In PostgreSQL, the default transaction isolation level is Read Committed.
🟢 Junior Level
In PostgreSQL, the default transaction isolation level is Read Committed.
The Core Concept in 30 Seconds
Under Read Committed, a transaction is guaranteed protection against Dirty Reads: every query sees only data that was successfully committed (COMMITTED) before the current SQL statement began executing.
The defining architectural principle of PostgreSQL at this level:
Each individual SQL statement (
SELECT,UPDATE,DELETE) within the same open transaction acquires its own fresh data snapshot (Statement-Level Snapshot)!
If a single open transaction runs SELECT balance FROM accounts WHERE id = 1, pauses, and repeats the identical query, the second query will observe newly committed updates made by concurrent transactions during that pause.
Execution Timeline
Transaction A (Read Committed) Concurrent Transaction B
------------------------------ ------------------------
BEGIN;
SELECT count(*) FROM orders; -- Returns 100
BEGIN;
INSERT INTO orders (...);
COMMIT; -- Formally committed!
SELECT count(*) FROM orders; -- Returns 101 (Sees new row in fresh snapshot!)
COMMIT;
Anomaly Vulnerability Matrix in PostgreSQL (Read Committed)
| ANSI SQL Anomaly | Status in PostgreSQL | Why It Happens |
|---|---|---|
| Dirty Read | ❌ Prevented | PostgreSQL verifies commit status bits in the tuple header; uncommitted tuples are invisible |
| Non-Repeatable Read | ⚠️ Permitted | Re-executing queries generates a new statement snapshot reflecting recent commits |
| Phantom Read | ⚠️ Permitted | Range queries in a new statement snapshot see newly committed inserted or deleted rows |
| Lost Update | ⚠️ Permitted | Occurs during application-level Read-Modify-Write flows without locking or versioning |
Practical Spring Boot Example
In Spring, @Transactional defaults to Isolation.DEFAULT, which resolves to Read Committed on PostgreSQL:
@Service
public class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional // Defaults to Read Committed in PostgreSQL
public void printBalanceTwice(Long accountId) throws InterruptedException {
// Query 1: Allocates Statement Snapshot #1
Account firstRead = accountRepository.findById(accountId).orElseThrow();
System.out.println("First Read: " + firstRead.getBalance());
// Simulate 2 seconds of external processing
// Meanwhile, a concurrent transaction executes: UPDATE accounts SET balance = 5000 WHERE id = 1
Thread.sleep(2000);
// Query 2: In plain SQL, this allocates Statement Snapshot #2!
// NOTE: Hibernate caches entities in its L1 Persistence Context (firstRead == secondRead).
// To bypass L1 and query PostgreSQL's fresh snapshot, issue a native query or refresh:
Account freshRead = accountRepository.findByIdFreshNative(accountId);
System.out.println("Second Read: " + freshRead.getBalance());
// freshRead.getBalance() reflects the concurrent commit (Non-Repeatable Read)!
}
}
🟡 Middle Level
MVCC Architecture in PostgreSQL
PostgreSQL achieves transaction isolation using Multi-Version Concurrency Control (MVCC). Unlike traditional two-phase locking engines:
Readers never block writers, and writers never block readers.
Data modifications (UPDATE, DELETE) do not overwrite existing bytes in-place on disk. Instead, the engine creates new versions of rows (tuples):
INSERT: Appends a tuple with the current transaction ID int_xminand setst_xmax = 0.DELETE: Leaves the tuple in place but writes the deleting transaction ID intot_xmax.UPDATE: Atomically executes aDELETEon the old version (setting itst_xmax) plus anINSERTof the new version (new tuple witht_xmin, linked via thet_ctidpointer).
Table Heap Page:
+---------------------------------------------------------------------------------+
| Version 1: [xmin: 100, xmax: 105, balance: 1000] -> (Dead tuple after commit 105)|
| Version 2: [xmin: 105, xmax: 0, balance: 1500] -> (Live tuple for snapshot >= 105)|
+---------------------------------------------------------------------------------+
Snapshot Lifecycle in Read Committed vs. Repeatable Read
Inside the PostgreSQL engine, a snapshot is represented by SnapshotData:
xmin: Lowest active Transaction ID (all $XID < xmin$ are guaranteed completed).xmax: First unassigned Transaction ID (all $XID \ge xmax$ are invisible).xip[]: List of in-progress Transaction IDs betweenxminandxmax.
Read Committed:
Query 1 (SELECT) -----> GetTransactionSnapshot() -> Snapshot #1 allocated
Query 2 (SELECT) -----> GetTransactionSnapshot() -> Snapshot #2 allocated anew!
Repeatable Read:
Query 1 (SELECT) -----> GetTransactionSnapshot() -> Snapshot #1 frozen for entire transaction
Query 2 (SELECT) -----> Reuses Snapshot #1!
Under Read Committed, PostgreSQL calls GetTransactionSnapshot() before every single SQL statement, minimizing the retention of old data versions and boosting throughput.
The EvalPlanQual (EPQ) Mechanism for Concurrent Updates
When two transactions attempt to update the same row concurrently under Read Committed:
-- Initial state: id = 1, balance = 1000, is_active = true
-- Transaction T1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND is_active = true;
-- T1 acquires an exclusive row lock (RowExclusiveLock / XMAX)
-- Transaction T2 (concurrent):
BEGIN;
UPDATE accounts SET is_active = false WHERE id = 1 AND is_active = true;
-- T2 finds the row locked by T1 and pauses, waiting for T1 to finish.
When Transaction T1 commits:
- Transaction T2 unblocks.
- Because T2 runs under Read Committed, it does not fail with a serialization failure.
- PostgreSQL triggers EvalPlanQual (EPQ): T2 fetches the latest committed version of the row (produced by T1) via
t_ctidand re-evaluates the query’sWHEREclause. - In this example,
is_active = trueremains satisfied (T1 only changedbalance). T2 successfully applies its update, settingis_active = false. - If T1 had set
is_active = false, the EPQ re-check would evaluate tofalse, and T2 would silently skip updating the row (updating 0 rows).
🔴 Senior Level
PostgreSQL Kernel Internals: Commit Log (CLOG) and Hint Bits
Checking the transaction manager (ProcArrayLock) on every visibility check (HeapTupleSatisfiesVisibility) would cause disastrous CPU spinlock contention. PostgreSQL optimizes visibility checks through two layers:
- CLOG (
pg_xact): A shared memory ring buffer and on-disk files storing 2 bits per transaction:00—TRANSACTION_STATUS_IN_PROGRESS01—TRANSACTION_STATUS_COMMITTED10—TRANSACTION_STATUS_ABORTED11—TRANSACTION_STATUS_SUB_COMMITTED
- Hint Bits in
t_infomask:- The first reader that inspects a tuple checks
pg_xact, resolves the creator transaction’s status (xmin), and mutates the tuple header in shared buffer memory directly, setting hint bits:HEAP_XMIN_COMMITTEDHEAP_XMIN_INVALID(rolled back)HEAP_XMAX_COMMITTED
- Subsequent queries across all connections inspect these hint bits directly without accessing
pg_xactor acquiring lock manager spinlocks.
- The first reader that inspects a tuple checks
HeapTuple Header Structure (HeapTupleHeaderData):
+-------------------------------------------------------------------------------+
| t_xmin (32 bit) | t_xmax (32 bit) | t_cid / t_xvac (32 bit) | t_ctid (6 bytes) |
| t_infomask2 | t_infomask (16 bit: Hint Bits!) | t_hoff (offset) |
+-------------------------------------------------------------------------------+
The Impact on VACUUM, Bloat, and Transaction Wraparound
Even under Read Committed, long-running transactions or orphaned idle in transaction sessions cause severe cluster degradation:
- Holding
OldestXminBack: An uncommitted transaction holds back the globalOldestXminhorizon. - Blocking
autovacuum: PostgreSQL’sautovacuumcannot purge dead tuples whose $xmax \ge \text{OldestXmin}$. - Table & Index Bloat: Concurrent updates accumulate dead tuples on disk. A 5 GB table can rapidly balloon to 50 GB.
- Transaction ID Wraparound: If the transaction horizon lags by $2^{31}$ transactions, PostgreSQL enforces a safety emergency shutdown into read-only mode to prevent catastrophic data corruption.
-- Monitoring dangerous long-running transactions holding autovacuum back:
SELECT pid,
usename,
state,
now() - xact_start AS xact_duration,
now() - state_change AS state_duration,
query
FROM pg_stat_activity
WHERE state IN ('active', 'idle in transaction')
AND xact_start < now() - INTERVAL '5 minutes'
ORDER BY xact_start ASC;
Highload Defenses Against Lost Update under Read Committed
@Service
public class HighloadWalletService {
private final WalletRepository walletRepository;
public HighloadWalletService(WalletRepository walletRepository) {
this.walletRepository = walletRepository;
}
// Strategy 1: Atomic In-Place SQL Update (Zero Application Contention)
@Transactional
public void creditBalanceAtomic(Long walletId, BigDecimal amount) {
int updated = walletRepository.incrementBalanceAtomic(walletId, amount);
if (updated == 0) {
throw new IllegalStateException("Wallet not found or deactivated");
}
}
// Strategy 2: Pessimistic Row Locking (SELECT ... FOR UPDATE)
@Transactional
public void debitBalancePessimistic(Long walletId, BigDecimal amount) {
Wallet wallet = walletRepository.findByIdForUpdate(walletId)
.orElseThrow(() -> new IllegalArgumentException("Wallet not found"));
if (wallet.getBalance().compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds");
}
wallet.setBalance(wallet.getBalance().subtract(amount));
}
}
4 Tricky Questions
1. What happens if you execute SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED in PostgreSQL?
Answer:
The query executes successfully without syntax errors, but the isolation level is silently and automatically promoted to Read Committed.
In PostgreSQL’s MVCC storage architecture, reading uncommitted (dirty) data is physically impossible:
- The visibility checking routines (
HeapTupleSatisfiesVisibility) evaluate whether a tuple’s creating transaction has committed via CLOG status bits. If a transaction isIN_PROGRESS, the tuple is invisible to all other sessions. - Enabling Dirty Reads would require disabling transaction visibility validation, breaking consistency across heap pages, WAL, index trees, and TOAST storage.
- PostgreSQL accepts the
READ UNCOMMITTEDsyntax purely for ANSI SQL compliance, mapping it directly toRead Committed.
2. How does the EvalPlanQual (EPQ) re-check work, and can an UPDATE silently skip rows?
Answer:
Yes, an UPDATE can silently skip rows.
When Transaction T2 attempts to update a row locked by Transaction T1:
- T2 pauses on the row lock held by T1.
- Once T1 commits, T2 wakes up and follows the tuple pointer
t_ctidto inspect the updated version written by T1. - PostgreSQL runs EvalPlanQual: it feeds the newly committed tuple into T2’s original
WHEREclause. - If the predicate remains
true, T2 applies its update to the new version. - If T1’s update modified columns such that T2’s
WHEREpredicate is no longer satisfied (e.g., T1 changedstatus = 'PENDING'tostatus = 'COMPLETED'), EPQ evaluates tofalse. T2 skips the row without raising an error, and the updated row count returned is0.
3. Why can SELECT ... FOR UPDATE in PostgreSQL under Read Committed return a row that violates the original WHERE condition in complex JOINs?
Answer: This is an established behavioral edge case in EvalPlanQual during multi-table joins. Consider:
SELECT * FROM parent p JOIN child c ON p.id = c.parent_id
WHERE c.status = 'ACTIVE' FOR UPDATE OF p;
- The query executor builds an execution plan and finds a matching row pair where
c.status = 'ACTIVE'. - When attempting to lock the
parentrow, the engine discovers a concurrent transaction just updatedparentand committed. - EPQ fetches the new
parenttuple and re-evaluates the join. - In older PostgreSQL versions (pre-PostgreSQL 12), EPQ re-evaluated the join condition using the original snapshot’s child tuple rather than re-reading a fresh child tuple. If a concurrent transaction had updated
childtoINACTIVE, the query could still lock and return the parent row. PostgreSQL 12+ substantially overhauled EPQ join handling, but best practice dictates locking both joined tables explicitly:FOR UPDATE OF p, c.
4. How does Read Committed in PostgreSQL differ from Read Committed in Oracle and Microsoft SQL Server?
Answer:
- PostgreSQL vs. Oracle: Both engines use non-blocking MVCC readers. However, Oracle stores historical row versions in a dedicated Undo Tablespace and modifies heap tables in-place. PostgreSQL writes new tuple versions directly into the main table heap pages, requiring the background
VACUUMdaemon to reclaim dead tuples. If Oracle runs out of undo space under long transactions, it throwsORA-01555: snapshot too old, whereas PostgreSQL experiences table and index bloat. - PostgreSQL vs. MS SQL Server: By default, standard MS SQL Server uses a locking concurrency model for Read Committed:
SELECTqueries acquire short-term Shared Locks (S-locks) that block concurrent writers and wait for active writers to finish. To achieve PostgreSQL’s lock-free read behavior in MS SQL Server, administrators must explicitly enable Read Committed Snapshot Isolation (RCSI), which utilizes thetempdbsystem database for row versioning.
🎯 Interview Cheat Sheet
- Default Isolation: PostgreSQL defaults to
Read Committed. - Core Mechanism: Implemented via MVCC; readers never block writers, and writers never block readers.
- Statement Snapshot: Each individual SQL statement gets a fresh snapshot (
GetTransactionSnapshot()). - Tuple Structure:
t_xmin(creating transaction),t_xmax(deleting/updating transaction), andt_infomask(Hint Bits). - EvalPlanQual (EPQ): Mechanism that re-evaluates
WHEREconditions on the newest committed tuple version when unblocking after a concurrent update. - READ UNCOMMITTED Behavior: Accepted syntactically, but automatically elevated to
Read Committed. - Production Bloat Hazard: Long-running transactions or
idle in transactionsessions hold backOldestXmin, preventingautovacuumfrom reclaiming dead tuples and leading to table bloat.