Which ALTER TABLE statements lock a PostgreSQL table?
Quick answer: Most
ALTER TABLEforms take anACCESS EXCLUSIVElock (a handful take weaker ones — notablyADD FOREIGN KEY, which needs onlySHARE ROW EXCLUSIVE), but most operations are metadata-only and release the lock in milliseconds. The dangerous ones rewrite the whole table — some type changes, and adding a column with a volatile default — holding that lock for the entire rewrite. The other trap is the lock queue: a blockedALTERmakes every query behind it wait too. Guard migrations with a shortlock_timeoutand retry.
The exact behavior of several of these operations changed across PostgreSQL versions. The notes below call out the version; always confirm against the major version you actually run.
The lock most ALTER TABLE statements take
Most ALTER TABLE subcommands acquire an ACCESS EXCLUSIVE lock — the strongest
lock, which blocks reads and writes on the table. What differs is how long
it's held:
- Metadata-only changes update the catalog and release the lock almost immediately.
- Table-rewrite changes hold that lock while every row is rewritten — which on a large table can mean minutes of total unavailability.
Metadata-only (fast — catalog change, no rewrite)
| Operation | Notes |
|---|---|
ADD COLUMN with no default | Always metadata-only (all versions) — NULLs are supplied on readout |
ADD COLUMN with a constant default | Metadata-only since PostgreSQL 11 (the default is stored in the catalog) |
DROP COLUMN | Logical — the column is marked dropped; space is reclaimed later |
RENAME COLUMN / RENAME (table) | Catalog only |
ALTER COLUMN SET DEFAULT / DROP DEFAULT | Catalog only |
ADD CONSTRAINT ... CHECK (...) NOT VALID | Skips the validating scan; validate later (see below) |
Widening varchar(n) → larger varchar or text | Binary-coercible — no rewrite |
Table-rewrite or full scan (slow — holds ACCESS EXCLUSIVE for the whole operation)
| Operation | Notes |
|---|---|
ALTER COLUMN ... TYPE requiring conversion (e.g. integer → bigint) | Rewrites every row |
ADD COLUMN with a volatile default (e.g. DEFAULT random()) | Rewrites every row |
SET NOT NULL | Full-table scan (not a heap rewrite) under ACCESS EXCLUSIVE. PostgreSQL 12+ can skip the scan entirely — an instant metadata change — if a validated CHECK (col IS NOT NULL) constraint already exists |
The safer patterns
Validate constraints in two steps. Add the constraint NOT VALID (a quick
metadata change), then validate it separately — VALIDATE CONSTRAINT takes a
weaker SHARE UPDATE EXCLUSIVE lock that allows reads and writes:
ALTER TABLE orders ADD CONSTRAINT orders_total_positive
CHECK (total >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_positive;
Not everything takes ACCESS EXCLUSIVE
A few forms take weaker locks, which is worth knowing before you schedule a maintenance window you don't need:
| Operation | Lock | Effect |
|---|---|---|
ADD FOREIGN KEY | SHARE ROW EXCLUSIVE — on both the referencing and referenced tables | Blocks writes, allows reads |
VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | Allows reads and writes |
SET STATISTICS, CLUSTER ON, SET (attribute options) | SHARE UPDATE EXCLUSIVE | Allows reads and writes |
Adding a foreign key still scans the referencing table to verify existing rows,
so on a big table use NOT VALID first and VALIDATE CONSTRAINT after — that
moves the scan under the weakest lock of the three.
Always set a lock_timeout for migrations. This is the single most important habit. Without it, a migration blocked behind a long query will itself block all new traffic:
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN notes text; -- fails fast if it can't get the lock
Wrap it in a retry loop in your migration tooling: on lock_not_available, wait
and try again, so a transient long query doesn't turn into an outage.
Why does AI-generated migration code get this wrong?
Your AI coding agent (Claude Code, Cursor) writes the ALTER TABLE that matches
the request — "add a status column with a default", "change id to bigint" —
and it's syntactically perfect. What it can't see is that orders has 200
million rows, that id → bigint triggers a full rewrite, or that a reporting
query holds a lock right now. Whether a statement is metadata-only or a
multi-minute table rewrite depends on your data volume and your live workload,
neither of which is in the source it reasons over.
How do I ship schema changes safely?
- Know which category each statement falls in before running it in production.
- Prefer
NOT VALID+VALIDATEfor constraints and foreign keys. - Set a short
lock_timeoutand retry on every migration. - For a rewriting type change on a big table, consider the add-new-column /
backfill / swap approach instead of an in-place
ALTER COLUMN TYPE.
How DBGorilla helps
DBGorilla connects read-only and gives your AI coding agent (Claude Code, Cursor)
the real context around a migration — the table's row count, its size, and what's
currently holding locks — so it can warn that an ALTER will rewrite a huge
table or block behind live traffic before you run it. It surfaces and explains;
it does not execute your migrations. Get started free →
FAQ
Does adding a column with a default rewrite the table?
Since PostgreSQL 11, a constant (non-volatile) default is metadata-only. A
volatile default (e.g. random()) still rewrites every row. On PostgreSQL 10 and
earlier, any default rewrote the table.
Why did a quick ALTER TABLE freeze my whole application?
ALTER TABLE needs an ACCESS EXCLUSIVE lock. If a long query holds a weaker
lock, the ALTER waits — and every new query queues behind its pending lock, so
one slow query plus one migration stalls the table.
How do I make a migration fail fast instead of blocking?
SET lock_timeout (e.g. '3s') before the ALTER, so it errors instead of
queuing when it can't get the lock, and retry in a loop.
Does changing a column type always rewrite the table?
No. Binary-coercible changes (widening varchar, or to text) are
metadata-only. Conversions like integer → bigint rewrite the whole table.