Articles › Servers and DevOps

Zero-downtime PostgreSQL migrations: expand, migrate, contract

Rename a column in three deploys with no errors. The locks to watch, CREATE INDEX CONCURRENTLY, NOT VALID then VALIDATE, lock_timeout, and batched backfills.

Advanced 8 min read October 22, 2026

What you will learn
  • A schema change the old code still depends on goes in three steps: add the new thing, move data and code, then remove the old thing
  • Set lock_timeout before every DDL statement, or one waiting ALTER queues even plain SELECTs on that table
  • Build indexes with CREATE INDEX CONCURRENTLY and add constraints NOT VALID, then VALIDATE them separately
  • Backfill in small batches, each its own transaction, and work out the total time before you start

Most migrations that take a site down are not wrong statements. They are right statements run at the wrong moment: the schema changed while old code was still running, or one ALTER held a lock and every request queued behind it. This article shows how to change a PostgreSQL schema while the system keeps serving traffic: expand → migrate → contract, the locks that matter, and backfilling data in batches.

Checked against the PostgreSQL 18 documentation (ALTER TABLE, CREATE INDEX, explicit locking, and the 11, 12 and 18 release notes). Everything here works on PostgreSQL 12 and later unless noted.

Why migrations break sites

During a deploy, two versions of your code run at once: a rolling deploy, several workers, or just the seconds between running the migration and restarting the app. If the migration makes the schema incompatible with either version, that version errors immediately.

The second problem is locks. Most ALTER TABLE forms need an ACCESS EXCLUSIVE lock, which conflicts with everything, including a plain SELECT. If a long query holds the table, the ALTER waits. While it waits, every request that arrives later queues behind the ALTER, even though the ALTER has not started. One slow query plus one waiting ALTER freezes the whole table.

Rule one: every migration must work with both the previous and the next version of the code.

Expand → migrate → contract

Split an incompatible change into three steps. Each one is deployable and each one can be rolled back.

StepSchemaCode
expandadd the new thing (column, table, index); leave the old alonewrite both old and new, read old
migratebackfill old data into the new place, then add constraintsread new, still write both
contractremove the old thinguse only the new

Expand cannot break old code, because old code does not know the new column exists. Contract cannot break new code, because new code stopped using the old column a deploy ago. If any deploy goes wrong you can roll back to the previous version at once, because the schema still supports it. This fits neatly with atomic deploys and rollback.

Deploy 1: expandadd full_name · write both, read oldBackfillcopy old rows in batchesDeploy 2: migrateread from full_nameDeploy 3: contractDROP COLUMN name
Every deploy works with both the previous and next code version, so each step can roll back

Worked example: renaming a column in three deploys

Table users has a column name; we want full_name, with no downtime.

Deploy 1 (expand). Add the new column, nullable, no default. Metadata only.

SET lock_timeout = '2s';
ALTER TABLE users ADD COLUMN full_name text;

This code version writes both name and full_name on every insert and update, and still reads name. Then run the backfill (next section) so old rows get full_name too.

Deploy 2 (migrate). Once the backfill is done, add NOT NULL without scanning the table under a heavy lock:

SET lock_timeout = '2s';
ALTER TABLE users ADD CONSTRAINT users_full_name_nn
  CHECK (full_name IS NOT NULL) NOT VALID;               -- brief lock, no scan
ALTER TABLE users VALIDATE CONSTRAINT users_full_name_nn;  -- scans, light lock
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;     -- scan skipped: a valid CHECK proves no NULLs
ALTER TABLE users DROP CONSTRAINT users_full_name_nn;

This code version reads full_name and still writes both, so you can roll back to version 1.

Deploy 3 (contract). The code stops touching name entirely. Once that deploy is finished, drop it:

SET lock_timeout = '2s';
ALTER TABLE users DROP COLUMN name;

DROP COLUMN does not physically remove the data; it makes the column invisible, which is why it is quick. The space comes back gradually as existing rows are updated, or at once if something rewrites the whole table.

PostgreSQL 18 adds a shortcut: a NOT NULL constraint can itself be added NOT VALID, and SET NOT NULL validates it. The CHECK route above works from version 12 onward, so it stays the safe default.

The locks to know

StatementLockBlocksRewrites table?
ADD COLUMN nullable or constant default (11+)ACCESS EXCLUSIVE, brieflyeverything, brieflyno
ADD COLUMN volatile default, e.g. clock_timestamp()ACCESS EXCLUSIVEeverything, for the whole rewriteyes, table and indexes
CREATE INDEXSHAREwrites (reads continue)no
CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVEneither reads nor writesno
ADD CHECK ... NOT VALIDACCESS EXCLUSIVE, brieflyeverything, brieflyno, and no scan
ADD FOREIGN KEY ... NOT VALIDSHARE ROW EXCLUSIVE on both tables, brieflywrites, brieflyno
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVEneither reads nor writesno (scan only)
SET NOT NULLACCESS EXCLUSIVEeverythingno, but scans the table unless a valid CHECK exists
DROP COLUMNACCESS EXCLUSIVE, brieflyeverything, brieflyno

The pattern: work that scans or rewrites the table goes into statements with light locks (CONCURRENTLY, VALIDATE). Statements that need a heavy lock must be metadata changes that finish in a fraction of a second.

CREATE INDEX CONCURRENTLY

CREATE INDEX CONCURRENTLY users_full_name_idx ON users (full_name);

The trade-offs: it scans the table twice and waits for existing transactions to finish, so it is much slower than a normal build. It cannot run inside a transaction block. If it fails part-way (a deadlock, a duplicate in a unique index) it leaves an INVALID index that queries ignore but every write still maintains. Find it with \d users in psql, then DROP INDEX and build again, or REINDEX INDEX CONCURRENTLY.

lock_timeout: fail fast instead of freezing the table

lock_timeout defaults to 0, meaning wait forever. Set it before every DDL statement: if the lock is not granted in time, the statement errors instead of blocking the queue. The limit applies to each lock acquisition attempt separately.

Worked example: a report runs a 40-second SELECT on users, and you issue an ALTER.

  • No lock_timeout: the ALTER waits 40 seconds, and every new request touching users queues behind it. At 50 requests a second that is about 50 × 40 = 2,000 stuck requests; your connection pool ran out long before
  • lock_timeout = '2s': the ALTER gives up after 2 seconds. At most about 50 × 2 = 100 requests wait, then traffic flows again. The migration fails and you retry later

Two seconds is a common choice; tune it to your app’s own timeouts. Your migration tooling must retry, because PostgreSQL will not. If you also set statement_timeout, keep lock_timeout below it, otherwise it never fires.

Backfilling in batches

A single UPDATE users SET full_name = name on 20 million rows is one long transaction: it locks every row it touches, creates 20 million dead tuples in one go, and if it fails at 90% everything rolls back. Split by primary key into batches of a few thousand rows, each committing on its own.

#!/usr/bin/env bash
# run from a machine that can reach the DB; DB="postgres://app@db.example.com/app"
set -euo pipefail
BATCH=5000
MAX=$(psql "$DB" -Atc "SELECT coalesce(max(id),0) FROM users")
for ((start=1; start<=MAX; start+=BATCH)); do
  psql "$DB" -qc "UPDATE users SET full_name = name
                  WHERE id >= $start AND id < $start + $BATCH
                    AND full_name IS NULL"
  sleep 0.1   # give replicas and autovacuum room to keep up
done

The full_name IS NULL condition makes it safe to re-run: if it stops half-way, start again without overwriting values the app already wrote.

Work out the time before you start. The per-batch time is an assumption here; measure yours on staging with realistic data.

value
Rows20,000,000
Batch size5,000
Batches20,000,000 ÷ 5,000 = 4,000
Time per batch (measured, assumed here)150 ms + 100 ms pause = 250 ms
Total4,000 × 0.25 s = 1,000 s ≈ 17 minutes

If 150 ms per batch is too long, shrink the batch. The goal is that each transaction is short enough that nobody notices. The total time hardly matters, because the site keeps serving normally the whole way through.

Common mistakes

  • Forgetting the tool wraps a transaction. CREATE INDEX CONCURRENTLY fails at once, and with several DDL statements in one file, the first one’s lock is held until the file ends
  • Dropping a column in the same deploy that stops using it. Old code still running errors. Finish the deploy that stops using it first
  • ORMs that cache the column list. Some ORMs load column names at process start and SELECT or INSERT all of them. Drop a column and it breaks even though your code never names it. Tell the ORM to ignore the column before you drop it
  • Changing a column type in place. ALTER COLUMN ... TYPE to a type that is not binary-compatible rewrites the whole table under ACCESS EXCLUSIVE. Use the same expand/contract steps as a rename
  • Testing on an empty table. A migration that takes 50 ms on a dev box can lock for 10 minutes on real data. Always test on a full-size copy (the database edition of works on my machine)
  • A backfill that is not idempotent. Without the IS NULL guard, a re-run overwrites what the new code wrote

A statement that takes a heavy lock must finish in a fraction of a second; push the heavy work into light-lock statements, and always set lock_timeout.

Sources

FAQ

Is ADD COLUMN with a DEFAULT on a big table still dangerous?

Since PostgreSQL 11, a non-volatile (constant) default is stored in the catalog and no rewrite happens, so it is fast on any size of table. A volatile default such as clock_timestamp() still rewrites the table and its indexes. Either way the statement needs a brief ACCESS EXCLUSIVE lock, so set lock_timeout.

Why does CREATE INDEX CONCURRENTLY fail inside my migration?

It cannot run inside a transaction block, and many migration tools wrap each file in BEGIN/COMMIT. Turn the wrapping off for that one migration. If the build fails part-way it leaves an INVALID index behind; drop it and build again.

Can't I just run RENAME COLUMN?

The statement itself is fast, metadata only. But the version of your code still running during the deploy uses the old name and errors the moment the rename commits. If a short outage is acceptable, fine. If not, do it in three steps.

Commands and settings were tested on sample systems. Try them on a test machine before production.