Skip to main content
RunBook Academy

PostgreSQLIX · Locks, Blocking and DeadlocksLocks

Lock modes and the conflict matrix

Intermediate⏱ ~30 minpsql

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

Not yet marked complete on this device.

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.

ModeTaken by
ACCESS SHARESELECT
ROW SHARESELECT … FOR UPDATE/SHARE, foreign key checks
ROW EXCLUSIVEINSERT, UPDATE, DELETE, MERGE
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, most ALTER TABLE … SET
SHARECREATE INDEX (non-concurrent)
SHARE ROW EXCLUSIVECREATE TRIGGER, some ALTER TABLE forms
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLY
ACCESS EXCLUSIVEALTER 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.

Read-only / Safetable-level conflicts, measured pairwise on 18.6
$ 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             -> CONFLICTS

Three 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.

OperationLockBlocks reads?
ADD COLUMN with no default, or with a non-volatile defaultACCESS EXCLUSIVEYes, but very briefly
ADD COLUMN with a volatile defaultACCESS EXCLUSIVEYes, for a full rewrite
DROP COLUMNACCESS EXCLUSIVEYes, briefly — metadata only
ALTER COLUMN TYPEACCESS EXCLUSIVEYes, for a full rewrite
SET NOT NULLACCESS EXCLUSIVEYes, for a full scan
ADD CONSTRAINT … NOT VALIDACCESS EXCLUSIVEYes, briefly
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVENo
CREATE INDEXSHARENo — blocks writes
CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVENo
ALTER TABLE … SET (fillfactor = …)SHARE UPDATE EXCLUSIVENo
TRUNCATEACCESS EXCLUSIVEYes

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.

Read-only / Safewhat a real contention picture looks like
$ 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. EXCLUSIVE allows reads; ACCESS EXCLUSIVE does not.
  • SHARE UPDATE EXCLUSIVE conflicts 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_timeout before 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

  1. 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?

  2. Q2. An ALTER TABLE ADD COLUMN that is metadata-only took 18 seconds on a measured run. What accounted for the time?

  3. 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?

  4. Q4. Which of these were confirmed by the measured conflict matrix? Select all that apply.

  5. 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.

  6. 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.