Skip to content

Isolation levels

When many transactions run at once, they can interfere with each other in ways that produce surprising results. An isolation level is the dial that controls how much one transaction is protected from the in-progress work of others. Turning the dial up prevents more anomalies but makes conflicts more likely to fail and need a retry.

The SQL standard names a set of phenomena that weaker isolation allows. PostgreSQL’s levels are defined by which of these they forbid.

  • Dirty read — reading a row another transaction has changed but not yet committed. PostgreSQL never allows this at any level.
  • Non-repeatable read — reading the same row twice in one transaction and getting different committed values because another transaction changed it in between.
  • Phantom read — running the same search twice and finding rows that were not there the first time because another transaction inserted them.
  • Write skew — two transactions each read an overlapping set of rows, then each writes based on what it read, producing a result no single serial order could have created.

PostgreSQL offers three usable levels. (A fourth standard name, Read Uncommitted, exists but behaves like Read Committed here, because dirty reads are never permitted.)

Isolation level | Dirty read | Non-repeatable read | Phantom read | Write skew
------------------+------------+---------------------+--------------+-----------
Read Committed | No | Possible | Possible | Possible
Repeatable Read | No | No | No | Possible
Serializable | No | No | No | No

Read Committed is the default. Each statement in it sees a fresh snapshot of all data committed before that statement began, which is why two reads in the same transaction can disagree.

You raise the level on a transaction with SET TRANSACTION ISOLATION LEVEL, issued right after BEGIN and before any data is read or written. The drivers expose the same idea through their transaction options.

BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE owner = 'Ada';
-- ... later in the same transaction, the value is guaranteed unchanged
SELECT balance FROM accounts WHERE owner = 'Ada';
COMMIT;

In pgAdmin: open two Query Tool tabs side by side, set a high isolation level in one with BEGIN; SET TRANSACTION ISOLATION LEVEL ..., and run an UPDATE in the other tab to watch how the first transaction’s repeated reads stay stable.

The decision is the same shape every time: when a statement reads data, the level determines which snapshot it sees and whether a conflicting commit by someone else can affect the outcome.

flowchart TD
  A[Statement reads data] --> B{Isolation level}
  B -->|Read Committed| C[Fresh snapshot per statement]
  B -->|Repeatable Read| D[One snapshot for the whole transaction]
  B -->|Serializable| E[Snapshot plus conflict tracking]
  C --> F[Same row may differ on a second read]
  D --> G[Repeated reads are stable]
  E --> H[Abort if the result could not occur serially]
Each isolation level reads from a different snapshot strategy

Serializable is the strongest level: PostgreSQL guarantees the outcome is as if the concurrent transactions had run one after another in some order. To do this it watches for dangerous patterns and, when it detects one, aborts a transaction with a serialization failure (SQLSTATE 40001). That is not a bug; it is the level doing its job. Your application must catch that error and run the transaction again.

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... your reads and writes ...
COMMIT; -- may raise: ERROR: could not serialize access ... (40001)
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: ...
HINT: The transaction might succeed if retried.
  • Read Committed is the right default for most workloads; only raise the level when you have a real anomaly to prevent.
  • At Serializable (and Repeatable Read for write conflicts), always wrap the transaction in a retry loop that re-runs it on SQLSTATE 40001. The hint in the error even tells you so.
  • Set the isolation level at the very start of the transaction. Once data has been accessed, you cannot change it.
  • A higher level is not “more correct” for free. It trades a higher chance of aborts for stronger guarantees, so measure before reaching for Serializable everywhere.
Which isolation level is the PostgreSQL default?
What should an application do when a Serializable transaction fails with SQLSTATE 40001?
Which anomaly does Repeatable Read prevent that Read Committed allows?