Skip to content

PostgreSQL Isolation Levels Explained

Aman Kumar Singh5 min read
Part 18 of 40From the PostgreSQL Masterclass series
PostgreSQL Isolation Levels Explained — article by Aman Kumar Singh

Isolation is the "I" in ACID, and it's the one that causes real bugs in production, the kind that only appear under concurrent load and are almost impossible to reproduce locally. Two requests hit the same rows at the same moment and produce a result neither would alone: a double-charged customer, an oversold ticket, a balance that goes negative. Isolation levels are the dial that controls how much concurrent transactions can interfere, and knowing which level you're running is the difference between correct code and a race waiting to happen.

This is part 18 of the PostgreSQL Masterclass, following transactions and ACID.

The anomalies isolation prevents

Isolation levels are defined by which concurrency anomalies they allow. Four matter:

  • Dirty read: seeing another transaction's uncommitted changes. Postgres never allows this, even at its lowest level.
  • Non-repeatable read: reading a row twice in one transaction and getting different values, because another transaction committed a change in between.
  • Phantom read: running the same query twice and getting different rows, because another transaction inserted or deleted matching rows in between.
  • Serialization anomaly: the result of committing a group of transactions is inconsistent with running them one at a time, even though each individually looks fine.

Each isolation level draws the line at a different anomaly.

Read Committed: the default

Postgres runs at Read Committed by default. Each statement sees a snapshot of data as of the moment that statement began, including everything committed before it. Within one transaction, two identical queries can return different results if another transaction committed in between, so non-repeatable and phantom reads are both possible.

This is fine for the majority of workloads, and it's why most apps never think about isolation. The trap is read-modify-write logic:

-- Read Committed: this has a race condition
BEGIN;
  SELECT stock FROM products WHERE id = 1;   -- app reads stock = 1
  -- another transaction sells the same item here
  UPDATE products SET stock = stock - 1 WHERE id = 1;  -- now -1
COMMIT;

The SELECT and the decision based on it aren't protected against a concurrent change. Two customers both read "1 in stock," both buy, and you've oversold. Read Committed doesn't stop this, because the two transactions don't touch until the writes, by which point both decisions are made.

Repeatable Read: a stable snapshot

Repeatable Read gives the whole transaction a single snapshot taken at its start. Every query sees the database as it was when the transaction began, ignoring anything committed afterward. Non-repeatable and phantom reads disappear: read a row twice and you get the same value, run a query twice and you get the same rows.

BEGIN ISOLATION LEVEL REPEATABLE READ;
  SELECT ...;   -- consistent snapshot for the whole transaction
  SELECT ...;   -- same view, regardless of concurrent commits
COMMIT;

There's a catch, and it's important. If your Repeatable Read transaction tries to update a row that another transaction changed and committed after your snapshot, Postgres raises a serialization failure rather than silently working on stale data:

ERROR: could not serialize access due to concurrent update

This is a feature, not a bug. It's Postgres refusing to let you overwrite a change you didn't see. Your application must catch this error and retry the transaction. Any code using Repeatable Read or Serializable needs retry logic.

Serializable: as if one at a time

Serializable is the strongest level. It guarantees the outcome is equivalent to running all concurrent transactions in some serial order, one after another. It prevents every anomaly, including serialization anomalies that Repeatable Read allows. Postgres implements this efficiently with Serializable Snapshot Isolation, which detects dangerous patterns among concurrent transactions and aborts one with a serialization error.

The oversell example above, run under Serializable, is safe: one of the two concurrent buyers gets a serialization failure and retries, and the retry sees the updated stock. You pay for this with more serialization failures under contention, so again, retry logic is mandatory.

BEGIN ISOLATION LEVEL SERIALIZABLE;
  -- read and write freely; Postgres ensures a serial-equivalent result
COMMIT;   -- may raise a serialization failure to catch and retry

Choosing a level

The practical guidance:

  • Read Committed (default) for most work. Handle read-modify-write races with explicit row locks (SELECT FOR UPDATE, covered soon) rather than raising the isolation level.
  • Repeatable Read when a transaction reads many rows and needs them all consistent as of one moment, like a report or a multi-step calculation.
  • Serializable when correctness under concurrency is critical and you'd rather retry than reason about every possible interleaving, such as financial operations. Pair it with a retry loop.

The higher levels trade throughput and occasional retries for stronger guarantees. Most systems run Read Committed and reach for explicit locking on the specific operations that need it, which is the subject of the next few articles. But when you have genuinely tricky invariants across multiple rows, Serializable plus retries is often simpler and safer than hand-rolled locking.

The one thing you should never do is assume Read Committed protects a read-modify-write. It doesn't, and that assumption is behind a large share of the "it worked in testing" concurrency bugs that reach production.

Next, we go under the isolation levels to the mechanism that enforces them at the row level: locks, what they block, and how to avoid turning them into contention.

Key takeaways

  • PostgreSQL never allows dirty reads; isolation levels differ in whether they allow non-repeatable reads, phantoms, and serialization anomalies.
  • Read Committed (the default) gives each statement a fresh snapshot and does not protect read-modify-write logic.
  • Repeatable Read gives the whole transaction one snapshot and raises a serialization error on conflicting updates, so it needs retry logic.
  • Serializable guarantees a serial-equivalent outcome and prevents every anomaly, at the cost of more retries under contention.
  • Any code at Repeatable Read or Serializable must catch serialization failures and retry the transaction.

Frequently asked questions

What is the default isolation level in PostgreSQL?

Read Committed. Each statement sees data committed before it started, so within one transaction two identical queries can return different results if another transaction commits in between.

Does Read Committed prevent race conditions?

No. It prevents dirty reads but not read-modify-write races. If you read a value, decide based on it, and then write, a concurrent transaction can change the value in between. Use explicit row locks or a higher isolation level.

When should I use Serializable isolation?

When correctness under concurrency matters more than raw throughput and you don't want to reason about every interleaving, such as financial or inventory logic. It guarantees a serial-equivalent result but requires retrying transactions that fail with serialization errors.

What is a serialization failure?

An error PostgreSQL raises at Repeatable Read or Serializable when committing would violate the isolation guarantee, typically because another transaction changed data your transaction relied on. The correct response is to retry the whole transaction.

What's the difference between non-repeatable and phantom reads?

A non-repeatable read is getting a different value when re-reading the same row. A phantom read is getting a different set of rows when re-running the same query, because rows were inserted or deleted. Repeatable Read prevents both in PostgreSQL.

Related articles

Optimistic vs Pessimistic Locking in PostgreSQL — Aman Kumar Singh
Understanding and Preventing Deadlocks in PostgreSQL — Aman Kumar Singh
MVCC, Dead Tuples, and VACUUM in PostgreSQL — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.