Skip to main content
ZELSIS

Postgres migrations that lock your tables

The migration ran in two seconds on staging. In production the orders table has a few million rows, the migration holds a lock while it works, and every checkout waits behind it. Nothing errors; the site just stops answering until the deploy finishes. These are the statements that cause it and the versions that do not.

1. CREATE INDEX blocks writes

A plain CREATE INDEX blocks inserts, updates and deletes on the table until the index is built. On a large table that can take minutes.

Writes to orders wait until this finishes
create index orders_customer_id_idx on orders (customer_id);
Builds while writes continue
create index concurrently orders_customer_id_idx on orders (customer_id);

CONCURRENTLY cannot run inside a transaction, so most migration tools need it in a migration of its own with the transaction turned off. If it fails halfway it leaves an invalid index; drop it and run it again.

2. SET NOT NULL scans the whole table under the strictest lock

ALTER TABLE ... ALTER COLUMN ... SET NOT NULL takes an ACCESS EXCLUSIVE lock, which blocks reads too, and keeps it while Postgres checks every row. On Postgres 12 and later you can do the check first without blocking anyone.

Reads and writes stop while every row is checked
alter table orders alter column shop_id set not null;
Check without blocking, then the lock is only held for a moment
alter table orders add constraint orders_shop_id_not_null check (shop_id is not null) not valid;
alter table orders validate constraint orders_shop_id_not_null;  -- scans, but allows reads and writes
alter table orders alter column shop_id set not null;             -- uses the valid constraint, no scan
alter table orders drop constraint orders_shop_id_not_null;

Adding a new column with a constant default (add column status text not null default 'new') is fast on Postgres 11 and later because existing rows are not rewritten. A volatile default such as now() or gen_random_uuid() still rewrites the table.

3. The lock queue: a quick ALTER can still freeze the site

Even an ALTER that needs its lock for a millisecond has to wait for running queries to finish. While it waits, every new query on that table queues behind it. One slow report query plus one migration is enough to stop checkout. Set a lock timeout so the migration gives up and retries instead of holding everyone up.

At the top of each migration
set lock_timeout = '3s';
set statement_timeout = '15min';

Test it before you ship

Test it: Restore a production-sized copy (or generate a few million rows) and run the migration while a script inserts rows in a loop. If the inserts pause, the migration locks the table.
Test it: During the run, check select pid, mode, granted, query from pg_locks join pg_stat_activity using (pid) where relation = 'orders'::regclass; to see which lock the migration holds and who is waiting.

How Zelsis catches this

Zelsis reads the migrations in each pull request and flags non-concurrent indexes and NOT NULL changes on existing tables, with the safe version to paste in.

  • DB-PERF-51Migration takes a blocking table lock on an existing table

Check your own repo

Public repositories scan free. Paste a GitHub URL on the dashboard and every finding comes with the file, the line and a fix.

Scan a repository free