25 Everyone waits in line
A migration adds a nullable column to accounts. It’s a metadata-only change that completes in milliseconds. The moment it runs, every request that touches accounts hangs, the connection pool fills, and the site goes down. Nothing in the migration was slow. It was waiting, and while it waited, everyone else waited too.
Here is that outage, reproduced with three sessions on the book’s database. migration runs alter table accounts add column note text, and web runs select name from accounts where id = 42:
The reporting session ran select count(*) from accounts inside a transaction and never closed it. The migration needs an exclusive lock, so it waits for the reporting session. The web request only wants to read one row, which the reporting session would happily allow, but it’s queued behind the migration. Every request after it joins the line.
Usually readers and writers don’t block each other (Updates are inserts). When they do, the line is the whole story. Learn to read it.
Pick an isolation level on purpose
Read committed, the default, gives each statement a fresh snapshot; repeatable read gives the whole transaction one; serializable also guarantees a result some one-at-a-time order could produce. Only serializable rules out every anomaly lists what each prevents; here is what each does to your code. (InnoDB defaults to repeatable read: MySQL defaults to repeatable read; Postgres doesn’t.)
Read committed loses updates computed in the app
Most applications run here and are fine. The trap is read-modify-write in application code: two requests each read a balance, subtract in the application, and write back.
tx1 spent 30 and tx2 spent 20: fifty credits spent, balance down by twenty. That’s a lost update, and no error says so. Do the arithmetic in the database instead:
An update that waited on a changed row re-reads the latest version and re-checks its where, so two concurrent spends return 70 and then 50. When the logic can’t fit in one statement, lock the row as you read it with select ... for update.
Deep dive: The lost update, run for real
Two sessions, both at read committed, each doing what a request handler would:
While tx2 waits, pg_stat_activity shows it on Lock / transactionid, blocked by tx1. When tx1 commits, tx2’s update finds the row changed, re-reads it, re-checks where account_id = 42 (still true), and writes its 80 over tx1’s 70.
Prove it · lab 24-concurrency
Run the same sequence with both transactions at repeatable read and tx2 fails the moment tx1 commits, because the row it wants to update changed after its snapshot. tx1’s 70 stands.
Prove it · lab 24-concurrency
Repeatable read turns that race into an error to retry
At repeatable read, an update of a row someone changed since your snapshot is refused instead of overwritten: the same race fails tx2 with could not serialize access due to concurrent update. At this level and at serializable, that error (SQLSTATE 40001) is a normal outcome, not a bug: your code retries the whole transaction, or rare races become user-facing errors.
Serializable catches the write skew repeatable read misses
Repeatable read still allows write skew. An on-call rotation must always have someone on call. Two engineers go off call at the same moment; each counts who’s on call (2, so it’s safe) and deactivates only their own row. Both commit, and nobody is on call; no row was updated twice, so repeatable read saw nothing wrong. At serializable, one fails with could not serialize access due to read/write dependencies among transactions. Postgres tracks what each transaction read, without blocking anyone, and aborts one when no serial order explains the result (Even a read-only transaction can see the impossible).
Deep dive: Write skew on the on-call table
Two rows, both active; each engineer’s transaction runs:
The read tracking shows as SIReadLock entries in pg_locks. You pay in overhead and in more retryable failures, a few of them false positives.
Prove it · lab 24-concurrency
Our default: read committed for requests, repeatable read for reports, serializable for rules across rows, with retries.
Retry the whole transaction
Serialization failures (40001) and deadlocks (40P01) mean “nothing happened; try again”. Retry the whole transaction, reads included, with capped, jittered attempts. Those two codes are the ones to retry by default; under serializable, a unique violation (23505) or exclusion violation (23P01) can also be a lost race worth one more try. Retry nothing else blindly. Keep side effects out of the retried function, or an email goes out once per attempt; record the intent and act after commit (Put the message in the same transaction as the order).
Deep dive: A retry wrapper in Go
One error aborts the whole transaction
In Postgres, any error inside a transaction poisons the rest of it:
The update never ran, and commit came back as ROLLBACK: the first insert is gone too. “Insert, catch the duplicate, carry on” breaks the moment someone wraps it in a transaction. Prevent the error instead (on conflict do nothing), or use a savepoint (MySQL differs; see Errors abort the whole transaction).
Prove it · lab 24-concurrency
Savepoints contain the damage
A savepoint marks a point you can roll back to without losing the rest. ORM “nested transactions” are savepoints (Rails’ requires_new: true, Django’s nested atomic()), and so is every PL/pgSQL EXCEPTION block.
Subtransactions are not free
Each savepoint starts a subtransaction, and one that writes gets its own XID. Each backend caches 64 subtransaction XIDs; past that the cache overflows, and every session’s visibility checks against that transaction fall back to an on-disk structure, pg_subtrans. Replicas are more fragile still: there, any subtransaction alongside one long-running transaction can push lookups into pg_subtrans. This bit GitLab in 2021: on their replicas, a single savepoint during a long-running transaction was enough to stall queries on and off for weeks, until they removed savepoints from the application (GitLab’s write-up). Keep savepoints out of loops, and reach for on conflict first.
Deep dive: Watching the subtransaction cache overflow
pg_stat_get_backend_subxact (PostgreSQL 16+) shows it. After 64 savepoints in one transaction, each followed by an insert: subxact_count 64, subxact_overflowed false. One more: still 64, and true.
Prove it · lab 24-concurrency
Every statement locks the table
Every statement also locks each table it touches until the transaction ends. Three facts about the eight table lock modes cover nearly every incident:
- Reads and writes never conflict at the table level. Row conflicts are a separate system.
ACCESS EXCLUSIVEconflicts with everything, including plainselect.drop,truncate, and mostalter tableforms take it.SHAREblocks writes but not reads, which is why plaincreate indexmakes a table read-only andcreate index concurrentlyexists.
Most ALTER TABLE forms take ACCESS EXCLUSIVE
Most ALTER TABLE forms take ACCESS EXCLUSIVE, even when the work is instant: adding a nullable column takes microseconds, but getting the lock is the dangerous part (Change the schema, keep the site). To check a statement, run it in a transaction, read your rows in pg_locks, and roll back.
Deep dive: The eight modes, the conflict matrix, and what each DDL took
ROW SHARE is select ... for update; ROW EXCLUSIVE is insert, update, delete; SHARE UPDATE EXCLUSIVE is vacuum, analyze, create index concurrently; SHARE ROW EXCLUSIVE is create trigger and adding a foreign key; EXCLUSIVE is refresh materialized view concurrently.
An X means the requested mode waits. On this server add column, add constraint ... not valid, and rename column took AccessExclusiveLock; validate constraint and set (fillfactor = 90) took ShareUpdateExclusiveLock; add foreign key ... not valid took ShareRowExclusiveLock on both tables.
Prove it · lab 24-concurrency
A waiting lock blocks everyone behind it
Locks are granted in order, like a single line at a counter. Requests that conflict with a waiting request queue behind it, even if they’re compatible with everything held. That prevents starvation, and it’s exactly what turns a harmless migration into an outage.
In pg_locks during the opening incident, the web query’s AccessShareLock shows granted = f, though the only granted lock is another AccessShareLock: it’s behind the migration. In production a few hundred requests a second pile up, the pool runs dry, and health checks fail. When the reporting transaction ends, the migration runs in a millisecond and the line drains.
Prove it · lab 24-concurrency
The defense is to make DDL give up quickly. With set lock_timeout = '2s', the same alter fails with canceling statement due to lock timeout after two seconds instead of holding the line. Two seconds of blocked traffic is a blip; retry a few times. Then find the session that held the lock: an idle in transaction session holding locks is a bug, and idle_in_transaction_session_timeout caps how long it can last.
Any statement that takes ACCESS EXCLUSIVE can take the site down if it has to wait, so every migration session sets lock_timeout and retries (Rule one: lock_timeout and retry).
Deep dive: Reproducing the queue
Three sessions: reporting runs begin; select count(*) from accounts; and sits idle; migration runs alter table accounts add column note text; web runs select name from accounts where id = 42. Then pg_locks joined to pg_stat_activity on pid, for relation = 'accounts'::regclass:
Prove it · lab 24-concurrency
Row locks live in the row, not in pg_locks
Row locks aren’t kept in pg_locks while held (there could be millions). A locking transaction writes its ID into the row’s header (xmax, Everything is a page), and a transaction wanting a conflicting lock waits for that transaction to finish. So a row-lock wait usually shows up as an ungranted ShareLock on a transactionid, not on a row. When several sessions queue for the same row, the first waiter also holds a heavyweight tuple lock on it, and the ones behind it wait on that tuple lock instead, so in a pile-up you’ll see ungranted tuple entries too.
| Lock | Taken by | Blocks |
|---|---|---|
FOR UPDATE | DELETE; UPDATE that changes a key column; explicit | All other row locks |
FOR NO KEY UPDATE | Any other UPDATE; explicit | FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE |
FOR SHARE | Explicit select ... for share | FOR UPDATE, FOR NO KEY UPDATE |
FOR KEY SHARE | Foreign key checks on the referenced row; explicit | FOR UPDATE only |
The weaker pair exists for foreign keys. Inserting an event for project 1 takes FOR KEY SHARE on the project, and a rename of the project (FOR NO KEY UPDATE) doesn’t block it. But while a session holds select ... for update on the project, the insert waits: 3.6 seconds in our test, until that session rolled back. So lock with for no key update unless you’ll delete the row or change its key. nowait fails instead of waiting; skip locked skips rows someone holds.
Deep dive: What a row-lock wait looks like in pg_locks
With session A holding for update on project 1, the insert’s locks:
The writer asks for a ShareLock on the locker’s XID: “wake me when that transaction ends”. The granted tuple lock is the writer’s place at the head of the line for this row; a second writer would wait on it, ungranted.
Prove it · lab 24-concurrency
Shared row locks make multixacts, and multixacts wrap too
When two open transactions lock the same row in modes that don’t conflict, such as two child inserts taking FOR KEY SHARE on one parent, Postgres puts a multixact in its xmax: an ID pointing to a member list. A popular parent under many concurrent child inserts gets a new one per insert. Multixacts have their own wraparound (Wraparound stops writes before anything is corrupted): watch mxid_age(datminmxid) next to transaction ID age, and keep child-inserting transactions short.
Deep dive: Three lockers, and the member-space limits
Three sessions each begin, insert one child row for parent 1, and stay open. pgrowlocks shows one multixact in the parent’s xmax, and pg_get_multixact_members lists three transaction IDs in keysh mode. The third locker got a new multixact holding all three, since multixacts never change.
Prove it · lab 24-concurrency
autovacuum_multixact_freeze_max_age (400 million by default) forces an anti-wraparound vacuum. The member area has its own limit: past about 10 GB autovacuum gets aggressive everywhere, and at about 20 GB the server refuses to create multixacts (multixact "members" limit exceeded, Routine Vacuuming), which blocks exactly the foreign key inserts into a busy parent. For such a parent, alter table projects set (autovacuum_multixact_freeze_max_age = 100000000) freezes it sooner, and avoid for share in application code.
LWLock waits are fixed in the workload, not the query
Not every line forms on a lock you asked for. Under heavy concurrency, sessions also queue on LWLocks, which no statement of yours mentions. A lightweight lock guards a piece of shared memory for microseconds, and a pile of waits means “too many of us are doing the same internal thing at once”, not “someone holds what I want”. So the fix is in the workload:
LockManager: hot queries lock too many tables and indexes. Each backend has a few private fast-path slots that skip the shared lock table: 16 through PostgreSQL 17, 64 by default in 18.BufferMapping: the working set doesn’t fit in memory.WALWrite: too many tiny transactions commit.
Deep dive: Inside the lock manager: the fast path, and four LWLocks
Every query locks every relation it plans against, in a shared lock table split into 16 partitions, each behind one LockManager LWLock. Private fast-path slots skip that for weak table locks; PostgreSQL 18 sizes them from max_locks_per_transaction, 64 at the default (the commit). An unpruned query over 365 partitions took 64 fast-path locks and 1,035 shared ones (Every partition a query can’t prune costs planning time). Drop unused indexes, make partitioned queries prune, and on 18 raise max_locks_per_transaction (a restart) if hot queries touch more than 64 relations.
Prove it · lab 24-concurrency
BufferMapping is explained in Finding a page takes a hash lookup and WALWrite in Many commits share one flush. A fourth, BufferContent, means one hot page many sessions write, like the last leaf of an ever-increasing key. Two more lines form inside the server: the relation extension lock (Lock:extend) when many sessions grow one table, much cheaper since PostgreSQL 16 (the commit), and the SLRU caches for transaction status, subtransactions, and multixacts, thrashing when pg_stat_slru’s blks_read keeps climbing.
A Postgres queue commits with the business write
Row locks and skip locked add up to a solid job queue, with one decisive advantage over a separate system: the job is enqueued in the same transaction as the business write. Roll back and it never existed. A jobs table needs a partial index on queued jobs only, which stays small however many finished jobs pile up:
Each worker skips the jobs others hold
Each worker must get a different job without waiting on the others:
The subquery skips any job another worker holds and takes the first it can lock: 20 buffers with 200,000 finished jobs in the table. With worker A holding job 1, worker B got job 2 immediately. Without skip locked, a third worker queued behind A until its lock_timeout fired: every worker in line for the same row, throughput collapsed to one.
Deep dive: The table, the claim’s plan, and the workers side by side
Short jobs hold the transaction; long jobs commit first
For jobs of a few seconds, hold the transaction for the whole job: a crash rolls it back and frees the job. For jobs that run minutes, which would hold back vacuum, claim and commit, process, then record the result, with a reaper for jobs stuck in running and idempotent handlers. A claim reads about 20 pages, so the queue rarely limits throughput before the jobs do; we didn’t benchmark a ceiling, so measure yours. Delete or archive done jobs on a schedule: the partial index ignores them, but the heap and vacuum don’t.
Deep dive: Recording results, retries, and the reaper
notify jobs in the enqueuing transaction is delivered only on commit, so workers can listen instead of polling hard. At high enqueue rates, though, every committing transaction that issued a notify takes one database-wide lock to queue its notifications, which serializes those commits. Notify once per batch, or let workers poll at a modest interval.
Take advisory locks per transaction, not per session
For mutual exclusion over something that isn’t a row (“only one nightly rollup at a time”), advisory locks lock an integer key that means whatever you say. Two cron instances each start a transaction and run select pg_try_advisory_xact_lock(hashtext('nightly-rollup')): the first gets t, the second f and exits. The transaction-scoped form is released at commit or rollback, so prefer it to the session-level pg_advisory_lock. Session-level locks leak through any error path that skips the unlock, for as long as the pooled connection lives.
Deep dive: An advisory lock shows up as its hashed key
The checks below use the session-level pg_try_advisory_lock to show the difference. In pg_locks the lock shows as locktype = advisory with objid 107030647, which is hashtext('nightly-rollup'). For entity locks, the two-integer form, pg_advisory_xact_lock(1, account_id), avoids hash collisions.
Prove it · lab 24-concurrency
Deadlocks are an ordering bug
A deadlock is two transactions each waiting for a lock the other holds. After deadlock_timeout (1 second) Postgres finds the cycle and aborts one:
tx1 got ERROR: deadlock detected; tx2 committed. The fix is consistent lock ordering: lock rows in primary key order (select ... order by id for update first, since where id = any($1) doesn’t guarantee order), touch tables in a fixed order, and retry 40P01. Turn on log_lock_waits.
Deep dive: Deadlocks are counted, so graph them
The error’s DETAIL names both processes, the XID each was waiting for, and both statements, which usually points straight at the two code paths that lock in opposite orders. pg_stat_database.deadlocks counts them, so graph it: a steady trickle means an ordering bug in a hot path, not bad luck. With log_lock_waits on, any lock wait that outlasts deadlock_timeout is logged with its blocker even when no cycle forms, which catches the near misses too.
Prove it · lab 24-concurrency
Find the root blocker
When something is stuck, find the root of the line first. This query lists every session waiting on a lock plus every session blocking one; it produced the table at the top of this chapter, which shows only some of its columns:
wait_event says relation for a table lock, transactionid for a row lock. The root has an empty blocked_by and is often idle in transaction. select pg_terminate_backend(11033) ends it, rolling back its transaction (pg_cancel_backend does nothing for an idle session). Then fix whatever left it open.
Almost every problem in this chapter gets worse with long transactions: they hold locks longer, extend the lock queue, widen the window for deadlocks, keep subtransaction overflow alive, and block vacuum. Never hold a transaction open across a network call or a user’s think time.
The short version
- Read committed loses updates in application read-modify-write. Do the arithmetic in SQL.
- Repeatable read and serializable turn anomalies into
40001errors; retry the whole transaction, side effects after commit. - One error aborts the transaction. Prevent errors instead of catching them, and keep savepoints out of loops.
- One waiting
ALTER TABLEblocks every later query on its table. Always setlock_timeoutfor migrations. LWLockwaits mean the workload is crowding an internal structure: fix the workload.for update skip lockedand a partial index make a job queue that commits with the business write.- Lock in a consistent order. In an incident, find the root blocker first.
Further reading
- Transaction Isolation, PostgreSQL documentation. What each level guarantees, including the read committed re-check rule.
- Explicit Locking, PostgreSQL documentation. Table and row lock matrices, advisory locks, deadlocks.
- Serialization Failure Handling, PostgreSQL documentation. Which SQLSTATEs to retry.
- Why we spent the last month eliminating PostgreSQL subtransactions, GitLab, 2021. The incident above, investigated in full.
- Subtransactions, PostgreSQL documentation. The 64-entry cache and what happens past it.
- src/backend/storage/lmgr/README, PostgreSQL source. Spinlocks, LWLocks, heavyweight locks, and the fast path.
- Multixacts and Wraparound, PostgreSQL documentation. Freezing multixacts and the member-space limits.