What lock does each DDL statement actually take? A cheat sheet verified against the PostgreSQL source

Every Postgres DDL statement takes a lock. Most cheat sheets on the internet get at least one wrong. This one is verified line by line against tablecmds.c, lockcmds.c, and indexcmds.c in PostgreSQL 17.

Every cheat sheet for Postgres DDL lock modes that I have seen on the internet has at least one error. The most common mistakes:

  • Treating every ADD CONSTRAINT form as one lock mode. Foreign keys use SHARE ROW EXCLUSIVE; CHECK, UNIQUE, PRIMARY KEY, EXCLUDE, and USING INDEX forms use ACCESS EXCLUSIVE on the target table.
  • Listing REFRESH MATERIALIZED VIEW CONCURRENTLY as SHARE UPDATE EXCLUSIVE (it is EXCLUSIVE)
  • Listing ALTER TYPE ADD VALUE as SHARE UPDATE EXCLUSIVE on PG12+ (the type-object lock is EXCLUSIVE, not a table lock at all)
  • Listing REINDEX TABLE as ACCESS EXCLUSIVE (it is SHARE on the table, ACCESS EXCLUSIVE only on the index)
  • Listing ATTACH PARTITION as ACCESS EXCLUSIVE on PG12+ (it is SHARE UPDATE EXCLUSIVE on the parent)

This post is the cheat sheet we use inside pgfence v0.6, with the source file and line of code that gives each lock its level. If you find a row that disagrees with the Postgres source, please open an issue and we will fix it.

The eight lock modes, ranked

From src/include/storage/lockdefs.h:

#Lock modeBlocks
1ACCESS SHAREOnly ACCESS EXCLUSIVE
2ROW SHAREEXCLUSIVE, ACCESS EXCLUSIVE
3ROW EXCLUSIVESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
4SHARE UPDATE EXCLUSIVESHARE UPDATE EXCLUSIVE and above
5SHAREROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
6SHARE ROW EXCLUSIVEROW EXCLUSIVE and above
7EXCLUSIVEEverything except ACCESS SHARE
8ACCESS EXCLUSIVEEverything

The colloquial way to read this: anything at level 5 or above blocks writes, only level 8 blocks reads.

ALTER TABLE subcommands

Source: AlterTableGetLockLevel() in src/backend/commands/tablecmds.c.

SubcommandLock modeBlocks readsBlocks writes
ADD COLUMN (no default)ACCESS EXCLUSIVEYesYes
ADD COLUMN ... DEFAULT <constant> (PG 11+)ACCESS EXCLUSIVE (metadata only, fast)Yes (briefly)Yes (briefly)
ADD COLUMN ... DEFAULT <volatile>ACCESS EXCLUSIVE (full rewrite)YesYes
ADD COLUMN ... NOT NULL (no default)ACCESS EXCLUSIVE (full scan)YesYes
DROP COLUMNACCESS EXCLUSIVEYesYes
ALTER COLUMN TYPE (binary-coercible)ACCESS EXCLUSIVE (fast)Yes (briefly)Yes (briefly)
ALTER COLUMN TYPE (rewrite required)ACCESS EXCLUSIVE (slow)YesYes
ALTER COLUMN SET NOT NULLACCESS EXCLUSIVEYesYes
ALTER COLUMN DROP NOT NULLACCESS EXCLUSIVEYesYes
ALTER COLUMN SET DEFAULTACCESS EXCLUSIVE (metadata)Yes (briefly)Yes (briefly)
ALTER COLUMN DROP DEFAULTACCESS EXCLUSIVE (metadata)Yes (briefly)Yes (briefly)
ADD CONSTRAINT ... CHECKACCESS EXCLUSIVEYesYes
ADD CONSTRAINT ... FOREIGN KEYSHARE ROW EXCLUSIVE (both tables)NoYes (both tables)
ADD CONSTRAINT ... UNIQUE (without USING INDEX)ACCESS EXCLUSIVEYesYes
ADD CONSTRAINT ... EXCLUDEACCESS EXCLUSIVEYesYes
ADD CONSTRAINT ... PRIMARY KEY (without USING INDEX)ACCESS EXCLUSIVEYesYes
ADD CONSTRAINT ... USING INDEX (UNIQUE or PK)ACCESS EXCLUSIVEYes (briefly)Yes (briefly)
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVENoNo
DROP CONSTRAINTACCESS EXCLUSIVEYesYes
RENAME COLUMNACCESS EXCLUSIVE (instant on PG14+)Yes (briefly)Yes (briefly)
RENAME (table)ACCESS EXCLUSIVEYesYes
SET TABLESPACEACCESS EXCLUSIVEYesYes
SET LOGGED / SET UNLOGGEDACCESS EXCLUSIVEYesYes
SET STATISTICSSHARE UPDATE EXCLUSIVENoNo
ATTACH PARTITION (PG12+)SHARE UPDATE EXCLUSIVE (parent)NoNo
DETACH PARTITIONACCESS EXCLUSIVEYesYes
DETACH PARTITION CONCURRENTLY (PG14+)SHARE UPDATE EXCLUSIVENoNo
REPLICA IDENTITY (any form)ACCESS EXCLUSIVEYes (briefly)Yes (briefly)

Index operations

Source: src/backend/commands/indexcmds.c and src/backend/commands/cluster.c.

StatementLock on tableLock on indexNotes
CREATE INDEXSHAREACCESS EXCLUSIVEBlocks writes for the whole build
CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVESHAREAllows reads and writes
DROP INDEXACCESS EXCLUSIVE (table)ACCESS EXCLUSIVE (index)Blocks everything
DROP INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVEACCESS EXCLUSIVE (briefly)Allows reads and writes
REINDEX INDEXn/aACCESS EXCLUSIVETable is implicit SHARE
REINDEX TABLESHAREACCESS EXCLUSIVE (per index)Reads OK, writes blocked
REINDEX TABLE CONCURRENTLYSHARE UPDATE EXCLUSIVEvariesAllows reads and writes
REINDEX SCHEMAper-table SHAREper-index ACCESS EXCLUSIVELong held; avoid in tx
CLUSTER tableACCESS EXCLUSIVEACCESS EXCLUSIVENo CONCURRENTLY form. Use pg_repack.

Type, sequence, and view operations

Source: src/backend/commands/typecmds.c, src/backend/commands/sequence.c, src/backend/commands/matview.c.

StatementLockObjectNotes
ALTER TYPE ADD VALUE (PG12+)EXCLUSIVEtype objectNot a table lock. Instant.
ALTER TYPE ADD VALUE (PG11 and earlier)ACCESS EXCLUSIVEtype objectCannot run inside a transaction
ALTER TYPE RENAME VALUEACCESS EXCLUSIVEtype object
CREATE SEQUENCEACCESS EXCLUSIVEnew object
ALTER SEQUENCEACCESS EXCLUSIVEsequence
CREATE MATERIALIZED VIEWACCESS EXCLUSIVEnew object
REFRESH MATERIALIZED VIEWACCESS EXCLUSIVEmatviewBlocks reads
REFRESH MATERIALIZED VIEW CONCURRENTLYEXCLUSIVEmatviewAllows reads but blocks other refreshes

The last row is worth highlighting. Many cheat sheets list this as SHARE UPDATE EXCLUSIVE. From matview.c:

/* Concurrent refresh takes EXCLUSIVE lock to serialize against other refreshes */
matviewOid = RangeVarGetRelidExtended(stmt->relation, ExclusiveLock, 0, ...);

It is EXCLUSIVE, not SHARE UPDATE EXCLUSIVE. Reads still work because EXCLUSIVE only blocks ROW SHARE and above, and plain SELECT takes ACCESS SHARE.

Schema, role, and database operations

StatementLockObjectNotes
CREATE SCHEMAACCESS EXCLUSIVEnamespace
DROP SCHEMA ... CASCADEACCESS EXCLUSIVEevery dropped objectWalks deps. Catastrophic if wrong.
DROP TABLEACCESS EXCLUSIVEtable
TRUNCATEACCESS EXCLUSIVEtableUse batched DELETE instead
CREATE TRIGGERSHARE ROW EXCLUSIVEtable(Corrected in pgfence v0.5)
DROP TRIGGERACCESS EXCLUSIVEtable
VACUUMSHARE UPDATE EXCLUSIVEtable
VACUUM FULLACCESS EXCLUSIVEtableUse pg_repack for large tables

How pgfence keeps this honest

This table lives in pgfence’s source as src/rules/lock-modes.ts. Every entry has at least one test that:

  1. Statically maps the AST node to the predicted lock mode
  2. Executes the statement against a real Postgres container in trace mode
  3. Polls pg_locks during execution and asserts the observed lock matches the prediction

If you upgrade Postgres and a lock mode changes (it has happened: see ATTACH PARTITION between PG11 and PG12), the trace tests will fail and we fix the table. Trace mode is built into the CLI, so you can verify any statement against your own Postgres version:

npx @flvmnt/pgfence trace migrations/your_migration.sql

The output includes a “Verified” column for every statement: Confirmed, Mismatch, or Trace-only. A mismatch is a bug we want to know about.

Bookmark and share

This page is the cheat sheet I wish I had had three years ago. It is also a living document: PostgreSQL 18 will tweak two of these lock levels (we are tracking the patches), and pgfence’s source will update the day they land in tablecmds.c.

If you spot a row that disagrees with the Postgres source, please open an issue. Verified corrections only, with a source link.

GitHub · Docs · npm

← All posts