What PostgreSQL DDL really locks

Every row below was measured by pg-migration-guard on a live server, not copied from the documentation.

31rules
136 / 136claims hold
4servers measured
0differences between versions

Each statement ran on a 2,000-row table inside a transaction that was rolled back. After it ran, the tool read pg_locks for the lock held, compared filenodes for a rewrite, and read the sequential scan counter. A second connection with a 100 ms lock_timeout then tried a real read and a real write, so "blocks" means traffic was actually stopped.

Statements the linter flags

RuleStatementLock on the tableRewriteScanBlocks readsBlocks writes
PMG001CREATE INDEX demo_note_idx ON demo (note)ShareLocknoyesnoyes
PMG002DROP INDEX demo_score_idxAccessExclusiveLocknonoyesyes
PMG004ALTER TABLE demo ADD CONSTRAINT demo_parent_fk FOREIGN KEY (parent_id) REFERENCES parent (id)ShareRowExclusiveLocknoyesnoyes
PMG005ALTER TABLE demo ADD CONSTRAINT demo_score_ck CHECK (score >= 0)AccessExclusiveLocknoyesyesyes
PMG006ALTER TABLE demo ALTER COLUMN note SET NOT NULLAccessExclusiveLocknoyesyesyes
PMG007ALTER TABLE demo ADD CONSTRAINT demo_id_note_uq UNIQUE (id, note)AccessExclusiveLocknoyesyesyes
PMG008ALTER TABLE parent_nokey ADD PRIMARY KEY (id)AccessExclusiveLocknoyesyesyes
PMG009ALTER TABLE demo ADD COLUMN token uuid DEFAULT gen_random_uuid()AccessExclusiveLockyesyesyesyes
PMG010ALTER TABLE demo ADD COLUMN seq bigserialAccessExclusiveLockyesyesyesyes
PMG012ALTER TABLE demo ALTER COLUMN score TYPE bigintAccessExclusiveLockyesyesyesyes
PMG013CLUSTER demo USING demo_pkeyAccessExclusiveLockyesyesyesyes
PMG014TRUNCATE demo CASCADEAccessExclusiveLockyesn/ayesyes
PMG015REINDEX INDEX demo_score_idxShareLocknoyesyesyes
PMG016REFRESH MATERIALIZED VIEW demo_mvAccessExclusiveLockyesnoyesno
PMG017ALTER TABLE events DETACH PARTITION events_2025AccessExclusiveLocknonoyesyes
PMG020ALTER TABLE demo ADD CONSTRAINT demo_score_ck CHECK (score >= 0) NOT VALID; ALTER TABLE demo VALIDATE CONSTRAINT demo_score_ckAccessExclusiveLocknoyesyesyes
PMG030ALTER TABLE demo DROP COLUMN noteAccessExclusiveLocknonoyesyes
PMG031ALTER TABLE demo RENAME COLUMN note TO remarkAccessExclusiveLocknonoyesyes
PMG032ALTER TABLE demo RENAME TO demo_archiveAccessExclusiveLocknonoyesyes
PMG033DROP TABLE demo CASCADEAccessExclusiveLocknonoyesyes
PMG040UPDATE demo SET score = 0RowExclusiveLocknoyesnono

What the linter recommends instead

The fixes are measured the same way. NOT VALID followed by VALIDATE does the scan under a lock that lets traffic through, which is the point of the two-step pattern.

A safe statement can still say "blocks" here. What matters is how long: with no rewrite and no scan, it only changes the catalog and holds its lock for milliseconds, as long as it gets the lock quickly. That is why the linter also asks for a lock_timeout.

PatternStatementLock on the tableRewriteScanBlocks readsBlocks writes
safe add-column-constant-defaultALTER TABLE demo ADD COLUMN region text DEFAULT 'dhaka'AccessExclusiveLocknonoyesyes
safe add-column-now-defaultALTER TABLE demo ADD COLUMN seen_at timestamptz DEFAULT now()AccessExclusiveLocknonoyesyes
safe add-foreign-key-not-validALTER TABLE demo ADD CONSTRAINT demo_parent_fk FOREIGN KEY (parent_id) REFERENCES parent (id) NOT VALIDShareRowExclusiveLocknononoyes
safe validate-foreign-keyALTER TABLE demo VALIDATE CONSTRAINT demo_parent_fkShareUpdateExclusiveLocknoyesnono
safe add-check-not-validALTER TABLE demo ADD CONSTRAINT demo_score_ck CHECK (score >= 0) NOT VALIDAccessExclusiveLocknonoyesyes
safe validate-checkALTER TABLE demo VALIDATE CONSTRAINT demo_score_ckShareUpdateExclusiveLocknoyesnono
safe set-not-null-after-validated-checkALTER TABLE demo ALTER COLUMN score SET NOT NULLAccessExclusiveLocknonoyesyes
safe unique-using-existing-indexALTER TABLE demo ADD CONSTRAINT demo_score_uq UNIQUE USING INDEX demo_score_uq_idxAccessExclusiveLocknonoyesyes
safe widen-varcharALTER TABLE demo ALTER COLUMN label TYPE varchar(40)AccessExclusiveLocknonoyesyes
safe varchar-to-textALTER TABLE demo ALTER COLUMN label TYPE textAccessExclusiveLocknonoyesyes

Statements that do not run at all

RuleStatementSQLSTATE
PMG003 concurrent-in-transactionCREATE INDEX CONCURRENTLY demo_note_idx ON demo (note)25001
PMG011 add-column-not-null-no-defaultALTER TABLE demo ADD COLUMN region text NOT NULL23502
PMG022 enum-value-in-transactionALTER TYPE demo_status ADD VALUE 'held'; SELECT 'held'::demo_status55P04

One migration, probed

The same change to a fictional courier's shipment table, written two ways, on PostgreSQL 17. Locks pile up in the single transaction on the left, and the final UPDATE runs with the table fully blocked. On the right each statement commits by itself.

Risky: pgmg check

0007_shipment_tracking.sql:4: error PMG009 add-column-volatile-default
    default gen_random_uuid() on shipment.public_id rewrites the table if it is volatile (ignore this if the function is immutable or stable)
0007_shipment_tracking.sql:4: warning PMG019 missing-lock-timeout
    no lock_timeout set before locking shipment
0007_shipment_tracking.sql:6: error PMG004 foreign-key-validates-now
    shipment_hub_fk checks every row of shipment while blocking writes
0007_shipment_tracking.sql:6: warning PMG021 many-tables-locked-in-transaction
    one transaction holds strong locks on hub, shipment
0007_shipment_tracking.sql:9: error PMG006 set-not-null-scans
    SET NOT NULL on shipment.tracking_code scans the table under ACCESS EXCLUSIVE
0007_shipment_tracking.sql:11: error PMG001 add-index-not-concurrent
    index build on shipment blocks writes until done
0007_shipment_tracking.sql:13: error PMG041 backfill-under-lock
    this data change runs while the transaction blocks hub, shipment
5 errors, 2 warnings

Risky: pgmg probe

line 2: BEGIN
  skipped: the probe holds its own transaction
line 4: ALTER TABLE shipment ADD COLUMN public_id uuid DEFAULT gen_random_uuid()
  holds AccessExclusiveLock on shipment
  holds AccessExclusiveLock on shipment_pkey
  rewrote shipment
  scanned shipment
  reads blocked on shipment
  writes blocked on shipment
line 6: ALTER TABLE shipment ADD CONSTRAINT shipment_hub_fk FOREIGN KEY (hub_id) REFERENCES hub 
  holds ShareRowExclusiveLock on hub
  holds AccessShareLock on hub_pkey
  holds AccessExclusiveLock on shipment
  holds AccessExclusiveLock on shipment_pkey
  scanned hub, shipment
  reads blocked on shipment
  writes blocked on hub, shipment
line 9: ALTER TABLE shipment ALTER COLUMN tracking_code SET NOT NULL
  holds ShareRowExclusiveLock on hub
  holds AccessShareLock on hub_pkey
  holds AccessExclusiveLock on shipment
  holds AccessExclusiveLock on shipment_pkey
  scanned shipment
  reads blocked on shipment
  writes blocked on hub, shipment
line 11: CREATE UNIQUE INDEX shipment_tracking_code_key ON shipment (tracking_code)
  holds ShareRowExclusiveLock on hub
  holds AccessShareLock on hub_pkey
  holds AccessExclusiveLock on shipment
  holds AccessExclusiveLock on shipment_pkey
  holds AccessExclusiveLock on shipment_tracking_code_key
  scanned shipment
  reads blocked on shipment
  writes blocked on hub, shipment
line 13: UPDATE shipment SET status = 'pending' WHERE status = 'booked'
  holds ShareRowExclusiveLock on hub
  holds RowShareLock on hub_pkey
  holds AccessExclusiveLock on shipment
  holds AccessExclusiveLock on shipment_pkey
  holds AccessExclusiveLock on shipment_tracking_code_key
  scanned shipment
  reads blocked on shipment
  writes blocked on hub, shipment
line 15: COMMIT
  skipped: the probe holds its own transaction
rolled back: nothing was changed

Safe: the fix

-- The same change, one short lock at a time.
SET lock_timeout = '2s';

ALTER TABLE shipment ADD COLUMN public_id uuid;
ALTER TABLE shipment ALTER COLUMN public_id SET DEFAULT gen_random_uuid();
ALTER TABLE shipment ADD CONSTRAINT shipment_hub_fk
    FOREIGN KEY (hub_id) REFERENCES hub (id) NOT VALID;
ALTER TABLE shipment ADD CONSTRAINT shipment_tracking_code_nn
    CHECK (tracking_code IS NOT NULL) NOT VALID;

Safe: pgmg probe

line 2: SET lock_timeout = '2s'
line 4: ALTER TABLE shipment ADD COLUMN public_id uuid
  holds AccessExclusiveLock on shipment
  reads blocked on shipment
  writes blocked on shipment
line 5: ALTER TABLE shipment ALTER COLUMN public_id SET DEFAULT gen_random_uuid()
  holds AccessExclusiveLock on shipment
  reads blocked on shipment
  writes blocked on shipment
line 6: ALTER TABLE shipment ADD CONSTRAINT shipment_hub_fk FOREIGN KEY (hub_id) REFERENCES hub 
  holds ShareRowExclusiveLock on hub
  holds AccessShareLock on hub_pkey
  holds AccessExclusiveLock on shipment
  reads blocked on shipment
  writes blocked on hub, shipment
line 8: ALTER TABLE shipment ADD CONSTRAINT shipment_tracking_code_nn CHECK (tracking_code IS NO
  holds ShareRowExclusiveLock on hub
  holds AccessShareLock on hub_pkey
  holds AccessExclusiveLock on shipment
  reads blocked on shipment
  writes blocked on hub, shipment
rolled back: nothing was changed

All rules

PMG001 error CREATE INDEX without CONCURRENTLY

Takes SHARE on the table for the whole build. Reads go on; every INSERT, UPDATE and DELETE waits until the index is finished.

Fix: Use CREATE INDEX CONCURRENTLY, outside a transaction.

PMG002 warning DROP INDEX without CONCURRENTLY

Takes ACCESS EXCLUSIVE on the index's table. The drop is quick, but it has to wait for every open query on the table, and everything behind it in the lock queue waits with it.

Fix: Use DROP INDEX CONCURRENTLY, outside a transaction.

PMG003 error CONCURRENTLY inside a transaction

CONCURRENTLY commits internally between its phases, so PostgreSQL refuses it inside a transaction block. The migration fails at deploy time.

Fix: Run the statement in its own migration with the tool's transaction wrapper switched off.

PMG004 error Foreign key added without NOT VALID

Checks every row while holding SHARE ROW EXCLUSIVE on both tables, so writes to the child and the parent stop for the length of the scan.

Fix: Add the constraint NOT VALID, then VALIDATE CONSTRAINT in a separate transaction.

PMG005 error CHECK constraint added without NOT VALID

Scans the whole table under ACCESS EXCLUSIVE, so reads and writes both stop until it ends.

Fix: Add the constraint NOT VALID, then VALIDATE CONSTRAINT in a separate transaction.

PMG006 error SET NOT NULL with no validated CHECK behind it

Scans the table under ACCESS EXCLUSIVE to prove there are no NULLs. If a validated CHECK (col IS NOT NULL) already exists, PostgreSQL uses it and skips the scan.

Fix: Add CHECK (col IS NOT NULL) NOT VALID, validate it in a separate transaction, then SET NOT NULL and drop the CHECK. On PostgreSQL 18, ADD CONSTRAINT ... NOT NULL col NOT VALID does the same directly.

PMG007 error UNIQUE constraint without USING INDEX

Builds the index inside ALTER TABLE, under ACCESS EXCLUSIVE, for as long as the build takes.

Fix: CREATE UNIQUE INDEX CONCURRENTLY first, then ADD CONSTRAINT ... UNIQUE USING INDEX.

PMG008 error PRIMARY KEY without USING INDEX

Builds the index and proves NOT NULL inside ALTER TABLE, under ACCESS EXCLUSIVE.

Fix: CREATE UNIQUE INDEX CONCURRENTLY first, then ADD CONSTRAINT ... PRIMARY KEY USING INDEX.

PMG009 error New column whose default is evaluated per row

A constant default is stored once in the catalog. A volatile one, such as gen_random_uuid(), has to be written to every row, so the table is rewritten under ACCESS EXCLUSIVE.

Fix: Add the column with no default, set the default in a second statement, backfill in batches.

PMG010 error New serial, identity or stored generated column

Each of these needs a value in every existing row, so the table is rewritten under ACCESS EXCLUSIVE.

Fix: Add a plain column, attach a sequence as the default, and backfill in batches.

PMG011 error NOT NULL column with no default

Existing rows have no value for the column, so the statement fails on any non-empty table.

Fix: Add it nullable or with a constant default, backfill, then enforce NOT NULL.

PMG012 error ALTER COLUMN TYPE

Most type changes rewrite the table and rebuild its indexes under ACCESS EXCLUSIVE. The exceptions are binary-compatible ones, such as varchar to text or a longer varchar.

Fix: Add a new column, write to both, backfill in batches, switch readers, drop the old one.

PMG013 error VACUUM FULL or CLUSTER

Both copy the whole table under ACCESS EXCLUSIVE. Neither belongs in a deploy.

Fix: Schedule pg_repack or pg_squeeze, or a maintenance window, outside the migration.

PMG014 error TRUNCATE

Removes every row and takes ACCESS EXCLUSIVE. Unlike DELETE it also fires no row triggers.

Fix: If the data really is disposable, say why in a pgmg:ignore comment.

PMG015 error REINDEX without CONCURRENTLY

Blocks writes to the table, and blocks reads that would have used the index, for the whole rebuild.

Fix: Use REINDEX ... CONCURRENTLY, outside a transaction.

PMG016 error REFRESH MATERIALIZED VIEW without CONCURRENTLY

Replaces the view's contents under ACCESS EXCLUSIVE, so nothing can read it during the refresh.

Fix: Add a unique index on the view and use REFRESH MATERIALIZED VIEW CONCURRENTLY.

PMG017 warning DETACH PARTITION without CONCURRENTLY

Takes ACCESS EXCLUSIVE on the partitioned table, which stops all traffic to every partition while it waits for and holds the lock.

Fix: Use DETACH PARTITION ... CONCURRENTLY (PostgreSQL 14 and later), outside a transaction.

PMG019 warning Blocking DDL with no lock_timeout

DDL that has to wait for a long query sits at the head of the lock queue, and every later query on the table waits behind it. A short lock_timeout turns an outage into a failed, retryable migration.

Fix: Start the migration with SET lock_timeout = '2s'; and retry on failure.

PMG020 error VALIDATE in the same transaction as NOT VALID

The strong lock from ADD CONSTRAINT is kept until commit, so the validation scan runs under it, and the two-step pattern gains nothing.

Fix: Commit between the two statements, or put VALIDATE in the next migration.

PMG021 warning Strong locks on several tables in one transaction

Locks are held until commit. The more tables a transaction holds, the wider the outage and the likelier a deadlock against application transactions that touch them in another order.

Fix: One table per transaction. Split the migration.

PMG022 warning ALTER TYPE ADD VALUE inside a transaction

The new value cannot be used until the transaction commits. A later statement in the same migration that uses it fails.

Fix: Add the value in its own migration, and use it in the next one.

PMG030 error DROP COLUMN

Old application code still selects and inserts the column until the deploy finishes. The catalog change itself is quick and does not rewrite the table.

Fix: Remove every use from the code, deploy, then drop the column in a later migration.

PMG031 error RENAME COLUMN

The old name is gone the moment this commits, while the old code is still running.

Fix: Add the new column, write to both, backfill, switch readers, drop the old one.

PMG032 error RENAME TABLE

The old name is gone the moment this commits, while the old code is still running.

Fix: Rename, and keep a view under the old name until the old code is gone.

PMG033 error DROP TABLE

Destroys the data and breaks anything still reading the table, and only a backup restore brings it back.

Fix: Stop using the table, deploy, keep it for a while, then drop it with a recorded reason.

PMG040 warning UPDATE or DELETE with no WHERE

Touches every row in one transaction: row locks on the whole table, a burst of WAL for replicas to replay, and dead tuples for vacuum to clean up.

Fix: Backfill in batches of a few thousand rows, each in its own transaction.

PMG041 error Data change in a transaction that holds a strong lock

Every lock taken by earlier DDL is held until commit, so the table stays blocked for as long as the backfill runs.

Fix: Commit the DDL first and run the backfill separately.

PMG050 warning Primary key narrower than bigint

An int key runs out at 2.1 billion, and widening it later is a table rewrite on what is by then the largest table in the system.

Fix: Use bigint GENERATED ALWAYS AS IDENTITY, or uuid.

PMG051 warning timestamp without time zone

Stores a wall-clock reading with no zone. It goes wrong the first time a server, a client or a report runs in a different zone.

Fix: Use timestamptz, which stores an instant.

PMG052 warning json column

json keeps the raw text and re-parses it on every access. It has no equality operator and no GIN indexing, so GROUP BY, DISTINCT and containment queries on it fail or crawl.

Fix: Use jsonb, unless key order and exact whitespace have to survive.

PMG053 info char(n) column

char(n) pads with spaces, which changes comparisons and sizes, and is no faster than text.

Fix: Use text with a CHECK on length if the length matters.

pg-migration-guard 0.1.0. MIT licence. Measured on PostgreSQL 14.24, 16.14, 17.11, 18.6.