Filter concepts by levelShowing all levels.

PostgreSQL · Concurrency

Isolation Levels and Concurrency

The four concurrency phenomena, where PostgreSQL exceeds the SQL standard, optimistic vs pessimistic control, the FOR UPDATE lock family, NOWAIT/SKIP LOCKED, and the one race-condition pattern behind five real workflows

  • 4 phenomena, escalatingdirty → non-repeatable → phantom → serialization anomaly
  • PG exceeds the standardRepeatable Read also blocks phantom reads
  • Optimistic vs pessimistica bet about how often conflicts really happen
  • One race, five disguisesinventory, payments, counters, jobs, uniqueness

1.Dirty Reads, Non-Repeatable Reads, Phantom Reads and Serialization Anomalies

A dirty read sees another transaction's uncommitted change. A non-repeatable read sees the same row return different values on a second SELECT within one transaction, because another transaction committed a change to it in between. A phantom read sees a repeated query return a different set of rows (not just different values), because another transaction inserted or deleted matching rows in between. A serialization anomaly is the most subtle: the combined result of several committed, concurrent transactions could not have happened under ANY serial (one-at-a-time) ordering of them.

Key features

  • Dirty read: seeing another transaction's uncommitted change — PostgreSQL never allows this, at any isolation level
  • Non-repeatable read: the same row read twice in one transaction returns different values, because another transaction committed a change to it in between
  • Phantom read: the same query re-run in one transaction returns a different SET of rows, because another transaction inserted/deleted matching rows in between
  • Serialization anomaly: the combined outcome of concurrent, individually-fine transactions is inconsistent with every possible serial ordering of them
  • PostgreSQL never permits dirty reads at any level; Repeatable Read additionally prevents non-repeatable reads AND phantom reads (stronger than the SQL standard requires); only Serializable prevents serialization anomalies too

The four phenomena

The four phenomena
PhenomenonWhat changesPrevented starting at
Dirty readseeing uncommitted datanever allowed by PostgreSQL, at any level
Non-repeatable reada row's values, between two readsRepeatable Read
Phantom readwhich rows match a query, between two readsRepeatable Read (PostgreSQL exceeds the SQL standard here)
Serialization anomalythe combined outcome vs any serial orderingSerializable only
A non-repeatable read
Transaction A
accounts table
Transaction B
  1. 1SELECT balance → 1000
  2. 2UPDATE balance = 900; COMMIT
  3. 3SELECT balance → 900 (different!)
  1. Transaction A → accounts table: SELECT balance → 1000
  2. Transaction B → accounts table: UPDATE balance = 900; COMMIT
  3. Transaction A → accounts table: SELECT balance → 900 (different!)

Command

sql
-- Read Committed (default): permits non-repeatable and phantom reads
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 1000
-- (another transaction commits a change here)
SELECT balance FROM accounts WHERE id = 1;  -- may now read 900
COMMIT;

Dirty read (uncommitted data — never happens in PostgreSQL), non-repeatable read (a row's values change between two reads), phantom read (the matching row SET changes between two reads), serialization anomaly (the combined outcome is impossible under any serial ordering). Repeatable Read prevents the first three; only Serializable prevents all four.

2.PostgreSQL's Behavior Under Its Isolation Levels

PostgreSQL implements only three distinct isolation levels internally, not four — requesting Read Uncommitted silently gives you Read Committed behavior instead, since PostgreSQL never allows dirty reads regardless of what you ask for. And PostgreSQL's Repeatable Read is stronger than the SQL standard requires: it also blocks phantom reads, which the standard only mandates starting at Serializable.

Key features

  • PostgreSQL has 3 internally distinct isolation levels, not 4 — Read Uncommitted is accepted as valid syntax but behaves identically to Read Committed
  • Dirty reads never happen in PostgreSQL, at any level, including nominal "Read Uncommitted" — this is stronger than the SQL standard requires at that level
  • PostgreSQL's Repeatable Read also prevents phantom reads, which the SQL standard only requires starting at Serializable — this is a real, documented deviation worth knowing explicitly
  • Only Repeatable Read and Serializable can fail a transaction with a serialization error requiring retry — Read Committed never does
  • Serializable in PostgreSQL is implemented via Serializable Snapshot Isolation (SSI), detecting conflicts via predicate locking rather than literally executing transactions one at a time

SQL standard requirement vs PostgreSQL's actual behavior

SQL standard requirement vs PostgreSQL's actual behavior
LevelStandard requiresPostgreSQL actually provides
Read Uncommittedmay allow dirty readsbehaves exactly like Read Committed — no dirty reads
Read Committedno dirty readsno dirty reads (matches standard)
Repeatable Readno dirty/non-repeatable reads; phantoms allowedALSO prevents phantom reads — stronger than required
Serializableprevents all four phenomenaprevents all four (matches standard), via SSI
PostgreSQL vs the SQL standard, per isolation level
Read Uncommitted
behaves exactly like Read Committed — no dirty reads, though the standard would allow them
Read Committed
matches the standard exactly
Repeatable Read
also blocks phantom reads — the standard does not require this until Serializable
Serializable
matches the standard, via SSI predicate locking
  • Read Uncommitted: stronger than the standard, between fewer guarantees and more guarantees — behaves exactly like Read Committed — no dirty reads, though the standard would allow them
  • Read Committed: matches the standard, between fewer guarantees and more guarantees — matches the standard exactly
  • Repeatable Read: stronger than the standard, more guarantees — also blocks phantom reads — the standard does not require this until Serializable
  • Serializable: matches the standard, more guarantees — matches the standard, via SSI predicate locking

Command

sql
SHOW transaction_isolation;  -- 'read committed' by default

BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation;  -- still reports 'read uncommitted' as requested,
                              -- but behaves exactly like Read Committed underneath
COMMIT;

PostgreSQL implements 3 distinct isolation levels, not 4 — 'Read Uncommitted' is accepted syntax but behaves exactly like Read Committed. PostgreSQL's Repeatable Read is stronger than the SQL standard requires: it also blocks phantom reads. Check PostgreSQL's own docs, not a generic standard table, when reasoning about exactly what a level guarantees here.

3.Optimistic vs Pessimistic Concurrency Control

Pessimistic concurrency control locks a row before touching it (SELECT ... FOR UPDATE), assuming a conflict is likely enough to prevent upfront — other transactions simply wait. Optimistic concurrency control does not lock anything upfront; it reads a row, does its work, and only at write time checks (usually via a version column or WHERE clause matching the originally-read value) whether the row changed since it was read — assuming conflicts are rare enough to detect and retry instead of preventing.

Key features

  • Pessimistic: SELECT ... FOR UPDATE locks the row immediately; other transactions attempting to lock or modify it wait (or fail, with NOWAIT)
  • Optimistic: no lock is taken on read; the write is conditioned on the row being unchanged since it was read, typically via a version/updated_at column
  • Pessimistic pays a locking cost on every read-then-write, even when no conflict would have occurred — a cost independent of actual contention
  • Optimistic pays a retry cost only when a conflict genuinely occurred — cheap under low contention, expensive (frequent retries) under high contention
  • Optimistic concurrency requires the application to handle the "0 rows updated, someone else changed it" case explicitly — it is not automatic the way a lock wait is

Pessimistic vs optimistic concurrency

Pessimistic vs optimistic concurrency
PropertyPessimistic (FOR UPDATE)Optimistic (version check)
Cost paid on readlock acquisition, alwaysnone
Cost paid on conflictother transaction waitsthe losing transaction must retry
Best underhigh contentionlow contention
Requiresholding a transaction/lock opena version column or equivalent, and explicit retry logic
Two bets about how often transactions collide

Pessimistic — SELECT ... FOR UPDATE

  • Locks the row immediately, on read
  • Other transactions simply wait
  • Pays a locking cost even when no conflict happens
  • Best under high contention

Optimistic — version column

  • No lock taken on read
  • Write is conditioned on WHERE version = ?
  • Pays a retry cost, only when a conflict actually occurs
  • Best under low contention
  • Pessimistic — SELECT ... FOR UPDATE
    • Locks the row immediately, on read
    • Other transactions simply wait
    • Pays a locking cost even when no conflict happens
    • Best under high contention
  • Optimistic — version column
    • No lock taken on read
    • Write is conditioned on WHERE version = ?
    • Pays a retry cost, only when a conflict actually occurs
    • Best under low contention

Command

sql
-- pessimistic: lock the row up front
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- optimistic: no lock, check the version at write time
UPDATE accounts SET balance = balance - 100, version = version + 1
 WHERE id = 1 AND version = 7;  -- 0 rows updated means someone else changed it first

Pessimistic (SELECT ... FOR UPDATE) locks upfront and makes other transactions wait — good under high contention. Optimistic (version column + WHERE clause) takes no lock and detects conflicts only at write time via the affected row count — good under low contention, but requires the application to explicitly check for and handle a 0-row update as a real case.

4.Using SELECT ... FOR UPDATE Appropriately

SELECT ... FOR UPDATE locks the returned rows against concurrent modification for the rest of the current transaction, forcing other transactions that want to lock or change the same rows to wait until this one commits or rolls back. It is the pessimistic tool for read-then-write sequences that must not race — read a row, decide something based on it, then update it, with a guarantee nobody else changed it in between.

Key features

  • SELECT ... FOR UPDATE locks every row it returns until the current transaction ends (COMMIT or ROLLBACK)
  • Other transactions can still plain-SELECT the locked rows — FOR UPDATE only blocks other lockers/writers, not readers
  • The lock is acquired the moment the SELECT runs, and held for the rest of the transaction — using it outside an explicit BEGIN...COMMIT is nearly always a mistake, since autocommit would release it immediately
  • FOR UPDATE should target exactly the rows about to be modified — locking an unnecessarily broad set increases contention for no benefit
  • A transaction using FOR UPDATE must still keep the surrounding transaction short — the lock, like any lock, is held for the transaction's whole duration

FOR UPDATE at a glance

FOR UPDATE at a glance
PropertyBehavior
Blocks other FOR UPDATE/writes on the same rows?yes
Blocks plain SELECT on the same rows?no
Lock releasedat COMMIT or ROLLBACK
Typical useread-then-write sequences that must not race (inventory, balances, job claiming)
FOR UPDATE closes the read-then-write gap
Session A
Session B
inventory row
  1. 1SELECT ... FOR UPDATEreads 1, locks the row
  2. 2SELECT ... FOR UPDATE
  3. 3blocks — waits
  4. 4UPDATE quantity = quantity - 1
  5. 5COMMIT
  6. 6unblocked — reads the up-to-date value (0, not stale 1)
  1. Session A → inventory row: SELECT ... FOR UPDATE (reads 1, locks the row)
  2. Session B → inventory row: SELECT ... FOR UPDATE
  3. inventory row → Session B: blocks — waits
  4. Session A → inventory row: UPDATE quantity = quantity - 1
  5. Session A → inventory row: COMMIT
  6. inventory row → Session B: unblocked — reads the up-to-date value (0, not stale 1)

Command

sql
BEGIN;
SELECT quantity FROM inventory WHERE product_id = 42 FOR UPDATE;
-- row is now locked -- no other transaction can lock or update it until COMMIT/ROLLBACK
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 42;
COMMIT;

SELECT ... FOR UPDATE locks the returned rows against other lockers/writers for the rest of the current transaction — it protects exactly a read-then-write sequence, blocking other FOR UPDATE/writes but not plain SELECTs. It only works inside an explicit transaction; used outside one, the lock is released before it can protect anything.

5.FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE and FOR KEY SHARE

These four row-locking clauses form a strength ladder from strongest to weakest: FOR UPDATE (exclusive — blocks everything else), FOR NO KEY UPDATE (blocks writes but not a FOR KEY SHARE lock, used automatically by UPDATEs that do not touch key/unique columns), FOR SHARE (shared — blocks writes but allows other FOR SHARE/FOR KEY SHARE readers), and FOR KEY SHARE (weakest — only blocks a DELETE or an UPDATE that actually changes a key column).

Key features

  • FOR UPDATE: strongest — conflicts with all four modes, including another FOR UPDATE or any concurrent FOR KEY SHARE
  • FOR NO KEY UPDATE: conflicts with FOR SHARE, FOR NO KEY UPDATE and FOR UPDATE, but NOT with FOR KEY SHARE — this is what a plain UPDATE acquires automatically when it does not touch a primary key or unique column
  • FOR SHARE: conflicts with FOR NO KEY UPDATE and FOR UPDATE, but allows concurrent FOR SHARE and FOR KEY SHARE
  • FOR KEY SHARE: weakest — only conflicts with FOR UPDATE; specifically exists to let a foreign key check block a DELETE without blocking ordinary concurrent UPDATEs
  • Choosing a weaker lock than necessary risks a race the lock was meant to prevent; choosing a stronger lock than necessary needlessly increases contention

Conflict matrix (does row A's lock make row B's request wait?)

Conflict matrix (does row A's lock make row B's request wait?)
Held lockConflicts with
FOR UPDATEFOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE
FOR NO KEY UPDATEFOR UPDATE, FOR NO KEY UPDATE, FOR SHARE
FOR SHAREFOR UPDATE, FOR NO KEY UPDATE
FOR KEY SHAREFOR UPDATE
The four row-lock modes, strongest to weakest
FOR KEY SHARE
only blocks DELETE / key-changing UPDATE
FOR SHARE
blocks writes, allows other readers
FOR NO KEY UPDATE
what a plain UPDATE takes automatically
FOR UPDATE
blocks everything, including other FOR KEY SHARE
  • FOR KEY SHARE: blocks fewer operations, weaker intent — only blocks DELETE / key-changing UPDATE
  • FOR SHARE: between blocks fewer operations and blocks more operations, between weaker intent and stronger intent — blocks writes, allows other readers
  • FOR NO KEY UPDATE: between blocks fewer operations and blocks more operations, stronger intent — what a plain UPDATE takes automatically
  • FOR UPDATE: blocks more operations, stronger intent — blocks everything, including other FOR KEY SHARE

Command

sql
SELECT * FROM orders WHERE id = 1 FOR UPDATE;        -- strongest: full exclusive intent
SELECT * FROM orders WHERE id = 1 FOR NO KEY UPDATE;  -- updating non-key columns
SELECT * FROM orders WHERE id = 1 FOR SHARE;          -- read, but block concurrent writers
SELECT * FROM orders WHERE id = 1 FOR KEY SHARE;      -- weakest: just block deletion

FOR UPDATE (strongest, blocks everything) > FOR NO KEY UPDATE (blocks writes, not FOR KEY SHARE — what a normal UPDATE takes automatically) > FOR SHARE (blocks writes, allows other readers) > FOR KEY SHARE (weakest, only blocks DELETE and key-changing UPDATEs). Choose the weakest mode that actually expresses your real requirement to minimize needless contention.

6.NOWAIT and SKIP LOCKED Use Cases

By default, SELECT ... FOR UPDATE waits for a locked row to become free. NOWAIT instead fails immediately with an error if any selected row is already locked. SKIP LOCKED instead silently excludes any already-locked rows from the result and returns whatever is left unlocked — the standard mechanism for building a job queue where multiple workers each need to claim a different, unclaimed row without waiting on each other.

Key features

  • NOWAIT: fails immediately with an error (rather than waiting) if a selected row cannot be locked right away
  • SKIP LOCKED: silently omits already-locked rows from the result set instead of waiting or erroring — returns only rows it could actually lock
  • SKIP LOCKED gives an inconsistent view of the data (it is not "all matching rows," only "all matching rows that happened to be free") — the docs explicitly warn it is unsuitable for general-purpose queries
  • The canonical SKIP LOCKED use case is a job/task queue: many workers each run SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 to atomically claim one row nobody else has already claimed
  • Both options only affect the row-level lock; the required table-level ROW SHARE lock is still acquired normally either way

Default vs NOWAIT vs SKIP LOCKED

Default vs NOWAIT vs SKIP LOCKED
OptionBehavior when a row is already locked
(default)waits until the lock is released
NOWAITfails immediately with an error
SKIP LOCKEDsilently excludes that row, returns the rest
Two workers claiming jobs from the same queue

Plain FOR UPDATE

  • Worker B targets the same top row as Worker A
  • Worker B blocks, waiting for that exact row
  • Workers serialize even though other jobs are free

FOR UPDATE SKIP LOCKED

  • Worker B silently skips the row Worker A already locked
  • Worker B claims a different, genuinely unclaimed row
  • No waiting — workers run in true parallel
  • Plain FOR UPDATE
    • Worker B targets the same top row as Worker A
    • Worker B blocks, waiting for that exact row
    • Workers serialize even though other jobs are free
  • FOR UPDATE SKIP LOCKED
    • Worker B silently skips the row Worker A already locked
    • Worker B claims a different, genuinely unclaimed row
    • No waiting — workers run in true parallel

Command

sql
-- job queue: each worker claims one unclaimed row, no waiting
SELECT id FROM jobs
 WHERE status = 'pending'
 ORDER BY created_at
 LIMIT 1
   FOR UPDATE SKIP LOCKED;

-- fail fast instead of waiting: e.g. a UI edit-lock check
SELECT * FROM documents WHERE id = 5 FOR UPDATE NOWAIT;

NOWAIT fails immediately instead of waiting for a locked row — for fail-fast UI/API checks. SKIP LOCKED silently excludes locked rows and returns the rest — the standard building block for a multi-worker job queue, where any unclaimed row will do. Neither is a general-purpose SELECT option; SKIP LOCKED specifically gives an inconsistent view unsuitable outside queue-like patterns.

7.Race Conditions in Inventory, Payments, Counters, Jobs and Uniqueness Workflows

Five common workflow shapes share the same underlying race: read a value, decide something based on it, then write — with a gap in between where another transaction can do the exact same thing using the same stale read. Inventory (oversell the last unit), payments (double-charge or double-refund), counters (lost updates), job claiming (two workers processing the same job), and uniqueness checks (two "new" rows that were each checked against the same pre-insert state) are all this one pattern wearing different clothes.

Key features

  • Inventory: SELECT quantity, then check > 0, then UPDATE — two concurrent buyers can both pass the check on the same pre-decrement quantity
  • Payments: checking "has this payment already been processed" then processing it has the same gap — a double-submitted request can be processed twice
  • Counters: SELECT count, compute count + 1, UPDATE with the computed value — classic lost-update pattern, same shape as max(id)+1
  • Job claiming: multiple workers SELECT the same "next" unclaimed job before any of them has marked it claimed
  • Uniqueness checks: "check no row with this email exists, then INSERT" has a gap two concurrent signups can both pass

The pattern and its standard fix, per workflow

The pattern and its standard fix, per workflow
WorkflowThe gapStandard fix
Inventoryread quantity, then decrementsingle UPDATE ... WHERE quantity > 0, or FOR UPDATE
Paymentscheck processed, then processunique constraint on an idempotency key
Countersread count, compute, writeSET n = n + 1 in one statement, or FOR UPDATE
Job claimingread next job, then claim itFOR UPDATE SKIP LOCKED
Uniquenesscheck exists, then inserta real UNIQUE constraint, not an app-side check
One pattern, five disguises

Inventory

oversell the last unit

Payments

double-charge/refund

Counters

a lost update

Job claiming

two workers, one job

Uniqueness

two "new" rows, same key

  1. Inventory — oversell the last unit
  2. Payments — double-charge/refund
  3. Counters — a lost update
  4. Job claiming — two workers, one job
  5. Uniqueness — two "new" rows, same key

Command

sql
-- the general fix shape: collapse read-then-write into one atomic statement
UPDATE inventory SET quantity = quantity - 1
 WHERE product_id = 42 AND quantity > 0
RETURNING quantity;
-- 0 rows returned means "no stock" -- no separate SELECT, no gap

Inventory oversell, payment double-processing, lost counter updates, duplicate job claims, and race-y uniqueness checks are the same read-then-write gap wearing different clothes. Fix with one atomic statement (UPDATE ... WHERE ... RETURNING), FOR UPDATE to close the gap explicitly, a real UNIQUE constraint to make it a safe error, or FOR UPDATE SKIP LOCKED for interchangeable-worker claiming.

Remember

  • Dirty read (never in PostgreSQL) < non-repeatable read < phantom read < serialization anomaly — Repeatable Read prevents the first three, only Serializable prevents all four.
  • PostgreSQL's Repeatable Read exceeds the SQL standard by also blocking phantom reads — check PostgreSQL's own docs, not a generic isolation table.
  • Pessimistic (FOR UPDATE) locks upfront for high contention; optimistic (version column) checks at write time for low contention.
  • FOR UPDATE > FOR NO KEY UPDATE > FOR SHARE > FOR KEY SHARE — pick the weakest lock that expresses the real requirement.
  • Inventory, payments, counters, job claiming and uniqueness races are all the same read-then-write gap — fix with one atomic statement, FOR UPDATE, a UNIQUE constraint, or SKIP LOCKED.

Pro tip

  • SKIP LOCKED is the standard building block for a multi-worker job queue — any unclaimed row will do, so nobody should wait for a specific one.
  • NOWAIT is for fail-fast UX (tell the user immediately), not for background workers that should just try again.
  • FOR UPDATE only works inside an explicit BEGIN — used outside one, autocommit releases the lock before it protects anything.

Next up

  • MVCC — Must Understand
  • VACUUM, ANALYZE and Autovacuum
  • Storage and Table Internals

Sources

  • Transaction Isolation — postgresql.org/docs/17/transaction-iso.html
  • Explicit Locking — postgresql.org/docs/17/explicit-locking.html
  • SELECT (locking clause) — postgresql.org/docs/17/sql-select.html
  • Every example executed on PostgreSQL 17.11 · checked 2026-08-24

Spotted a mistake on this page? Tell us.