Cloud Tech by Victor
DatabasesIntermediate

Database Transactions & Isolation Levels

Why PostgreSQL's default Read Committed isolation still allows non-repeatable reads and phantom reads, and why Repeatable Read and Serializable trade that risk for transactions your application has to be ready to retry.

Updated 2026-07-243 min read

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 levelDirty readNon-repeatable readPhantom readSerialization anomaly
Read Committed (default)PreventedPossiblePossiblePossible
Repeatable ReadPreventedPreventedPrevented (PG-specific)Possible
SerializablePreventedPreventedPreventedPrevented

Syntax

sql
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... statements ...
COMMIT;

Examples

sql
-- 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 update

Repeatable 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.

Interview questions

What is PostgreSQL's default transaction isolation level, and what specific anomaly does it still allow that a stricter level would prevent?

PostgreSQL defaults to Read Committed, where each individual statement within a transaction sees a fresh snapshot of everything committed as of that statement's start, not the transaction's start. This prevents dirty reads (seeing another transaction's uncommitted changes) but still allows non-repeatable reads, running the same SELECT twice in one transaction can return different results if another transaction committed a change in between, because each statement gets its own snapshot rather than the transaction using one snapshot throughout. Repeatable Read fixes this specific anomaly by taking one snapshot at the start of the transaction and using it for every statement within it.

A transaction under Repeatable Read isolation fails with "could not serialize access due to concurrent update." What actually happened, and what is the application expected to do?

Repeatable Read uses snapshot isolation, the transaction sees a consistent snapshot from its own start, but if it then tries to update a row that another, concurrently-committed transaction already modified, PostgreSQL detects the conflict and aborts the transaction with a serialization failure rather than silently applying an update based on stale data. This is not an application bug, it is Repeatable Read (and Serializable) working as designed, both isolation levels explicitly require the application to catch this specific error and retry the transaction from the beginning, trading the guarantee of not overwriting concurrent changes for the operational cost of occasional automatic retries.

What's the practical difference between Repeatable Read and Serializable, given that PostgreSQL's Repeatable Read already prevents phantom reads?

PostgreSQL's Repeatable Read goes beyond the SQL standard's minimum and already prevents phantom reads via snapshot isolation, but it can still allow a specific class of anomaly called a serialization anomaly, where the combined effect of several concurrently-committed transactions is not equivalent to any possible serial (one-at-a-time) ordering of them, even though each transaction individually looks consistent. Serializable adds predicate locking on top of snapshot isolation specifically to detect and prevent that remaining anomaly, guaranteeing that the outcome is always equivalent to transactions having run one at a time in some order. The cost is the same as Repeatable Read's, more serialization failures the application must retry, in exchange for the strongest correctness guarantee available.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement