OnCallReady

Lesson 25.38 · PostgreSQL Operations · 26 min read

Safe migrations on a live database

In plain words

Imagine a one-lane bridge with traffic lights. A lorry needs the whole bridge to itself for two seconds to turn around. It waits at the entrance for a slow tractor to finish crossing - and every car behind the lorry waits too, even though they could have shared the bridge with the tractor.

A migration that changes a table is that lorry. The fix is to tell the lorry: "if you cannot get the bridge within three seconds, back off and try again later".

Why this lesson exists

"It's a two-second migration, it just adds a column" - and the site is down for ten minutes. Schema changes are the most common way an application team takes its own database down, and it is almost never the change itself that is slow: it is the lock it needs, and the queue of every other query that forms behind it. As the SRE you review migrations, you get paged when one hangs, and you are expected to know which changes are safe on a live table and how to make the others safe.

What you need to know already: lock modes, ACCESS EXCLUSIVE, blocking chains and pg_blocking_pids (lesson 6), indexes and EXPLAIN (lesson 8), lock_timeout (lesson 6).

The words you need first

The lock queue, live

A reporting session runs a long transaction that has read orders (it holds ACCESS SHARE - harmless on its own). Then a deploy runs a migration:

$ sudo -u postgres psql -d orders -c "alter table orders add column gift_note text" &
[1] 17416
$ sudo -u postgres psql -c "select pid, application_name, state, wait_event_type, pg_blocking_pids(pid) as blocked_by, left(query, 45) as query from pg_stat_activity where datname = 'orders' order by backend_start"
  pid  | application_name |        state        | wait_event_type | blocked_by |                     query
-------+------------------+---------------------+-----------------+------------+-----------------------------------------------
 17413 | weekly-report    | idle in transaction | Client          | {}         | SELECT count(*) FROM orders
 17418 | psql             | active              | Lock            | {17413}    | alter table orders add column gift_note text
 17421 | psql             | active              | Lock            | {17418}    | select id, status from orders where id = 5
 17424 | psql             | active              | Lock            | {17418}    | update orders set updated_at = now() where id
(4 rows)

The ALTER TABLE waits for the report's ACCESS SHARE (it needs ACCESS EXCLUSIVE). That alone would be fine - but the SELECT and UPDATE that came after it wait for the ALTER, not for the report: they conflict with the ACCESS EXCLUSIVE request in front of them in the queue. Every request for orders is now stuck until the report ends. The app's pools fill up with waiting requests, and that is the outage: from a migration whose own work takes milliseconds.

Stop the bleeding: cancel the migration, not the report - the queue behind it drains at once:

$ sudo -u postgres psql -Atc "select pg_cancel_backend(pid) from pg_stat_activity where query like 'alter table orders%' and state = 'active'"
ERROR:  canceling statement due to user request
t
$ sudo -u postgres psql -c "select pid, application_name, state, wait_event_type, left(query, 45) as query from pg_stat_activity where datname = 'orders' order by backend_start"
  pid  | application_name |        state        | wait_event_type |            query
-------+------------------+---------------------+-----------------+-----------------------------
 17413 | weekly-report    | idle in transaction | Client          | SELECT count(*) FROM orders
(1 row)

lock_timeout: fail fast, retry

The fix is to never let a migration wait for its lock for long. With lock_timeout the ALTER gives up after a few seconds instead of holding the queue; the app sees a hiccup of at most that long, and the deploy retries:

$ sudo -u postgres psql -d orders -c "set lock_timeout = '3s'" -c "alter table orders add column gift_note text"; echo "exit: $?"
SET
ERROR:  canceling statement due to lock timeout
exit: 1

canceling statement due to lock timeout after 3 seconds, and nothing else waited longer than that. A retry loop belongs in the migration runner:

for i in 1 2 3 4 5; do
  psql -X -v ON_ERROR_STOP=1 -d orders \
       -c "set lock_timeout = '3s'" -c "alter table orders add column gift_note text" && break
  sleep $((i * 5))
done

End the report (in real life: wait for it, or ask its owner), then the migration goes through in milliseconds:

$ sudo -u postgres psql -Atc "select pg_terminate_backend(pid) from pg_stat_activity where application_name = 'weekly-report'"
t
$ sudo -u postgres psql -d orders -c "set lock_timeout = '3s'" -c "alter table orders add column gift_note text"
SET
ALTER TABLE

Two more rules from the same incident: set statement_timeout for the migration too (a change that turns out to rewrite the table should fail, not run for an hour), and look at pg_stat_activity for long transactions before you deploy.

What is instant and what rewrites

ADD COLUMN gift_note text was instant: since PostgreSQL 11, adding a column - even with a constant DEFAULT - only changes the catalog. The lock is still ACCESS EXCLUSIVE, so it still needs lock_timeout, but it is held for milliseconds.

changecost on a big tablesafe way
ADD COLUMN x type / ... DEFAULT 'constant'catalog onlylock_timeout
ADD COLUMN x timestamptz DEFAULT clock_timestamp() (volatile default)rewriteadd without default, backfill in batches, then set the default
DROP COLUMNcatalog only (space is reclaimed later)lock_timeout; deploy code that stops using it first
RENAME column / tablecatalog onlybut old app code breaks: expand/contract (add new, dual-write, switch, drop old)
ALTER COLUMN TYPE (int -> bigint, text -> varchar(n) ...)rewrite + index rebuildnew column + backfill + switch; a few changes are free (varchar(10) -> varchar(20), -> text)
SET NOT NULLfull scan under ACCESS EXCLUSIVECHECK (x IS NOT NULL) NOT VALID -> VALIDATE -> then SET NOT NULL uses it (12+); 18 also allows ADD CONSTRAINT ... NOT NULL x NOT VALID
ADD CONSTRAINT ... CHECK / FOREIGN KEYfull scan, long lock... NOT VALID, then VALIDATE CONSTRAINT (SHARE UPDATE EXCLUSIVE: reads and writes continue)
CREATE INDEXblocks all writes while it buildsCREATE INDEX CONCURRENTLY
ADD PRIMARY KEY / UNIQUEindex build + lockCREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX

NOT NULL without the long lock

The column needs a value for every existing row first (the backfill, below), then:

$ sudo -u postgres psql -d orders -c "update orders set gift_note = '' where gift_note is null"
UPDATE 40000
$ sudo -u postgres psql -d orders -c "set lock_timeout = '3s'" -c "alter table orders add constraint orders_gift_note_nn check (gift_note is not null) not valid"
SET
ALTER TABLE
$ sudo -u postgres psql -d orders -c "alter table orders validate constraint orders_gift_note_nn"
ALTER TABLE
$ sudo -u postgres psql -d orders -c "set lock_timeout = '3s'" -c "alter table orders alter column gift_note set not null" -c "alter table orders drop constraint orders_gift_note_nn"
SET
ALTER TABLE
ALTER TABLE

The NOT VALID step is instant; VALIDATE scans but only with SHARE UPDATE EXCLUSIVE; and SET NOT NULL finds the validated CHECK and skips its own scan. (The one-shot UPDATE above is fine for 40,000 rows in a lab; on 400 million rows it is its own incident - next section.)

Indexes: CONCURRENTLY

A plain CREATE INDEX takes a SHARE lock - reads continue, every write waits until the build ends. CONCURRENTLY builds in several passes while writes continue:

$ sudo -u postgres psql -d orders -c "create index concurrently orders_status_created_idx on orders (status, created_at)"
CREATE INDEX
$ sudo -u postgres psql -d orders -c "\d orders" | grep -A8 Indexes
Indexes:
    "orders_pkey" PRIMARY KEY, btree (id)
    "orders_status_created_idx" btree (status, created_at)
Foreign-key constraints:
    "orders_customer_id_fkey" FOREIGN KEY (customer_id) REFERENCES customers(id)
Referenced by:
    TABLE "order_items" CONSTRAINT "order_items_order_id_fkey" FOREIGN KEY (order_id) REFERENCES orders(id)

Its rules:

$ sudo -u postgres psql -d orders -c "select indexrelid::regclass as index, indisvalid from pg_index where indrelid = 'orders'::regclass"
           index           | indisvalid
---------------------------+------------
 orders_pkey               | t
 orders_status_created_idx | t
(2 rows)

REINDEX INDEX CONCURRENTLY rebuilds a bloated or corrupt index the same way.

Backfills in batches

One UPDATE of every row is one transaction: it locks every row until it commits, writes a dead tuple per row (bloat), produces a burst of WAL (replicas fall behind, the disk fills) and cannot be stopped halfway without rolling all of it back. Do it in batches that each commit:

$ for start in $(seq 1 10000 40000); do sudo -u postgres psql -d orders -qc "update orders set gift_note = 'none' where id between $start and $((start + 9999)) and gift_note = ''"; done
$ sudo -u postgres psql -d orders -Atc "select count(*) from orders where gift_note = ''"
0

In production: batches of 1,000-10,000 rows by primary key, a short pause between them, watch replication lag and n_dead_tup while it runs, and make the job restartable (the where gift_note = '' makes each batch idempotent).

The migration checklist

  1. Does it take ACCESS EXCLUSIVE? Then SET lock_timeout = '3s' and a retry loop.
  2. Does it rewrite or scan the table? Then split it (new column + backfill, NOT VALID + VALIDATE, CONCURRENTLY).
  3. Any long transactions open right now? select pid, now() - xact_start, query from pg_stat_activity where xact_start < now() - interval '1 minute'.
  4. Is the app compatible with both the old and the new schema during the deploy (expand, migrate, contract)?
  5. statement_timeout for the migration, and someone watching pg_stat_activity while it runs.

In an interview: "A migration that adds a column took the site down - why?" - ALTER TABLE needs an ACCESS EXCLUSIVE lock; a long-running transaction held ACCESS SHARE, so the ALTER waited, and every query after it queued behind the ALTER in the lock queue. Fix: cancel the migration (the queue drains), and run migrations with SET lock_timeout = '3s' and retries; use CONCURRENTLY for indexes and NOT VALID + VALIDATE for constraints.

What you can do now

Why it helps

Schema changes are the most common way application teams take their own database down, and the change itself is rarely slow - the lock it needs and the queue behind it are. As the SRE you review migrations, you get paged when one hangs, and you are expected to know which changes are safe on a live table and how to make the others safe.

lock_timeout with retries, CREATE INDEX CONCURRENTLY, NOT VALID plus VALIDATE, and batched backfills cover most real migrations.

Commands in this lesson

psql

FAQ

Why does a waiting ALTER TABLE block plain SELECTs?

Lock requests are queued in order. The ALTER waits for ACCESS EXCLUSIVE, and every later request that conflicts with ACCESS EXCLUSIVE - which is every request, including SELECT - queues behind the ALTER, not behind whatever the ALTER is waiting for. One long transaction plus one waiting migration stops the whole table.

Is adding a column always slow?

No. Since PostgreSQL 11 adding a column, even with a constant default, only changes the catalog and is instant. It still needs ACCESS EXCLUSIVE for a moment, so it still needs lock_timeout. Volatile defaults like clock_timestamp() and most column type changes rewrite the whole table - those need a different plan.

What happens if CREATE INDEX CONCURRENTLY fails?

It leaves an INVALID index behind: maintained on every write, never used for reads. \d shows it as INVALID and pg_index.indisvalid is false. Drop it (DROP INDEX CONCURRENTLY) and build it again. Also remember it cannot run inside a transaction block, so migration tools must run it outside their transaction.

How do I add NOT NULL to a big table without a long lock?

Make sure every row has a value (a batched backfill), add CHECK (col IS NOT NULL) NOT VALID (instant), run VALIDATE CONSTRAINT (a scan with a lock that lets reads and writes continue), then SET NOT NULL, which uses the validated check instead of scanning again, and drop the check. PostgreSQL 18 can also add a NOT NULL constraint as NOT VALID directly.

Why backfill in batches?

One UPDATE of every row is one huge transaction: it locks every row until it commits, creates a dead tuple per row, produces a burst of WAL that makes replicas fall behind and can fill disks, and cannot be stopped without rolling everything back. Small committed batches by primary key keep each of those small and make the job restartable.

In an interview Mid

A migration that only adds a column took the site down. What happened?

ALTER TABLE needs an ACCESS EXCLUSIVE lock. A long-running transaction held an ACCESS SHARE lock on the table, so the ALTER waited - and in the lock queue every later query on the table, even plain SELECTs, waited behind the ALTER. The column itself is a catalog-only change that takes milliseconds. In the incident, cancel the migration so the queue drains, then find the long transaction. For the future: run DDL with SET lock_timeout = '3s' and retries, check for long transactions before deploying, use CREATE INDEX CONCURRENTLY and NOT VALID + VALIDATE CONSTRAINT for the expensive parts.

Also asked: Which ALTER TABLE operations rewrite the table? · Why can CREATE INDEX CONCURRENTLY not run in a transaction? · How would you backfill a new column on a table with hundreds of millions of rows?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.