Isolation and Locking
Introduction
When many clients access the database at once, isolation levels define what one transaction can see of another's work. Locks enforce those rules when conflicts arise. This chapter explains dirty reads, non-repeatable reads, and phantoms, compares PostgreSQL's default READ COMMITTED with MySQL InnoDB's default REPEATABLE READ, and covers FOR UPDATE, FOR SHARE, deadlocks, and long-transaction bloat. Compare with MySQL isolation and locks.
Prerequisites
- Transactions Basics
- Two
psqlsessions (two terminals) helpful for demos accountstable from the previous chapter
Phenomena
| Phenomenon | Description |
|---|---|
| Dirty read | Read uncommitted data another transaction may rollback |
| Non-repeatable read | Same row read twice, different values (committed update between) |
| Phantom read | Same query twice, different row set (new rows inserted) |
Four Isolation Levels in PostgreSQL
| Level | Dirty read | Non-repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | Not possible* | Possible | Possible |
| READ COMMITTED (default) | No | Possible | Possible |
| REPEATABLE READ | No | No | Possible** |
| SERIALIZABLE | No | No | No |
*PostgreSQL treats READ UNCOMMITTED as READ COMMITTED—no true dirty reads.
**PostgreSQL REPEATABLE READ also blocks many phantom cases via snapshot isolation—stricter than the SQL standard minimum.
Check current level:
SHOW transaction_isolation;
-- read committedSet Isolation Level
For the session default:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;For the next transaction only:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE name = 'Alice';
COMMIT;Inside an already started transaction:
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;Demo: Non-Repeatable Read (READ COMMITTED)
Session A:
BEGIN;
SELECT balance FROM accounts WHERE name = 'Alice';Session B:
UPDATE accounts SET balance = balance + 5 WHERE name = 'Alice';
COMMIT;Session A again:
SELECT balance FROM accounts WHERE name = 'Alice';
COMMIT;Second read can differ—non-repeatable read allowed at READ COMMITTED.
At REPEATABLE READ, the first snapshot repeats for the whole transaction.
REPEATABLE READ Snapshot
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts WHERE name = 'Alice';
-- other session updates and commits
SELECT * FROM accounts WHERE name = 'Alice';
-- same result as first select in this transaction
COMMIT;PostgreSQL REPEATABLE READ uses snapshot isolation—no non-repeatable reads of committed data within one transaction.
SERIALIZABLE
Strictest—serializable snapshot isolation detects conflicts and may raise:
ERROR: could not serialize access due to concurrent updateBEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM accounts;
COMMIT;Retry the transaction on serialization failure in apps. Use sparingly—contention increases.
PostgreSQL vs MySQL Defaults
| PostgreSQL | MySQL InnoDB | |
|---|---|---|
| Default level | READ COMMITTED | REPEATABLE READ |
| READ UNCOMMITTED | Maps to READ COMMITTED | True dirty reads possible |
| Phantom handling at RR | Snapshot + SSI options | Next-key locks common |
ORMs and drivers may assume different defaults—set explicitly when behavior matters.
Row-Level Locks
| Lock | SQL | Use |
|---|---|---|
| FOR UPDATE | Exclusive row lock | Update/delete after select |
| FOR NO KEY UPDATE | Weaker write lock | Updates that don't change key columns |
| FOR SHARE | Shared lock | Block writers, allow readers |
| FOR KEY SHARE | Weakest share | FK checks |
Explicit exclusive lock:
BEGIN;
SELECT id, balance FROM accounts WHERE name = 'Alice' FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE name = 'Alice';
COMMIT;Shared lock (multiple readers, block writers):
BEGIN;
SELECT * FROM accounts WHERE name = 'Bob' FOR SHARE;
COMMIT;Code explanation:
- Locks apply to rows returned by the query—index use matters
NOWAITfails immediately if lock unavailable;SKIP LOCKEDskips locked rows—useful for job queues
SELECT id FROM jobs WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1;Deadlocks
Two transactions waiting on each other's locks—PostgreSQL detects and aborts one:
ERROR: deadlock detected
DETAIL: Process 123 waits for ShareLock on transaction 456; blocked by process 789...PostgreSQL logs deadlock details—check server logs in production.
Inspect current locks (concept):
SELECT pid, mode, locktype, relation::regclass, granted
FROM pg_locks
WHERE relation IS NOT NULL
LIMIT 20;Prevention habits:
- Lock rows in a consistent order (always
idascending) - Keep transactions short
- Avoid user interaction inside transactions
Long Transactions and Bloat
Open transactions hold back VACUUM from reclaiming old row versions—tables grow (bloat), queries slow.
| Symptom | Cause |
|---|---|
| Table size grows while row count stable | Old MVCC versions not vacuumed |
pg_stat_activity shows xact_start hours ago | Long open transaction |
Check long runners:
SELECT pid, state, xact_start, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start NULLS LAST;Fix: COMMIT or ROLLBACK idle transactions—details in Performance, VACUUM, and Monitoring.
Advisory Locks (Optional)
Application-level locks without touching table rows:
SELECT pg_advisory_lock(42);
-- critical section work
SELECT pg_advisory_unlock(42);Use for migrations or single-flight jobs across app instances.
FAQ
Which isolation level should apps use?
READ COMMITTED is PostgreSQL's default and works for most web apps. Use REPEATABLE READ or SERIALIZABLE when you need stronger guarantees and handle retries.
FOR UPDATE vs regular SELECT?
Regular SELECT takes no row lock in PostgreSQL (MVCC snapshot). FOR UPDATE blocks concurrent writers on those rows—use for "read balance, then debit" patterns.
Does PostgreSQL have gap locks like MySQL?
Not in the InnoDB next-key sense. SERIALIZABLE and predicate locking behave differently—test concurrent scenarios.
What is SKIP LOCKED?
Workers grab the next unlocked job row without waiting—great for parallel queue consumers.
How do I debug locks?
pg_locks, pg_stat_activity, and log_lock_waits = on in postgresql.conf (dev/staging).
Deadlock in production?
Retry the whole transaction once or twice; fix lock ordering if deadlocks repeat.