Skip to content
Back to Homephamkhanhminhman.com / blog
PostgreSQL / Concurrency 27 Aug 2026 11 min read

PostgreSQL has four isolation levels. You only get three.

Search for transaction isolation and you get the same table every time: four levels down the side, three anomalies across the top, X marks where each one is allowed. Read uncommitted permits dirty reads. Repeatable read still permits phantoms. Serializable permits nothing.

That table is accurate. It describes the SQL standard. It does not describe PostgreSQL, and if you write code against it you will be wrong in both directions — defending against anomalies that cannot happen, and exposed to one that the table does not list at all.

Everything below is measured on PostgreSQL 16.15 with two real connections overlapping. The numbers are from those runs.

1. The fourth level is accepted and ignored

PostgreSQL takes READ UNCOMMITTED without complaint, and asking it back confirms the setting:

BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation;
-- read uncommitted

So the level exists as far as the interface is concerned. Now hold an uncommitted change in one session and try to read it from another:

-- Session A                             -- Session B (READ UNCOMMITTED)
BEGIN;
UPDATE acct SET bal=999 WHERE id=1;
-- held, not committed
                                         SELECT bal FROM acct WHERE id=1;
                                         -- 100      <- not 999
ROLLBACK;

Session B reads 100. I checked the premise rather than trusting the setup: pg_stat_activity showed one backend holding a live backend_xid, so the uncommitted 999 genuinely existed at that moment. It was simply never visible.

Dirty reads are not implementable here. Under MVCC an uncommitted row version is stamped with a transaction id that no other snapshot considers valid, so there is no code path that returns it. PostgreSQL accepts the keyword for standard compliance and silently gives you Read Committed instead. Four levels in the syntax, three in behaviour.

2. Read Committed: the default, and what it does not promise

Read Committed is the default, and the promise is narrow: you never see uncommitted data. It says nothing about seeing the same data twice, and that is not a corner case — it takes two ordinary statements:

-- Session A (READ COMMITTED)            -- Session B
BEGIN;
SELECT bal FROM acct WHERE id=1;
-- 100
                                         UPDATE acct SET bal=555 WHERE id=1;
                                         COMMIT;
SELECT bal FROM acct WHERE id=1;
-- 555        <- same transaction, different answer
COMMIT;

Each statement takes a fresh snapshot, so a long transaction is not reading one version of the world — it is reading a slideshow. Count rows and the same thing happens with rows appearing rather than changing:

READ COMMITTED   count #1 = 2   count #2 = 3    <- phantom read
                 (a concurrent INSERT committed in between)

Neither of these throws. If a report sums a column, then re-reads it to check a total, Read Committed lets those two reads disagree and nothing anywhere says so.

3. Repeatable Read blocks phantoms — which the standard says it need not

Raise the level and re-run both scenarios unchanged:

non-repeatable read
  READ COMMITTED    read #1 = 100   read #2 = 555     changed
  REPEATABLE READ   read #1 = 100   read #2 = 100     stable

phantom read
  READ COMMITTED    count #1 = 2    count #2 = 3      phantom
  REPEATABLE READ   count #1 = 2    count #2 = 2      blocked
                    (outside the transaction: 3 rows, the INSERT did commit)

The second block is the interesting one. The standard's table permits phantom reads at Repeatable Read; PostgreSQL blocks them anyway. That is not a bonus feature bolted on, it falls out of the implementation: Repeatable Read takes one snapshot at the first statement and every later read in that transaction is answered from it. A row inserted afterwards is not hidden by a lock — it simply is not in the snapshot.

This is where copying the standard's table costs you real work. Code written to defend against phantoms at Repeatable Read on PostgreSQL is defending against something that cannot occur. Meanwhile the anomaly that can occur, in §6, is not in that table at all.

4. “Repeatable Read means read locks” is off by three orders of magnitude

The usual explanation for why the reads above stayed stable is that Repeatable Read holds read locks until commit, so nobody can modify what you have read. That is a testable claim, so I tested it — with a control, because a single timing number proves nothing on its own. Same scenario twice: one session holds a row while another tries to update it.

CONTROL   holder ran SELECT ... FOR UPDATE   ->  writer waited 4003 ms
TEST      holder ran a plain SELECT at RR    ->  writer waited    1 ms

The control is there to prove the measurement can detect a lock at all: a real FOR UPDATEmade the writer wait out the holder's full four seconds. Against a plain SELECT at Repeatable Read the same writer finished in a millisecond. There is no read lock. There was never a read lock.

Both facts have the same cause. An UPDATE does not overwrite a row, it writes a new version alongside the old one, so a writer has nothing to wait for and a reader holding an older snapshot keeps seeing the older version. Readers do not block writers, writers do not block readers, and the level you choose decides when your snapshot is taken rather than how long you hold locks.

Locks have not disappeared — two transactions updating the same row still serialise, and SELECT ... FOR UPDATE is exactly how you ask for a lock deliberately. It is ordinary reads that are free.

5. A stable snapshot does not make read-modify-write safe

Here is the trap that follows from §3. Repeatable Read gives such clean reads that it looks like it makes the classic read-then-write pattern safe. It does not. Two transactions, both reading a balance of 100, one adding 10 and one adding 20. The correct answer is 130:

READ COMMITTED    final bal = 120   no error
REPEATABLE READ   final bal = 110   ERROR 40001: could not serialize access
                                           due to concurrent update
SERIALIZABLE      final bal = 110   ERROR 40001: (same)

Read Committed returned 120 and reported success. One update was overwritten by the other and the total is quietly wrong forever. That is a lost update, and it is the most expensive line in this post.

Repeatable Read returned 110, which looks worse and is not. 110 is one transaction committed correctly and the other refused with SQLSTATE 40001. Nothing was silently lost; one caller was told to come back. Retry it and you land on 130.

So Repeatable Read does not remove the conflict, it converts a silent wrong answer into a loud error — and hands you the obligation to catch it. Any code running above Read Committed needs this, and it belongs around the whole transaction, because a retry means redoing the reads too:

for attempt in range(3):
    try:
        with conn.transaction():          # BEGIN ... COMMIT
            bal = read_balance(conn, 1)   # the read must happen inside the retry
            write_balance(conn, 1, bal + delta)
        break
    except psycopg.errors.SerializationFailure:
        if attempt == 2:
            raise
        time.sleep(0.05 * 2 ** attempt)   # back off, then take a fresh snapshot

Retrying only the UPDATE re-applies arithmetic derived from a stale read, which reintroduces the bug you raised the isolation level to remove.

Worth saying plainly: if the whole operation fits in one statement — UPDATE acct SET bal = bal + 20 WHERE id = 1 — none of this applies. The database does the read and the write atomically, at any isolation level, with no retry loop. Raising the isolation level is what you do when the logic genuinely cannot fit in one statement.

6. Write skew: the one reason to reach for Serializable

By now Repeatable Read looks close to complete: stable reads, no phantoms, lost updates turned into retryable errors. So what is Serializable for?

For this. An on-call table with a rule everybody knows — at least one person must stay on call. Two people, both on call, both deciding to go home at the same moment. Each checks the rule first, and each check passes:

-- both sessions run exactly this, concurrently
BEGIN;
SELECT count(*) FROM duty WHERE on_call;      -- 2, so it is safe for me to leave
UPDATE duty SET on_call = false WHERE name = :me;
COMMIT;
REPEATABLE READ   0 people left on call    no error
SERIALIZABLE      1 person  left on call    ERROR 40001: could not serialize access
                                                  due to read/write dependencies
                                                  among transactions

Repeatable Read committed both transactions happily and left nobody on call. No lost update happened — the two transactions touched different rows, so there was no conflict to detect. Each one read a fact, that fact was invalidated by the other, and both wrote based on what was true when they looked.

That is write skew, and it is not in the standard's three-anomaly table. Snapshot isolation is exactly where it lives, so it is precisely the anomaly PostgreSQL users are least warned about.

Serializable catches it. PostgreSQL implements Serializable as SSI — Serializable Snapshot Isolation — which tracks the read/write dependencies between overlapping transactions and aborts one when the pattern could not have arisen from running them one after another. Note what it does not do: it does not queue transactions behind locks. They run concurrently and one loses at commit time. That is why the message says read/write dependencies rather than anything about waiting.

The cost is that same 40001, and now it can hit transactions that never touched a common row — so the retry loop from §5 stops being optional bookkeeping and becomes the price of admission.

7. Choosing, in order

  • Can it be one statement? Then do that and stop reading. An atomic UPDATE ... SET x = x + n beats every level below, with no retries.
  • Read Committed — the default — for anything where two reads disagreeing is survivable. Just do not run read-modify-write on it: that is the 120 that never reports an error.
  • Repeatable Read when a transaction must see one consistent version of the world: reports, exports, multi-step reads. On PostgreSQL you get phantom protection for free. Bring the retry loop.
  • Serializablewhen correctness depends on an invariant spanning rows that your transaction does not itself write — the on-call rule, double booking, any “at least one” or “at most N” constraint. Nothing below it sees write skew.
  • Handle 40001 anywhere above Read Committed. An unhandled serialization failure is a 500 to a user who did nothing wrong, in an app that was one retry away from being correct.

The thread through all of it: isolation levels on PostgreSQL are not about how long locks are held, they are about when your snapshot is taken and which conflicts get promoted from silent corruption into an error you have to answer for. Each level up trades a class of quiet wrongness for a retry you have to write. That is the actual decision, and the standard's table does not show it.

PostgreSQL / ConcurrencyBack to Home