Table-level locks in PostgreSQL: which DDL blocks even SELECT on a live table Intermediate
Estimated reading time: 9 min read
Read first
Problem
Section titled “Problem”A shop has an orders table with 100,000 rows, receiving continuous INSERTs and UPDATEs from checkout. The team needs to add a payment_status column, an index on status, and a total_cents > 0 constraint.
A row-level lock only locks the rows being written. DDL locks the whole table, in one of eight modes. Which mode does each DDL take, whom does it block, and how do you rewrite it to lock less?
What to choose (summary first)
Section titled “What to choose (summary first)”Situation on orders |
Choose | Condition |
|---|---|---|
| Add a column | ADD COLUMN nullable or with a constant DEFAULT |
Metadata only; set lock_timeout |
| Add an index | CREATE INDEX CONCURRENTLY |
Outside a transaction block; check for an INVALID index |
| Add a CHECK or foreign key | ADD CONSTRAINT ... NOT VALID then VALIDATE CONSTRAINT |
Two statements, two transactions |
| Change a column type that rewrites | New column + batched backfill (expand–contract) | Avoid copying the table under a strong lock |
Core concepts
Section titled “Core concepts”A table-level lock is a lock on a relation (table, index), shown in pg_locks with locktype = 'relation'. Each statement takes the mode it needs automatically and holds it until the end of the transaction. Names containing “ROW” are still table-level locks.
Eight modes and typical statements
Section titled “Eight modes and typical statements”In pg_locks.mode the names are written in CamelCase: AccessShareLock, RowExclusiveLock…
| # | Mode | Typical statements |
|---|---|---|
| 1 | ACCESS SHARE |
SELECT |
| 2 | ROW SHARE |
SELECT ... FOR UPDATE/SHARE |
| 3 | ROW EXCLUSIVE |
INSERT, UPDATE, DELETE, MERGE |
| 4 | SHARE UPDATE EXCLUSIVE |
VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY, VALIDATE CONSTRAINT |
| 5 | SHARE |
CREATE INDEX |
| 6 | SHARE ROW EXCLUSIVE |
CREATE TRIGGER, ADD FOREIGN KEY |
| 7 | EXCLUSIVE |
REFRESH MATERIALIZED VIEW CONCURRENTLY |
| 8 | ACCESS EXCLUSIVE |
Most ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL, CLUSTER, LOCK TABLE |
Conflict matrix
Section titled “Conflict matrix”X = two different transactions cannot hold both. Bold cells are the usual sources of migration incidents.
| Held \ Requested | ACC. SHARE | ROW SHARE | ROW EXCL. | SHARE UPD. EXCL. | SHARE | SHARE ROW EXCL. | EXCL. | ACC. EXCL. |
|---|---|---|---|---|---|---|---|---|
| ACC. SHARE | X | |||||||
| ROW SHARE | X | X | ||||||
| ROW EXCL. | X | X | X | X | ||||
| SHARE UPD. EXCL. | X | X | X | X | X | |||
| SHARE | X | X | X | X | X | |||
| SHARE ROW EXCL. | X | X | X | X | X | X | ||
| EXCL. | X | X | X | X | X | X | X | |
| ACC. EXCL. | X | X | X | X | X | X | X | X |
Three takeaways:
- Only
ACCESS EXCLUSIVEblocks a plainSELECT: DDL taking this mode stops both reads and writes. SHARE(plainCREATE INDEX) allows reads but blocks every write.SHARE UPDATE EXCLUSIVEblocks neither reads nor writes but conflicts with itself (VACUUMwaits forCREATE INDEX CONCURRENTLY).
How it works
Section titled “How it works”A migration runs CREATE INDEX then ALTER TABLE on orders while the API reads and writes:
The migration runs CREATE INDEX inside a transaction and holds SHARE on orders.
- The migration holds
SHARE:SELECT(ACCESS SHARE) runs,INSERT(ROW EXCLUSIVE) waits. - The migration commits and releases
SHARE; theINSERTruns. - The migration holds
ACCESS EXCLUSIVE: bothSELECTandINSERTwait. - The migration commits; both requests continue.
The blocking time equals the time the lock is held plus the time spent waiting to acquire it. If the DDL has to wait for a long transaction, queries arriving later queue behind it: see lock queue.
Operational details
Section titled “Operational details”Common DDL → lock mode
Section titled “Common DDL → lock mode”ALTER TABLE takes ACCESS EXCLUSIVE by default, except for subforms noted otherwise. Checked against the PostgreSQL 17 docs and the demo:
Statement on orders |
Lock on the table | Time-consuming part |
|---|---|---|
ADD COLUMN nullable / non-volatile DEFAULT |
ACCESS EXCLUSIVE |
None; metadata only |
ADD COLUMN ... DEFAULT gen_random_uuid() |
ACCESS EXCLUSIVE |
Rewrites the table and indexes |
ALTER COLUMN TYPE bigint → numeric |
ACCESS EXCLUSIVE |
Rewrite (varchar(50) → varchar(100) does not) |
SET NOT NULL |
ACCESS EXCLUSIVE |
Scan, unless a valid CHECK exists |
ADD CONSTRAINT CHECK |
ACCESS EXCLUSIVE |
Scan; NOT VALID skips the scan |
ADD FOREIGN KEY |
SHARE ROW EXCLUSIVE (both tables) |
Scan; NOT VALID skips the scan |
VALIDATE CONSTRAINT |
SHARE UPDATE EXCLUSIVE |
Scan, does not block reads or writes |
CREATE INDEX |
SHARE |
Builds the index, blocks writes |
CREATE INDEX CONCURRENTLY |
SHARE UPDATE EXCLUSIVE |
Two scans, waits for old transactions |
REINDEX TABLE |
SHARE, plus ACCESS EXCLUSIVE on each index |
Rebuilds indexes |
DROP TABLE, TRUNCATE, DROP COLUMN, RENAME |
ACCESS EXCLUSIVE |
Short |
VACUUM FULL, CLUSTER |
ACCESS EXCLUSIVE |
Copies the entire table |
An ALTER TABLE with several subcommands takes the strongest lock among them.
Turning heavy DDL into light DDL
Section titled “Turning heavy DDL into light DDL”Loading diagram…
Principle: ACCESS EXCLUSIVE should only be held while changing metadata. Scans, index builds, and data copies move to SHARE UPDATE EXCLUSIVE statements or to batched code.
Real-world example
Section titled “Real-world example”Demo: seeing lock modes through pg_locks
Section titled “Demo: seeing lock modes through pg_locks”PostgreSQL 17.11, schema demo_table_level_locks. Each DDL runs in an open transaction, reads the session’s own locks, then ROLLBACKs.
CREATE VIEW my_locks AS SELECT c.relname, l.mode, l.granted FROM pg_locks l JOIN pg_class c ON c.oid = l.relation WHERE l.pid = pg_backend_pid() AND l.locktype = 'relation' AND c.relname IN ('orders', 'orders_pkey', 'customers');
BEGIN;ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers(id) NOT VALID;SELECT * FROM my_locks;ROLLBACK; relname | mode | granted-----------+-----------------------+--------- orders | AccessShareLock | t orders | ShareRowExclusiveLock | t customers | AccessShareLock | t customers | ShareRowExclusiveLock | tSummary of the other runs (locks on orders; rewritten compares pg_relation_filenode before and after):
ADD COLUMN ... DEFAULT gen_random_uuid() AccessExclusiveLock, rewritten = tADD COLUMN ... NOT NULL DEFAULT 'web' rewritten = fALTER COLUMN total_cents TYPE numeric AccessExclusiveLock, rewritten = tVALIDATE CONSTRAINT ShareUpdateExclusiveLockCREATE INDEX ShareLockREINDEX TABLE ShareLock; orders_pkey: AccessExclusiveLockCREATE INDEX CONCURRENTLY observed from another session while the writing transaction long_tx is still open:
app | mode | granted | wait_type | wait_event---------+--------------------------+---------+-----------+------------ cic | ShareUpdateExclusiveLock | t | Lock | virtualxid long_tx | RowExclusiveLock | t | Timeout | PgSleepThe statement already has its lock but waits for long_tx to finish; new INSERTs still complete. The diagram’s scenario:
app | mode | granted | blocked_by_pid-----------+---------------------+---------+---------------- api_read | AccessShareLock | f | {16832} api_write | RowExclusiveLock | f | {16832} migration | AccessExclusiveLock | t | {}The pid changes on every run.
Trade-offs
Section titled “Trade-offs”Pros
-
CONCURRENTLYandNOT VALIDkeep the table serving reads and writes while the heavy part runs. -
Each small step can be retried on its own under
lock_timeout.
< slot="cons"> >
-
The migration has many steps and is no longer atomic.
-
CONCURRENTLYis slower and can leave anINVALIDindex behind.
Cons
Production checklist
Section titled “Production checklist”- Note the lock mode of each statement when reviewing a migration;
ACCESS EXCLUSIVEstatements must neither rewrite nor scan. SET lock_timeoutfor the migration session, and retry with backoff.- Do not bundle DDL into one transaction; turn off the tool’s automatic transaction for
CONCURRENTLY. - After
CREATE INDEX CONCURRENTLY, checkpg_index.indisvalid. - Add CHECKs and foreign keys with
NOT VALID; validate in a separate step. - Before running, check
pg_stat_activityfor long transactions on the table.
Common mistakes
Section titled “Common mistakes”A short DDL still holds its lock for long if the transaction is long:
Bundling every change into one transaction
Common
BEGIN;ALTER TABLE orders ADD COLUMN payment_status text;CREATE INDEX orders_status_idx ON orders (status);ALTER TABLE orders ADD CONSTRAINT total_positive CHECK (total_cents > 0);COMMIT;Prefer
SET lock_timeout = '3s';ALTER TABLE orders ADD COLUMN payment_status text;CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status);ALTER TABLE orders ADD CONSTRAINT total_positive CHECK (total_cents > 0) NOT VALID;ALTER TABLE orders VALIDATE CONSTRAINT total_positive;Why
The first version holds ACCESS EXCLUSIVE from the first statement until COMMIT. The second runs in autocommit: each statement releases its lock when done.
Another misunderstanding:
| Misconception | Consequence | What to do |
|---|---|---|
ADD COLUMN is fast so it needs no lock_timeout |
The DDL waits for a long transaction and later queries queue up | Always set lock_timeout |
NOT VALID means skipping the check |
Old violating data goes undetected | Run VALIDATE |
Self-check
Section titled “Self-check”- Why does a plain
CREATE INDEXletSELECTrun but blockINSERT, whileADD COLUMNblocks both? DEFAULT 'web'andDEFAULT gen_random_uuid()both takeACCESS EXCLUSIVE. Why do their lock hold times differ?CREATE INDEX CONCURRENTLYis alreadygranted = tbut not finished. What is it waiting for, and are newINSERTs blocked?