Change and migrations
Most outages start with a change. Ship code and schema in small steps that the old and the new version both accept, and measure what each step locks.
- 1During every deploy, old and new code run together. Each schema state must work for both.
- 2Rename in place breaks the running version. Expand, migrate, then contract, over several deploys.
- 3A DDL statement that waits for its lock blocks every query behind it. Set lock_timeout and retry.
- 4Prefer weak locks and catalog-only changes: CONCURRENTLY, NOT VALID then VALIDATE, and batched backfills.
- A deploy replaces instances a few at a time. For minutes, v1 and v2 share one database.
- A rollback runs the same overlap in reverse.
- So a schema change and the code that needs it never ship as one step.
During a rolling deploy, both versions serve traffic, so every schema change must be compatible with the old and the new code.
| strategy | rollback | status |
|---|---|---|
| Stop all, deploy, start all | Another full deploy | Not approved |
| Rolling, a few instances at a time | Roll the old version back out | Approved |
| Blue-green: a second fleet, then one switch | Switch back in seconds | 2× capacity |
| Canary with automated analysis | Set the canary weight to 0 | Approved |
| Feature flag around new behaviour | Turn the flag off, no deploy | Approved |
| Dark launch: run new code on copied traffic, discard results | Nothing reaches users | Reads only |
I use a canary with automated analysis for code, and feature flags to turn on behaviour separately from the deploy.
| change | lock taken | rewrite | ran | longest read wait | longest write wait | status |
|---|---|---|---|---|---|---|
| ADD COLUMN, nullable | ACCESS EXCLUSIVE | no | 19 ms | 4 ms | 4 ms | Approved |
| ADD COLUMN with a constant default | ACCESS EXCLUSIVE | no | 19 ms | 4 ms | 4 ms | Approved |
| ADD COLUMN with a volatile default | ACCESS EXCLUSIVE | Yes | 3,177 ms | 3,145 ms | 3,146 ms | Not approved |
| ALTER COLUMN TYPE int to bigint | ACCESS EXCLUSIVE | Yes | 2,680 ms | 2,659 ms | 2,656 ms | Not approved |
| CREATE INDEX | SHARE | no | 898 ms | 8 ms | 878 ms | Not approved |
| CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | no | 1,268 ms | 13 ms | 38 ms | Approved |
| SET NOT NULL | ACCESS EXCLUSIVE | no | 267 ms | 245 ms | 246 ms | Not approved |
| CHECK NOT VALID, VALIDATE, then SET NOT NULL | ACCESS EXCLUSIVE, then SHARE UPDATE EXCLUSIVE, then ACCESS EXCLUSIVE, then ACCESS EXCLUSIVE | no | 319 ms | 7 ms | 7 ms | Approved |
| ADD FOREIGN KEY | SHARE ROW EXCLUSIVE (customers) + SHARE ROW EXCLUSIVE (orders) | no | 984 ms | 2 ms | 963 ms | Not approved |
| ADD FOREIGN KEY NOT VALID, then VALIDATE | SHARE ROW EXCLUSIVE (customers) + SHARE ROW EXCLUSIVE (orders), then ROW SHARE (customers) + SHARE UPDATE EXCLUSIVE (orders) | no | 803 ms | 1 ms | 6 ms | Approved |
| DROP COLUMN | ACCESS EXCLUSIVE | no | 30 ms | 7 ms | 7 ms | Approved |
- Locks come from pg_locks inside each statement's transaction. Waits come from 8 sessions that ran during the change.
- A rewrite copies every row into a new file under ACCESS EXCLUSIVE. Reads and writes stop for the whole copy.
- After the type change, 7 reads failed with 0A000: cached plan must not change result type. Running sessions held prepared statements for the old type.
- Since Postgres 11, ADD COLUMN with a constant default changes only the catalog: 4 ms here.
ACCESS EXCLUSIVE blocks reads and writes, SHARE blocks writes, and SHARE UPDATE EXCLUSIVE blocks neither. I pick the form that takes the weakest lock for the shortest time.
- CONCURRENTLY builds the index in two passes and waits for older transactions. It runs longer: 1.3 s against 0.9 s.
- NOT VALID adds the rule for new rows at once. VALIDATE checks old rows later under a weak lock.
The safe forms do the same work, but writers wait milliseconds instead of seconds.
- A report holds the table for 1.5 s. An ALTER that needs 4 ms queues behind it.
- Without a timeout, reads and writes stalled for 1,492 ms.
- With lock_timeout of 100 ms, the ALTER needed 6 attempts. No query waited more than 103 ms.
A waiting ALTER blocks every query behind it. I run DDL with a 100 ms lock_timeout and retry, so the worst stall is 100 ms, not the length of the longest transaction.
apply_ddl(statement):
REPEAT up to 50 times:
BEGIN
SET LOCAL lock_timeout = '100ms'1 // give up fast
run statement
COMMIT; RETURN done
ON lock_not_available (55P03)2:
ROLLBACK // leave the queue3
sleep 200 ms, then try again4
RETURN failed: tell the owner- 1SET LOCAL lasts for this transaction only. Other sessions keep their own settings.
- 2The lock was not granted in time. The statement did nothing, so a retry is safe.
- 3The queries that waited behind the ALTER now run.
- 4Long transactions end between attempts. Kill a transaction that never ends.
Tested source Go: apply DDL with retries
// ApplyDDL runs one DDL statement with a short lock_timeout, and retries when the lock is not
// available (55P03). A DDL request that waits for its lock also blocks every later query on the
// table, so it must give up quickly and try again later. lockTimeout 0 means wait forever.
func ApplyDDL(ctx context.Context, db *pgxpool.Pool, sql string, lockTimeout time.Duration, attempts int, pause time.Duration) (int, error) {
for try := 1; ; try++ {
err := pgx.BeginFunc(ctx, db, func(tx pgx.Tx) error {
set := fmt.Sprintf("SET LOCAL lock_timeout = '%dms'", lockTimeout.Milliseconds())
if _, err := tx.Exec(ctx, set); err != nil {
return fmt.Errorf("set lock_timeout: %w", err)
}
_, err := tx.Exec(ctx, sql)
return err
})
var pgErr *pgconn.PgError
switch {
case err == nil:
return try, nil
case !errors.As(err, &pgErr) || pgErr.Code != "55P03":
return try, fmt.Errorf("%s: %w", sql, err)
case try == attempts:
return try, fmt.Errorf("%s: lock not available after %d attempts: %w", sql, try, err)
}
select {
case <-time.After(pause):
case <-ctx.Done():
return try, ctx.Err()
}
}
}
- Tested: with a 50 ms timeout and 3 attempts against an open transaction, it fails with 55P03 after 3 tries.
- Also set statement_timeout, so a slow statement cannot run for hours.
Every migration statement runs with SET LOCAL lock_timeout and a retry loop. A lock it cannot get fails fast and harms nobody.
| goal | safe sequence |
|---|---|
| Add an index | CREATE INDEX CONCURRENTLY. If it fails, drop the invalid index and build again. |
| Add a foreign key | ADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT. |
| Make a column NOT NULL | ADD CHECK (col IS NOT NULL) NOT VALID, VALIDATE, SET NOT NULL, drop the CHECK. |
| Add a unique constraint | CREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX. |
| Add a column with a default | A constant default is safe. For a volatile one, add it nullable and backfill. |
| Change a column type | Add a new column, dual write, backfill, switch reads, drop the old column. |
| Rename a column or table | Expand and contract (panel J). Never rename in place under live code. |
Source: the Postgres 16 ALTER TABLE and CREATE INDEX documentation. The first three rows are measured in panel C.
I build indexes concurrently and add constraints NOT VALID, then validate them. A type change becomes a new column.
Step 1: Expand
- Run additive schema changes only: new nullable columns, new tables, indexes built CONCURRENTLY.
- Each statement runs with lock_timeout and retries.
- The old code still works, because it never reads the new parts.
If it fails
The lock is not available after all retries: stop the deploy. Nothing changed, so nothing needs a rollback.
I expand the schema first, then canary the code with automated analysis and roll out. I contract only when the old version can never come back.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Transactional DDL | A multi-statement migration commits all or nothing. | Any schema change |
| Postgres | Catalog-only ADD COLUMN and DROP COLUMN | Milliseconds on any table size, no rewrite. | Expand and contract |
| Postgres | CREATE INDEX CONCURRENTLY | Build an index while writes continue. | Unique constraints, partial indexes |
| Postgres | NOT VALID, then VALIDATE CONSTRAINT | Check new rows now, old rows later under a weak lock. | Foreign keys, CHECK constraints |
| Postgres | A valid CHECK proves NOT NULL | SET NOT NULL skips its full scan. | Tightening old columns |
| Postgres | lock_timeout, statement_timeout | A waiting DDL request gives up before it stalls traffic. | User requests, batch jobs |
| Postgres | pg_locks, pg_stat_activity | See which transaction blocks the migration. | Incident debugging |
| Service | Feature flags | Turn behaviour on and off without a deploy. | Gradual launches, kill switches |
| Service | Tolerant reader: ignore unknown fields | Old clients accept new responses. | Events, message schemas |
| Load balancer | Weighted routing per version | Canary at 1%, then more, and back to 0 in seconds. | Blue-green switch, dark launch |
| Orchestrator | Rolling update with surge and unavailable limits | Replace instances a few at a time, with health checks. | Node maintenance |
Postgres gives me catalog-only changes, concurrent index builds, NOT VALID constraints and lock_timeout. The load balancer and feature flags let me move traffic and behaviour separately.
Step 1: Start
Schema change at this step
None. Only the application changes.
What goes wrong, version by version
- v2:
42703column "full_name" of relation "customers" does not exist - v3:
42703column "full_name" of relation "customers" does not exist - v4:
42703column "full_name" of relation "customers" does not exist
At every step of expand and contract, the versions that serve traffic both work. A rename in place breaks the old version the moment it runs.
backfill(batch = 5000, pause = 10 ms):
top = max(id)
FOR lo FROM 1 TO top STEP batch:
UPDATE customers SET full_name = name // one short transaction1
WHERE id >= lo AND id < lo + batch2
AND full_name IS NULL // skip done rows3
sleep pause // replicas, vacuum keep up4- 1Each batch commits on its own. Its row locks end at its commit.
- 2A primary key range is an index range scan. No OFFSET, so every batch costs the same.
- 3A stopped job can start again from the top. It changes only rows still missing.
- 4In production, also pause while replica lag is above a limit.
Tested source Go: the batch loop · SQL: one batch
// Backfill copies name into full_name for every customer, in batches of size ids. Each batch is
// its own short transaction, so a row stays locked for one batch only, and the pause between
// batches lets replicas and vacuum keep up. The WHERE clause skips rows already done, so a
// stopped backfill can restart from the beginning.
func Backfill(ctx context.Context, db *pgxpool.Pool, size int, pause time.Duration) (BackfillStats, error) {
var top int64
if err := db.QueryRow(ctx, "SELECT coalesce(max(id), 0) FROM customers").Scan(&top); err != nil {
return BackfillStats{}, fmt.Errorf("read max id: %w", err)
}
var st BackfillStats
start := time.Now()
for lo := int64(1); lo <= top; lo += int64(size) {
t := time.Now()
tag, err := db.Exec(ctx, schema["backfill_batch"], lo, lo+int64(size))
if err != nil {
return st, fmt.Errorf("batch from id %d: %w", lo, err)
}
st.LongestMs = max(st.LongestMs, float64(time.Since(t).Microseconds())/1000)
st.Rows += tag.RowsAffected()
st.Batches++
select {
case <-time.After(pause):
case <-ctx.Done():
return st, ctx.Err()
}
}
st.Ms = float64(time.Since(start).Microseconds()) / 1000
st.PerSec = float64(st.Rows) / time.Since(start).Seconds()
return st, nil
}
UPDATE customers SET full_name = name
WHERE id >= $1 AND id < $2 AND full_name IS NULL;| method | total | rows a second | rows locked for | longest write wait |
|---|---|---|---|---|
| One UPDATE of every row | 5.8 s | 86,439 | 5,784 ms | 5,755 ms |
| 100 batches of 5,000, 10 ms pause | 9.9 s | 50,512 | 233 ms | 142 ms |
I backfill by primary key range in batches of a few thousand rows, each its own transaction, with a pause. Rows stay locked for one batch, not for the whole job.
| rule | status |
|---|---|
| Schema change and code that needs it in one deploy | Not approved |
| Migrate first; the new schema accepts the old code | Approved |
| Roll back code before the contract step | Approved |
| Down migration that drops new data | Not approved |
| Add optional fields to an API | Approved |
| Rename or remove a field in place | Not approved |
| Breaking change as a new version, old one deprecated | Approved |
| Protobuf: reuse a field number | Not approved |
- Services talk to version N-1 and N+1 during a rollout, so APIs follow the same expand-and-contract rule.
Every deploy must be safe to roll back until the contract step. After that, I roll forward.
| event | result | what stops it | saved by |
|---|---|---|---|
| A migration waits behind a long transaction | Every query on the table queues: 1,492 ms in our run. | lock_timeout of 100 ms and retries. | lock_timeout |
| CREATE INDEX on a large table | Writes stop for the build: 878 ms on 1M rows. | CREATE INDEX CONCURRENTLY. | CONCURRENTLY |
| A concurrent index build fails | An invalid index stays and slows writes. | Check indisvalid in pg_index, drop the index, build again. | pg_index |
| ALTER COLUMN TYPE on a live table | A full rewrite under ACCESS EXCLUSIVE, then 0A000 errors from cached plans. | A new column, dual write and backfill. | Expand, contract |
| New code ships before its migration | 42703: column does not exist. | Run the expand migration first. | Order |
| The backfill starts while v1 still writes | Rows v1 changes later keep a stale copy. The lab found 2 such rows. | Start the backfill after the last v1 instance stops. | Order |
| A backfill in one UPDATE | Rows stay locked 5.8 s; replicas fall behind. | Batches by key range, with pauses. | Batches |
| A canary raises the error rate | A small share of users sees errors. | Automated analysis sets the canary weight to 0. | Load balancer |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Migrations at deploy time, in one transaction each. | Tables up to about 1M rows. Even a rewrite took 3.2 s here. | More than one instance, so old and new code overlap. Or a lock stall in the latency graphs. |
| 2 | lock_timeout and retries on every DDL statement. | Catalog-only changes on any table size, with stalls capped at 100 ms. | Changes that need old and new code to agree: renames, type changes, NOT NULL. |
| 3 | Expand and contract, CONCURRENTLY and NOT VALID. | Every change in panel G, with no read or write stall above tens of milliseconds. | Backfills of tens of millions of rows; replica lag during the copy. |
| 4 | Backfill jobs: key-range batches, a pause on replica lag, restartable. | About 50,512 rows a second per job here: 100M rows in about 33 minutes. | A change Postgres cannot do online, such as a new primary key type on a huge table. |
| 5 | Copy and swap: build a new table, sync it with triggers or logical replication, then swap names. | Any change, at the cost of 2× storage during the copy. | Top of the ladder. |
The deadlines are derived: 100,000,000 rows ÷ 3,600 s ≈ 27,778 rows a second. The 100M estimate assumes the batched rate stays linear.
On small tables, any migration at deploy time works. As tables grow, I add lock_timeout, then expand and contract with concurrent builds, then throttled backfill jobs, then copy-and-swap for changes Postgres cannot do online.
0 of 9 known
Rename the column name to full_name with no downtime. List the steps.
ALTER TABLE ADD COLUMN takes a few milliseconds. Why did it stop the site for a minute?
Which of these rewrites the table: DEFAULT now(), DEFAULT gen_random_uuid()?
CREATE INDEX CONCURRENTLY fails half way. What is left, and what do you do?
Why add CHECK (col IS NOT NULL) NOT VALID before SET NOT NULL?
When must the backfill start?
You dropped the old column, and the new version has a bug. Roll back?
Canary or blue-green?
A mobile client from last year still calls your API. How do you remove a field?
- add column
- 19 ms with a constant default, on any table size.
- rewrite
- 3.2 s per 1M rows, with reads and writes stopped.
- index
- CREATE INDEX blocked writes 878 ms on 1M rows. CONCURRENTLY: 38 ms.
- lock queue
- A 4 ms ALTER behind a 1.5 s report stalled queries 1,492 ms.
- lock_timeout
- 100 ms caps that stall at about 103 ms.
- backfill
- About 50,512 rows a second in batches of 5,000.
- canary
- 1%, 5%, 25%, 50%, 100%, with an analysis gate at each step.
Postgres 16 on an 8-core laptop, 1M orders and 500,000 customers, 4 readers and 4 writers. Timings vary with load. Use them as orders of magnitude.