PostgreSQLIX · Locks, Blocking and DeadlocksLocks
Lock modes and the conflict matrix
What you'll learn
- Name the lock each ordinary statement takes without looking it up
- Predict whether two operations conflict from the matrix
- Distinguish EXCLUSIVE from ACCESS EXCLUSIVE and say why it matters
- Read pg_locks and identify what is held against what is waiting
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Locks are where PostgreSQL’s “readers never block writers” guarantee meets the operations that genuinely do need exclusivity. The system is small — eight table-level modes and one matrix — and knowing it converts “the migration caused an outage” into “the migration took ACCESS EXCLUSIVE, which we should have expected”.
The eight modes
Ordered from weakest to strongest. Every ordinary statement takes one of these on every table it touches.
| Mode | Taken by |
|---|---|
ACCESS SHARE | SELECT |
ROW SHARE | SELECT … FOR UPDATE/SHARE, foreign key checks |
ROW EXCLUSIVE | INSERT, UPDATE, DELETE, MERGE |
SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, most ALTER TABLE … SET |
SHARE | CREATE INDEX (non-concurrent) |
SHARE ROW EXCLUSIVE | CREATE TRIGGER, some ALTER TABLE forms |
EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY |
ACCESS EXCLUSIVE | ALTER TABLE (most forms), DROP TABLE, TRUNCATE, VACUUM FULL, CLUSTER, REINDEX |
The names are historical and two of them are actively misleading, which the measurements below make concrete.
The matrix, measured
Rather than restate the documented matrix, each pair below was tested by
holding one mode in an open transaction and attempting the other from a
second session with a two-second lock_timeout.
$ psql -U postgres -c "SET lock_timeout='2s'; BEGIN; LOCK TABLE lockdemo IN <mode> MODE; COMMIT" # against a held lock ACCESS SHARE vs ACCESS SHARE -> COMPATIBLE
ACCESS SHARE vs ACCESS EXCLUSIVE -> CONFLICTS
ROW EXCLUSIVE vs ROW EXCLUSIVE -> COMPATIBLE
ROW EXCLUSIVE vs SHARE -> CONFLICTS
SHARE UPDATE EXCLUSIVE vs SHARE UPDATE EXCLUSIVE -> CONFLICTS
SHARE UPDATE EXCLUSIVE vs ROW EXCLUSIVE -> COMPATIBLE
SHARE vs SHARE -> COMPATIBLE
SHARE vs ROW EXCLUSIVE -> CONFLICTS
EXCLUSIVE vs ACCESS SHARE -> COMPATIBLE
ACCESS EXCLUSIVE vs ACCESS SHARE -> CONFLICTSThree of those deserve to be pulled out.
SHARE UPDATE EXCLUSIVE conflicts with itself. Two vacuums, or a
vacuum and an ANALYZE, cannot run on the same table at the same time.
This is why a long vacuum delays the analyze that would have fixed a
plan, and why CREATE INDEX CONCURRENTLY on a table currently being
vacuumed waits.
SHARE UPDATE EXCLUSIVE is compatible with ROW EXCLUSIVE. Vacuum
does not block INSERT, UPDATE or DELETE. This is the whole reason
routine vacuum is usable on production.
EXCLUSIVE is compatible with ACCESS SHARE. A lock mode called
“exclusive” still permits plain SELECT.
Which DDL takes what
This is the table worth knowing before writing a migration, because the
difference between two ALTER TABLE forms can be an outage.
| Operation | Lock | Blocks reads? |
|---|---|---|
ADD COLUMN with no default, or with a non-volatile default | ACCESS EXCLUSIVE | Yes, but very briefly |
ADD COLUMN with a volatile default | ACCESS EXCLUSIVE | Yes, for a full rewrite |
DROP COLUMN | ACCESS EXCLUSIVE | Yes, briefly — metadata only |
ALTER COLUMN TYPE | ACCESS EXCLUSIVE | Yes, for a full rewrite |
SET NOT NULL | ACCESS EXCLUSIVE | Yes, for a full scan |
ADD CONSTRAINT … NOT VALID | ACCESS EXCLUSIVE | Yes, briefly |
VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | No |
CREATE INDEX | SHARE | No — blocks writes |
CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | No |
ALTER TABLE … SET (fillfactor = …) | SHARE UPDATE EXCLUSIVE | No |
TRUNCATE | ACCESS EXCLUSIVE | Yes |
The pattern the safe migrations follow is visible in two rows: split a
long ACCESS EXCLUSIVE operation into a brief one plus a long weak
one. ADD CONSTRAINT … NOT VALID takes the strong lock for an instant
and does no scanning; VALIDATE CONSTRAINT does the scanning under a
lock that blocks nothing. Lesson IX-05 develops this.
Reading pg_locks
pg_locks shows every lock held and every lock waited for. It is not
readable on its own; joined to pg_stat_activity it is.
SELECT a.pid,
a.state,
l.locktype,
l.mode,
l.granted,
CASE l.locktype
WHEN 'relation' THEN l.relation::regclass::text
WHEN 'transactionid' THEN l.transactionid::text
ELSE coalesce(l.objid::text, '')
END AS target,
left(a.query, 60) AS query
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype IN ('relation', 'transactionid', 'tuple')
ORDER BY a.pid, l.granted DESC;
granted = false is the row that matters: that session is waiting.
$ psql -U postgres -c "SELECT a.pid, a.state, l.mode, l.granted, left(a.query,42) AS query, pg_blocking_pids(a.pid) AS blocked_by FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid JOIN pg_class c ON c.oid=l.relation WHERE c.relname='lockdemo' ORDER BY a.pid" pid | state | mode | granted | query | blocked_by
------+--------+---------------------+---------+--------------------------------------------+------------
1615 | active | AccessShareLock | t | SELECT pg_sleep(20); | {}
1622 | active | AccessExclusiveLock | f | ALTER TABLE lockdemo ADD COLUMN note text; | {1615}
1629 | active | AccessShareLock | f | SELECT count(*) FROM lockdemo; | {1622}
(3 rows)The locktype column distinguishes what is being locked, and the
distinction matters when reading a blocking chain:
relation— a table or index. What DDL and vacuum contend on.transactionid— a waiter attached to another transaction’s id, which is how row-level conflicts are represented.tuple— a short-lived lock on one tuple, taken while a session works out whether it can proceed.virtualxid— every session holds one on itself, always. Noise.
What to take from this
- Eight modes, one matrix. Most statements take the weakest one that works.
- “ACCESS” in a mode name is about plain
SELECT.EXCLUSIVEallows reads;ACCESS EXCLUSIVEdoes not. SHARE UPDATE EXCLUSIVEconflicts with itself but not with writes, which is what makes vacuum usable.- A brief strong lock is only brief if it is granted immediately. Set
lock_timeoutbefore DDL. - Row locks live in the row, not in
pg_locks. Relation locks are the ones that can exhaust shared memory.
Cross-course references
- Linux for Production Sysadmins — Part LXXIX (Troubleshooting methodology) covers working from a matrix rather than from a guess, which is the habit this lesson is training.
- Git, CI/CD & GitOps — Part CVI (Change management) covers getting the lock a migration will take stated in the change record, so the window is sized before the statement runs.
Quiz
Knowledge check · 6 questions
Q1. A change plan states that a migration 'takes an exclusive lock for about ten seconds'. Why is that description insufficient for agreeing a maintenance window?
Q2. An ALTER TABLE ADD COLUMN that is metadata-only took 18 seconds on a measured run. What accounted for the time?
Q3. A query against a table with 2,000 partitions fails with 'out of shared memory' and a hint about max_locks_per_transaction. Why does this happen with relations and not with rows?
Q4. Which of these were confirmed by the measured conflict matrix? Select all that apply.
Q5. Millions of row locks held by one transaction consume no entries in the shared lock table, because the lock is recorded in each row's own header.
Q6. A colleague proposes adding a NOT NULL constraint to a large production table during business hours. What would you ask, and what would you propose instead?
Passing score: 75%. Answers are checked in this browser.