Overview
PostgreSQL implements three distinct isolation levels (Read Uncommitted is accepted syntactically but behaves identically to Read Committed), each preventing a strictly larger set of concurrency anomalies at the cost of more work for the application. Read Committed, the default, gives each statement its own fresh snapshot, preventing dirty reads but allowing non-repeatable reads and phantom reads within the same transaction. Repeatable Read takes one snapshot for the whole transaction via snapshot isolation, which happens to prevent phantom reads too in PostgreSQL specifically, but can still abort with a serialization failure the application must catch and retry. Serializable adds predicate locking on top of that to catch the one remaining anomaly class, a genuine correctness guarantee equivalent to transactions running one at a time, at the cost of more frequent retries.
Quick Reference
| Isolation level | Dirty read | Non-repeatable read | Phantom read | Serialization anomaly |
|---|---|---|---|---|
| Read Committed (default) | Prevented | Possible | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Prevented (PG-specific) | Possible |
| Serializable | Prevented | Prevented | Prevented | Prevented |
Syntax
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... statements ...
COMMIT;Examples
-- Under Repeatable Read or Serializable, a concurrent conflicting
-- update aborts this transaction; the application must catch
-- this and retry, it isn't an application bug.
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- ERROR: could not serialize access due to concurrent updateRepeatable Read and Serializable require retry logic
Both isolation levels can abort a transaction with a serialization failure specifically because a conflict was detected, this is expected behavior, not an error to suppress. Any application using either level needs to catch this specific error and retry the transaction from its start.
Visual Diagram
Common Mistakes
- Assuming Read Committed (the default) prevents re-running the same query twice with consistent results within one transaction; it explicitly does not.
- Using Repeatable Read or Serializable without adding retry logic for serialization failures, treating the error as a bug instead of expected, designed-for behavior.
- Acquiring locks on multiple rows/tables in inconsistent orders across different parts of the application, a common root cause of deadlocks independent of isolation level.
- Reaching for Serializable everywhere "to be safe" without accounting for the real cost in retry frequency under concurrent write load.
Performance
- Read Committed has the lowest overhead and highest concurrency of the three levels, which is exactly why it's the default for typical workloads.
- Repeatable Read and Serializable both add real overhead, snapshot maintenance and, for Serializable, predicate lock tracking, and both increase transaction retry rates under contention, a real operational cost, not just a correctness switch.
Best Practices
- Default to Read Committed unless a specific, identified anomaly (non-repeatable reads, phantom reads) actually matters for the transaction in question.
- Build retry logic for serialization failures into any code path using Repeatable Read or Serializable, don't treat the error as unexpected.
- Acquire locks in a consistent order across the whole application to reduce deadlocks regardless of isolation level chosen.
- Reserve Serializable for the specific transactions that genuinely need the strongest guarantee, not as a blanket default, given its added retry cost under contention.