PostgreSQL/Zero-downtime migrations

Zero-Downtime Migrations

A schema migration is a deploy against shared mutable state. The danger is not only what changes, it is what locks, what waits, and whether old and new application versions can survive together.

TLDR

Quick brief

Mental Model

Every table has a lock queue. A weak lock, like the one taken by a normal SELECT, can coexist with many other operations. A strong lock, like many ALTER TABLE forms need, must wait until incompatible holders leave. PostgreSQL also preserves queue fairness: when a strong lock is waiting, later weak locks queue behind it instead of skipping ahead.

That fairness rule is the common failure mode. The schema change may take milliseconds after it gets the lock, but while it waits, it becomes a gate in front of traffic. The useful question is not only "how long does this DDL run?" It is also "what lock does it request, how long can it wait, and who queues behind it?"

Timeline of a PostgreSQL lock queue where an old read transaction blocks an ALTER TABLE, and later traffic queues behind the waiting migration.

Ground-Up Explanation

PostgreSQL has many table-level lock modes, but migration planning usually starts with three:

LockTaken byWhy it matters
ACCESS SHARESELECTConflicts only with ACCESS EXCLUSIVE, but can still hold the queue open.
SHAREplain CREATE INDEXAllows reads, blocks writes for the index build.
ACCESS EXCLUSIVEmany ALTER TABLE forms, DROP, TRUNCATE, VACUUM FULLConflicts with everything, including reads.

Locks are held until the transaction ends, not merely until the statement that first touched the table ends. An idle transaction can therefore block a migration long after the user-facing query seems finished. The first safety rule is to make waiting cheap: set lock_timeout, let the migration fail fast, and retry when the queue is healthier.

The second rule is compatibility. During a rolling deploy, old application code, new application code, old schema, and new schema can overlap. During rollback, they overlap again. A migration is safe only if the app runs correctly against both shapes until the rollout window is closed.

Concept Deep Dive

Operation safety catalog

OperationRisk or lock behaviorSafer path
Add nullable columnUsually catalog-only, brief strong lock.Use short lock_timeout; deploy code that tolerates nulls.
Add column with constant defaultCatalog-only in PostgreSQL 11+ for non-volatile defaults.Safe with bounded lock wait; verify version before relying on this.
Add column with volatile defaultCan rewrite existing rows.Add nullable column first, backfill in batches, then set default for new writes.
Add NOT NULL columnRequires values for existing rows, often rewrite or scan.Expand-contract: nullable column, batched backfill, validated check, then SET NOT NULL.
Change column typeOften rewrites the table; widening some varchar limits can be metadata-only.Prefer shadow column, dual-write, backfill, switch reads, drop old column.
Rename column or tableMetadata-only but breaks running code that still uses the old name.Use a shadow path or compatibility layer until all code versions move.
Add indexPlain build blocks writes.Use CREATE INDEX CONCURRENTLY, outside a transaction block.
Add foreign keyLocks both tables and validates existing rows.Add NOT VALID, then VALIDATE CONSTRAINT.
Add CHECK constraintImmediate validation scans existing rows.Add NOT VALID, then validate after data is clean.
Drop columnMetadata-only but any remaining code reference fails.Stop all reads and writes first; drop in a later deploy.
Set or drop defaultMetadata-only for future writes.Safe with bounded lock wait; does not backfill existing rows.

Expand, migrate code, contract

The general recipe is three deployable states. In expand, add the new schema in a way old code can ignore and new code can use. In migrate code, write both shapes or read from the new shape with a fallback, then backfill existing data in small committed batches. In contract, remove the old shape only after every running and rollback-capable application version no longer needs it.

Timeline showing expand, migrate code, backfill, validate, and contract phases with a compatibility window across old and new application versions.
expand:       add new nullable shape, default, index, or constraint shell
migrate code: make application tolerate both old and new shapes
backfill:     update old rows in bounded batches with pauses
contract:     validate invariants, enforce constraints, remove old shape later

Timeouts are different tools

lock_timeout limits time spent waiting to acquire a lock. It protects live traffic from a migration that cannot start safely. statement_timeout limits total statement runtime after planning, waiting, and execution. It protects the database from long-running work. idle_in_transaction_session_timeout closes sessions that opened a transaction and then stopped doing work while still holding locks. Safe migration tooling usually uses a short lock_timeout, a task-appropriate statement_timeout, and an environment-wide idle transaction guard.

Implementation Details

Production examples

Use expand-contract for changes that cross application versions: renaming a column, splitting one column into two, replacing an enum with a lookup table, adding a non-null requirement to existing rows, or changing how a status is represented. The safe path is to add the new shape first, run code that can read both shapes, backfill old rows, validate the invariant, then remove the old shape after rollback risk is gone.

Use short lock_timeout for DDL that may wait behind live traffic: adding constraints, changing defaults, renaming objects, and taking locks for contract cleanup. A failed migration attempt is better than a migration waiting quietly while every request piles up behind it.

Backfills should look like background traffic, not a one-time database siege. Batch by primary key ranges, commit often, pause between batches, and track write latency, dead tuples, autovacuum, and replica lag. The migration is not finished until the application and database are both stable under normal traffic.

Lab Evidence

The runnable lab is labs/postgres/migrations: a 300k-row orders table, a Go load driver printing per-second p50/p99/max latency, and each failure mode as a Make target run against live traffic.

make break

30s reader + unguarded ALTER. The load terminal shows ops drop to zero for the full reader duration.

make test

Same scenario with lock_timeout='1s' + retry. Load shows ~1s p99 blips instead of a stall.

make index-break / index-safe

Plain index build stalls INSERTs; CONCURRENTLY keeps them flowing, measurably slower to finish.

Measured: the same migration, two outcomes

From a real run on 2026-07-07, load at ~900 ops/s. Without lock_timeout, the load driver recorded 27 consecutive seconds of zero completed queries while the ALTER waited behind a 30s reader:

t=  8s ops=  611 errs=0 p50=6.9ms    p99=22.1ms   max=26.6ms
t=  9s ops=0 errs=0  << STALL: no query completed this second >>
...                     (27 consecutive stall seconds)
t= 36s ops=  303 errs=0 p50=7.3ms    p99=28.0328s max=28.0372s
done. worst single-query latency or stall: 28.0372s

With SET lock_timeout = '1s' and a retry loop (success on attempt 10), the worst any query saw in the entire run was 1.03s:

t= 12s ops=  188 errs=0 p50=6.4ms    p99=1.0276s  max=1.0324s
t= 16s ops=  948 errs=0 p50=6.1ms    p99=25.7ms   max=1.0343s
done. worst single-query latency or stall: 1.0343s

The batched backfill filled 329,081 rows in 66 committing batches (9.7s total); the NOT VALID, VALIDATE, SET NOT NULL contract step took 0.111s; CREATE INDEX CONCURRENTLY finished in 0.242s with no recorded write stall.

Production Notes

Code Pointers

CodeWhy it matters
scripts/unsafe_alter.sqlThe one-line ALTER that creates the measured stall; the risk is context, not syntax.
scripts/safe_alter.sqlThe short lock_timeout pattern that retries instead of waiting behind traffic.
scripts/validate_region.sqlThe full NOT VALID, VALIDATE, SET NOT NULL contract sequence.
src/main.goLoad generator with per-second percentiles, and the batched backfill loop.
README.mdMeasured outputs and notes for lock queues, backfill, validation, and concurrent index creation.
MakefileTargets for unsafe/safe ALTER, backfill, validation, index breakage, and safe indexing.
compose.yamlPostgres service and deterministic lab environment.

Further Reading & Watching