Module A-13·18 min read

Concurrency bugs are almost never about wrong SQL — they are about wrong locking assumptions. The complete PostgreSQL locking matrix.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 13 — Locking Internals: Row Locks, Table Locks, and Advisory Locks

What this module covers: Concurrency bugs in production are almost never about wrong SQL — they are about wrong locking assumptions. This module covers the complete PostgreSQL locking matrix from first principles: what every lock mode protects, how row-level locks differ from table-level locks, the precise semantics of FOR UPDATE vs FOR SHARE vs FOR NO KEY UPDATE, how ALTER TABLE can silently queue behind a single open transaction and take down your application, and how advisory locks let you build distributed coordination primitives directly inside Postgres.


Why Locking Is Hard to Reason About

Postgres has multiple, overlapping lock systems that operate at different granularities simultaneously:

  1. Table-level locks — protect the table structure and relation-level operations
  2. Row-level locks — protect individual tuple modifications
  3. Page-level locks — internal, mostly transparent (buffer pins)
  4. Advisory locks — application-defined locks with no automatic semantics

Every DML statement acquires locks at multiple levels simultaneously. An UPDATE acquires a RowExclusiveLock on the table and a row-level exclusive lock on each modified tuple. The two systems interact: some table-level operations must wait for all row-level locks to be released before they can proceed.

Understanding which operations conflict — and which do not — is the difference between a schema migration that completes in 30 seconds and one that causes a 20-minute outage.


Table-Level Lock Modes

Postgres defines 8 table-level lock modes, ordered from weakest to strongest. Each mode conflicts with some modes and is compatible with others.

The Full Lock Matrix

Lock ModeAcquired ByConflicts With
ACCESS SHARESELECTACCESS EXCLUSIVE only
ROW SHARESELECT FOR UPDATE/SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT, UPDATE, DELETESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, ALTER TABLE ... VALIDATESHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARECREATE INDEX (non-concurrent)ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE ROW EXCLUSIVECREATE TRIGGER, some ALTER TABLEROW EXCLUSIVE and above
EXCLUSIVERare — some replication operationsEverything except ACCESS SHARE
ACCESS EXCLUSIVEALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL, LOCK TABLEEverything including SELECT

The critical insight: ACCESS EXCLUSIVE conflicts with every other lock mode, including plain SELECT. This is why ALTER TABLE on a busy table causes an outage.

The ALTER TABLE Outage Pattern

This is one of the most common causes of production incidents. Here is the exact mechanism:

-- Timeline:
-- t=0: Application connection A runs a long SELECT (acquires ACCESS SHARE)
-- t=1: DBA runs: ALTER TABLE transactions ADD COLUMN notes TEXT;
--       Postgres tries to acquire ACCESS EXCLUSIVE
--       ACCESS EXCLUSIVE conflicts with A's ACCESS SHARE → queues
-- t=2: New application connections try to SELECT from transactions
--       They try to acquire ACCESS SHARE
--       ACCESS SHARE is compatible with ACCESS SHARE, BUT...
--       The queued ACCESS EXCLUSIVE lock blocks ALL subsequent lock acquisitions
--       New SELECTs queue behind the ALTER TABLE
-- t=3: Connection queue fills up. New connections refused. Outage.

The ALTER TABLE does not hold a lock — it is waiting for one. But its waiting position in the lock queue blocks all subsequent requests, even ones that would normally be compatible with each other.

-- See the lock queue in real time
SELECT
  pid,
  locktype,
  relation::regclass,
  mode,
  granted,
  ROUND(EXTRACT(EPOCH FROM (now() - query_start))::numeric, 1) AS wait_seconds,
  LEFT(query, 80) AS query
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE relation = 'transactions'::regclass
ORDER BY granted DESC, wait_seconds DESC;

The Correct ALTER TABLE Pattern

-- Step 1: Set a lock_timeout so the ALTER fails fast rather than queuing
SET lock_timeout = '2s';
ALTER TABLE transactions ADD COLUMN notes TEXT;
-- If it can't get the lock in 2s, it fails. Retry during a quiet moment.
 
-- Step 2: For operations that MUST complete:
-- Kill long-running queries before running the ALTER
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
  AND query_start < now() - INTERVAL '30 seconds'
  AND query NOT LIKE 'autovacuum%';
 
-- Then immediately run the ALTER
SET lock_timeout = '500ms';
ALTER TABLE transactions ADD COLUMN notes TEXT;

The window between killing queries and running the ALTER must be short — new long-running queries can start in the gap.

Lock Modes in Practice

-- What lock does each statement acquire?
-- ACCESS SHARE (compatible with everything except ACCESS EXCLUSIVE):
SELECT * FROM transactions WHERE id = 1;
 
-- ROW SHARE (used with explicit row locking):
SELECT * FROM transactions WHERE id = 1 FOR UPDATE;
 
-- ROW EXCLUSIVE (normal writes):
INSERT INTO transactions (...) VALUES (...);
UPDATE transactions SET status = 'confirmed' WHERE id = 1;
DELETE FROM transactions WHERE id = 1;
 
-- SHARE UPDATE EXCLUSIVE (long-running maintenance, allows reads/writes):
VACUUM transactions;
ANALYZE transactions;
CREATE INDEX CONCURRENTLY idx_new ON transactions (sender);
 
-- ACCESS EXCLUSIVE (blocks everything):
ALTER TABLE transactions ADD COLUMN x TEXT;
TRUNCATE transactions;
DROP TABLE transactions;
LOCK TABLE transactions;  -- explicit full lock

Row-Level Locks

Table-level locks protect the schema. Row-level locks protect individual tuples from concurrent modification. They are separate systems.

The Four Row Lock Modes

FOR UPDATE

  • Strongest row lock. Marks the row as being updated.
  • Blocks: other FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE
  • Use when: you intend to update or delete the row, or you need to prevent any concurrent modification

FOR NO KEY UPDATE

  • Like FOR UPDATE but does not block FOR KEY SHARE
  • Use when: updating non-key columns (foreign keys to this row remain unblocked)
  • Postgres uses this internally for UPDATE statements that don't modify key columns

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

See it in production

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.