PostgreSQL Deep Dive

PostgreSQL Deep Dive: DDL Locking — Why Your ALTER TABLE Just Blocked Every Query on the Table

You run ALTER TABLE orders ADD COLUMN status text DEFAULT 'pending' on your production database. The deployment script hangs. Your monitoring dashboard lights up red. Every query on the orders table is blocked. Application requests are piling up. What just happened?

The answer is ACCESS EXCLUSIVE lock. Most ALTER TABLE subcommands acquire this lock mode, which conflicts with every other lock mode in PostgreSQL, including plain SELECT. Your schema change just locked out the entire table from both reads and writes, and it will hold that lock until the transaction commits.

Understanding DDL locking is not optional for running PostgreSQL in production. Here is a comprehensive breakdown of what each operation locks, the concurrency-safe alternatives, and the patterns that keep your application online during schema changes.

The Lock Hierarchy Refresher

PostgreSQL has eight table-level lock modes, but for DDL operations only a handful matter in practice:

Lock ModeBlocksHeld By
ACCESS SHARENothing except ACCESS EXCLUSIVESELECT
ROW SHAREEXCLUSIVE, ACCESS EXCLUSIVESELECT FOR UPDATE/SHARE
ROW EXCLUSIVESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVEINSERT, UPDATE, DELETE
SHARE UPDATE EXCLUSIVESHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY
SHAREROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVECREATE INDEX (non-concurrent)
SHARE ROW EXCLUSIVEROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVECREATE TRIGGER, some ALTER TABLE forms
EXCLUSIVEROW SHARE, ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLY
ACCESS EXCLUSIVEEverythingDROP TABLE, TRUNCATE, VACUUM FULL, REINDEX, most ALTER TABLE

The key insight: only ACCESS EXCLUSIVE blocks SELECT. But every lock mode from SHARE UPDATE EXCLUSIVE upward blocks at least some write operations. When you run DDL, you need to know exactly which lock level you are acquiring and what it conflicts with.

ALTER TABLE: The Danger Zone

The PostgreSQL docs state this clearly: “An ACCESS EXCLUSIVE lock is acquired unless explicitly noted.” That is the default. Most ALTER TABLE subcommands take ACCESS EXCLUSIVE, meaning your table is completely unavailable for the duration of the operation.

Here is a breakdown by lock level, from most to least disruptive:

ACCESS EXCLUSIVE (blocks everything, including reads)

These are the operations that cause the outage scenario described at the top:

  • ADD COLUMN (with a volatile default or expression)
  • DROP COLUMN
  • ALTER COLUMN TYPE (data rewrite)
  • SET NOT NULL (scans the table to verify no NULLs exist)
  • CLUSTER ON
  • SET WITHOUT CLUSTER
  • SET TABLESPACE (rewrites all data to new tablespace)
  • SET ACCESS METHOD
  • SET LOGGED / SET UNLOGGED
  • DISABLE TRIGGER ALL
  • ENABLE TRIGGER ALL
  • DROP CONSTRAINT
  • OWNER TO
  • REPLICA IDENTITY

Most of these need ACCESS EXCLUSIVE because they physically modify the table structure or need to guarantee no other transaction is observing the data in an inconsistent state. That is sensible, but it makes them dangerous on live tables.

SHARE UPDATE EXCLUSIVE (allows reads and writes)

Only a few ALTER TABLE subcommands are lightweight enough to avoid ACCESS EXCLUSIVE:

  • SET STATISTICS (changes per-column statistics target)
  • SET (attribute_option = value) / RESET (attribute_option) (n_distinct overrides)
  • SET STORAGE (changes TOAST storage strategy)
  • SET COMPRESSION (changes compression method for future inserts)
  • DROP EXPRESSION
  • ENABLE TRIGGER [name] / DISABLE TRIGGER [name] (single trigger)
  • ENABLE REPLICA TRIGGER / ENABLE ALWAYS TRIGGER (single trigger)

These are safe to run on production tables because they only modify catalog entries, not the data itself.

Special Cases That Require Attention

ADD COLUMN with a non-volatile default

-- SAFE: acquires ACCESS EXCLUSIVE, but the lock is held for only milliseconds
ALTER TABLE orders ADD COLUMN status text DEFAULT 'pending';

Wait, didn’t I just say ADD COLUMN takes ACCESS EXCLUSIVE? Yes, it does, but there is an important optimization. Since PostgreSQL 11, adding a column with a non-volatile default does not rewrite the table. The default value is stored in the catalog (pg_attribute.attmissingval), and existing rows physically have no value for that column. When a query reads an old row, PostgreSQL returns the catalog default instead.

The ACCESS EXCLUSIVE lock is still acquired, but it is held only for the time it takes to update the catalog entry, which is essentially instant on most tables. No table scan, no rewrite, no sustained lock.

However, if the default is volatile (calls now(), uuid_generate_v4(), or any STABLE/VOLATILE function), PostgreSQL must rewrite the entire table to evaluate the default for every row:

-- DANGEROUS: table rewrite, ACCESS EXCLUSIVE for the full duration
ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now();

SET NOT NULL without a full table scan

SET NOT NULL normally scans the entire table to verify no NULL values exist. But if a valid CHECK constraint already proves the column has no NULLs, the scan is skipped:

-- Two-step pattern avoids full table scan on step 2
ALTER TABLE orders ADD CONSTRAINT orders_status_check CHECK (status IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_check;  -- SHARE UPDATE EXCLUSIVE
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;          -- Fast, no scan needed

The VALIDATE CONSTRAINT step runs with SHARE UPDATE EXCLUSIVE and only scans rows modified since the constraint was added. The final SET NOT NULL sees the valid check constraint and skips the scan entirely.

ADD COLUMN DEFAULT NULL

Adding a column with DEFAULT NULL (or no default at all) is safe. PostgreSQL stores the attribute as “missing” in the catalog and never rewrites existing rows.

CREATE INDEX: Standard vs Concurrent

Standard CREATE INDEX

CREATE INDEX idx_orders_status ON orders (status);

This acquires a SHARE lock on the table. SHARE conflicts with ROW EXCLUSIVE, so all writes (INSERT, UPDATE, DELETE) are blocked for the entire duration of the index build. Reads continue normally. On a large table, this could mean minutes of write unavailability.

CREATE INDEX CONCURRENTLY

CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);

This acquires a SHARE UPDATE EXCLUSIVE lock, which allows both reads and writes to continue. It is the production-safe way to add indexes.

The mechanics are more complex:

  1. First phase: The index is entered into the catalogs as “invalid.” PostgreSQL waits for all existing transactions that modified the table to finish.
  2. Second scan: A second scan catches any rows that were modified between the first scan and the completion of the first phase’s waiting period.
  3. Final phase: PostgreSQL waits for all transactions that could have a snapshot predating the second scan to finish. Then the index is marked valid.

The cost: CONCURRENTLY takes longer (two scans instead of one, plus waiting periods) and uses more total I/O. On a busy system, the extra CPU and I/O load from the concurrent index build can slow other operations. But it does not block writes.

Failure modes of CONCURRENTLY

If the concurrent build fails (deadlock, uniqueness violation in a unique index, manual cancellation), it leaves behind an invalid index. This invalid index is ignored by queries but still consumes space and blocks future concurrent builds of the same index. You must drop it manually:

-- Check for invalid indexes
SELECT indexrelid::regclass, indisvalid, indisready
FROM pg_index
WHERE NOT indisvalid;

-- Clean up
DROP INDEX IF EXISTS idx_orders_status;

REINDEX CONCURRENTLY (PG12+)

REINDEX INDEX CONCURRENTLY idx_orders_status;

Like CREATE INDEX CONCURRENTLY, this rebuilds an index without blocking writes. It takes SHARE UPDATE EXCLUSIVE. Available since PG12. Before that, REINDEX without CONCURRENTLY takes ACCESS EXCLUSIVE and is essentially a table-level outage.

ADD CONSTRAINT: The NOT VALID Trick

Adding a check constraint normally requires scanning the entire table:

-- ACCESS EXCLUSIVE + full table scan
ALTER TABLE orders ADD CONSTRAINT orders_amount_check CHECK (amount > 0);

The two-step approach using NOT VALID avoids the table scan during the DDL:

-- Step 1: Add constraint as NOT VALID (ACCESS EXCLUSIVE, but instant, no scan)
ALTER TABLE orders ADD CONSTRAINT orders_amount_check CHECK (amount > 0) NOT VALID;

-- Step 2: Validate concurrently (SHARE UPDATE EXCLUSIVE, allows reads/writes)
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_check;

Step 1 acquires ACCESS EXCLUSIVE but only for the time it takes to create the catalog entry. No table scan happens. Step 2 validates existing rows with a SHARE UPDATE EXCLUSIVE lock, which does not block reads or writes. The only gap is between step 1 and step 2: new rows must satisfy the constraint (it is enforced), but existing violating rows are tolerated until validation completes.

The same pattern works for foreign keys:

-- Step 1: Add FK as NOT VALID
ALTER TABLE order_items
ADD CONSTRAINT fk_order
FOREIGN KEY (order_id) REFERENCES orders(id) NOT VALID;

-- Step 2: Validate
ALTER TABLE order_items VALIDATE CONSTRAINT fk_order;

Note that NOT VALID on foreign keys means PostgreSQL will not check existing rows for referential integrity. Only new rows are checked. The validation step scans and verifies all existing rows.

TRUNCATE: The Silent ACCESS EXCLUSIVE

TRUNCATE is deceptively dangerous. It is not a DELETE with a WHERE clause. It acquires ACCESS EXCLUSIVE on the table, which means no other session can even read from the table while the truncation is happening. It also acquires ACCESS EXCLUSIVE on any child tables that inherit from the truncated table.

-- Blocks everything on orders and all child tables
TRUNCATE orders;

Unlike DELETE, TRUNCATE does not fire ON DELETE triggers (by default), does not scan individual rows, and does not generate WAL for each deleted row. It is fast, but the trade-off is the aggressive lock.

The Lock Acquisition Problem

Here is a scenario that catches people off guard. You have a long-running transaction:

Session 1: BEGIN; SELECT * FROM orders WHERE id = 1;  -- holds ACCESS SHARE
Session 2: ALTER TABLE orders ADD COLUMN priority text;  -- wants ACCESS EXCLUSIVE, blocks

Session 2 does not fail. It waits. It waits as long as it takes for Session 1 to finish. If Session 1 is an application connection that started a transaction and forgot to commit, Session 2 blocks indefinitely (unless you have lock_timeout set). Meanwhile, every subsequent query that tries to access the orders table also blocks behind Session 2, because once Session 1 releases its ACCESS SHARE, Session 2 immediately grabs ACCESS EXCLUSIVE and queues up everyone else.

This cascading blocking pattern is one of the most common causes of “my database is hung” alerts in production.

Mitigations

Set lock_timeout at the session level before running DDL:

SET lock_timeout = '5s';
ALTER TABLE orders ADD COLUMN priority text;

If the lock cannot be acquired within 5 seconds, the statement fails with an error instead of blocking indefinitely. You can retry or investigate what is holding the lock.

Check what is blocking your DDL:

SELECT blocked.pid     AS blocked_pid,
       blocked.query  AS blocked_query,
       blocking.pid   AS blocking_pid,
       blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks kl ON kl.locktype = bl.locktype
    AND kl.database IS NOT DISTINCT FROM bl.database
    AND kl.relation IS NOT DISTINCT FROM bl.relation
    AND kl.page IS NOT DISTINCT FROM bl.page
    AND kl.tuple IS NOT DISTINCT FROM bl.tuple
    AND kl.pid != bl.pid
JOIN pg_stat_activity blocking ON blocking.pid = kl.pid
WHERE blocked.wait_event IS NOT NULL;

Practical SQL: Monitoring DDL Locks

What locks are currently held on a table?

SELECT pid,
       locktype,
       mode,
       granted,
       query_start,
       state,
       query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE relation = 'orders'::regclass
ORDER BY granted, mode;

What DDL is waiting?

SELECT pid,
       state,
       wait_event_type,
       wait_event,
       query,
       now() - query_start AS wait_duration
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
  AND query ~* '(ALTER|CREATE|DROP|TRUNCATE|REINDEX|CLUSTER|VACUUM\s+FULL)';

Lock level reference for common DDL operations

-- Quick reference: what lock does this DDL take?
-- Run EXPLAIN (ANALYZE, COSTS OFF) on the DDL and check pg_locks in another session

-- Session 1: run DDL in a transaction without committing
-- Session 2: query pg_locks to see what was acquired

Key Takeaways

  • Most ALTER TABLE subcommands take ACCESS EXCLUSIVE, which blocks everything including SELECT. This is the default and the docs say so explicitly.
  • CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY take SHARE UPDATE EXCLUSIVE, allowing reads and writes. Always use these in production.
  • Adding a column with a non-volatile default is instant (no table rewrite) since PG11, despite taking ACCESS EXCLUSIVE. The lock is held only for catalog updates.
  • The ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT pattern lets you add check constraints and foreign keys without a long ACCESS EXCLUSIVE hold.
  • A blocked DDL statement waiting for a long-running transaction causes cascading blocking. Set lock_timeout before running DDL.
  • TRUNCATE takes ACCESS EXCLUSIVE. It is fast but blocks everything, including reads.

What’s Next

Yesterday we covered how CTE materialization acts as an optimization fence. Tomorrow, we look at lock queues: the difference between fast-path locks and regular locks in PostgreSQL’s internal lock manager, and why some lock acquisition is nearly free while other locks require traversing the shared lock table.


Previous: CTE Materialization: The Optimization Fence That Kills Your Query Performance