Database Migrations & Backward-Compatible Deploys
Progressive delivery assumes you can put the previous version back. A schema change quietly removes that assumption: the container rolls back in thirty seconds, the dropped column does not come back at all, and the βstableβ version you just restored starts throwing errors on every request. Every strategy in this section β blue-green, canary, automated rollback β depends on two application versions being able to run against one database at the same time.
This guide covers the sequence that preserves that property: expand the schema additively, dual-write, backfill under throttle, switch reads, and contract only after the rollback window has closed. It also covers the CI gate that stops an unsafe migration from merging in the first place, because the cheapest place to catch a DROP COLUMN is the pull request, not the incident.
Prerequisites
Why Rollback Breaks Before Anything Else Does
A deploy is two changes that appear to be one: the application version and the schema version. Application versions are trivially reversible because the old image still exists. Schema versions are not, because the data the old structure held is gone the moment the structure is dropped. The result is an asymmetry that only shows up under pressure.
The rule that follows is short enough to put in a pull-request template: a migration may only add; removal is a separate release. Everything else in this guide is machinery for making that rule practical β how to keep two shapes in sync while both are live, how to move historical data without locking a hot table, and how to know when it is finally safe to contract.
There is a second, subtler reason the rule matters under progressive delivery. During a canary, both versions are serving simultaneously, not sequentially. A migration that is safe for a sequential blue-green cutover β apply, then switch everything at once β can still break a canary, because the stable version keeps receiving traffic against the changed schema for the entire analysis window. Canary-safe is a stricter bar than rollback-safe, and it is the bar worth designing to.
Step-by-Step Implementation
1. Expand β add the new structure, change nothing else
-- migrations/20260731_01_add_display_name.sql
-- Additive only: v1 does not know this column exists and is unaffected.
ALTER TABLE users ADD COLUMN display_name text;
-- Nullable, no default: on PostgreSQL this is a metadata-only change that takes
-- a brief ACCESS EXCLUSIVE lock and does not rewrite the table.
-- A NOT NULL DEFAULT on a large table is the classic accidental full rewrite.Verify the operation did not rewrite the table before running it against production:
psql -c "EXPLAIN (ANALYZE, BUFFERS) ALTER TABLE users ADD COLUMN display_name text;" \
--set=ON_ERROR_STOP=1 # run against a production-shaped staging restore firstAdding an index in the same step needs CONCURRENTLY, which cannot run inside a transaction β most migration tools need an explicit escape hatch for that, and forgetting it is how a five-second migration becomes a fifteen-minute table lock.
2. Dual-write β one code path, two destinations
Deploy application code that writes both shapes and still reads the old one. This version is fully compatible with the one before it, so the deploy is reversible at every moment.
// src/repositories/user.ts β dual-write phase
export async function updateUser(id: string, input: UserInput) {
return db.user.update({
where: { id },
data: {
full_name: input.name, // old shape β still authoritative for reads
display_name: input.name, // new shape β populated but not yet trusted
},
});
}
// Reads deliberately stay on the old column until the backfill has converged.
export const readName = (u: User) => u.full_name;The temptation at this point is to read display_name ?? full_name and skip the backfill. Resist it: the fallback hides an incomplete migration indefinitely, and you lose the one signal β a divergence count of zero β that tells you the next step is safe.
3. Backfill β throttled batches, measured convergence
#!/usr/bin/env bash
# scripts/backfill-display-name.sh β bounded batches so replication lag stays flat.
set -euo pipefail
BATCH=${BATCH:-5000}
LAG_LIMIT_MS=${LAG_LIMIT_MS:-1000}
while :; do
updated=$(psql -qtAX -c "
WITH batch AS (
SELECT id FROM users
WHERE display_name IS NULL
ORDER BY id
LIMIT ${BATCH}
FOR UPDATE SKIP LOCKED -- never block live writes
)
UPDATE users u SET display_name = u.full_name
FROM batch WHERE u.id = batch.id
RETURNING 1;" | wc -l)
[ "$updated" -eq 0 ] && { echo "backfill complete"; break; }
# Back off whenever the read replica falls behind β the backfill is never urgent.
lag=$(psql -qtAX -c "SELECT COALESCE(EXTRACT(MILLISECONDS FROM replay_lag),0)::int FROM pg_stat_replication LIMIT 1;")
if [ "${lag:-0}" -gt "$LAG_LIMIT_MS" ]; then sleep 10; else sleep 1; fi
echo "batch done: ${updated} rows, replica lag ${lag}ms"
doneConfirm convergence before moving on:
psql -qtAX -c "SELECT count(*) FROM users WHERE display_name IS DISTINCT FROM full_name;"
# β 0 means every row agrees; anything else means a write path is still missing the dual-write.A non-zero count here almost always means one code path β a bulk importer, an admin tool, a background job β was not updated in step 2. That is exactly what the check is for.
The throttle is the part worth understanding rather than copying. An unthrottled backfill is a denial-of-service attack you wrote yourself: it saturates write throughput, replication falls behind, and every read served from a replica starts returning stale data long before anyone thinks to blame the migration.
4. Switch reads, then contract
Deploy a version that reads display_name. Keep writing full_name β this is what preserves rollback. Only when the rollback window has closed does the final migration drop the old column, and that drop ships as its own release with nothing else in it.
5. Gate the unsafe cases in CI
The discipline above only holds if something checks it. A migration linter in the pull-request pipeline is a dozen lines and catches the destructive statements before review even starts.
#!/usr/bin/env bash
# scripts/migration-safety-gate.sh β blocks destructive DDL in the same release as app changes.
set -euo pipefail
CHANGED=$(git diff --name-only "origin/${GITHUB_BASE_REF:-main}"... -- 'migrations/*.sql')
[ -z "$CHANGED" ] && { echo "no migrations touched"; exit 0; }
# Destructive verbs that break a rollback if the previous version is still deployable.
if grep -inE '\b(DROP\s+(COLUMN|TABLE)|RENAME\s+(COLUMN|TO)|ALTER\s+COLUMN\s+\w+\s+TYPE)\b' $CHANGED; then
echo "β Destructive DDL found. Ship it as a contract-only release, labelled 'migration:contract'."
# An explicit label is the documented escape hatch β deliberate, visible, reviewed.
[[ "${PR_LABELS:-}" == *"migration:contract"* ]] || exit 1
fi
# CREATE INDEX without CONCURRENTLY locks writes for the duration of the build.
grep -inE 'CREATE\s+(UNIQUE\s+)?INDEX(?!\s+CONCURRENTLY)' -P $CHANGED && exit 1 || true
echo "β
migrations are expand-safe"Configuration Reference
| Setting | Type | Default | Effect |
|---|---|---|---|
statement_timeout |
ms | 0 (none) |
Caps how long a migration may hold a lock before failing β set 5000 for DDL |
lock_timeout |
ms | 0 (none) |
Fails fast when a table lock cannot be acquired instead of queueing behind traffic |
BATCH |
integer | 5000 | Rows per backfill iteration; larger batches finish sooner but lengthen each lock |
LAG_LIMIT_MS |
integer | 1000 | Replica lag above which the backfill pauses |
FOR UPDATE SKIP LOCKED |
clause | off | Lets the backfill step over rows a live transaction holds instead of blocking |
CONCURRENTLY |
flag | off | Builds an index without blocking writes; cannot run inside a transaction |
| Rollback window | duration | undefined | How long the contract step must wait; drives the dual-write duration |
Integration with Adjacent Topics
Canary analysis assumes schema compatibility. The metric-gated rollouts in canary releases and progressive rollouts run both versions against one database for the whole analysis window. If the canary applied a contracting migration, the stable version starts erroring and the analysis blames the canary for damage the migration caused.
Automated rollback needs a reversible target. The triggers in automated rollback triggers and runbook integration will fire on the error-rate spike a bad deploy causes β and then fail, loudly and late, if the schema moved underneath them. Wire the migration-safety gate into the same required checks that protect the deploy branch.
Preview environments are where this gets tested. The seeded databases described in database mocking and seeding for ephemeral environments are the only place to rehearse a migration against realistic row counts before it meets production volume.
Performance and Cost Impact
Expand-contract trades calendar time for reversibility. A rename that a destructive migration completes in one release takes three, spread over one to two weeks. What you buy is a rollback path that stays open the entire time, and the numbers favour it decisively: a five-minute revert versus a restore-from-backup measured in hours, with data loss between the last snapshot and the incident.
The runtime costs are smaller than teams expect. Dual-writing a text column adds roughly 3β5% to write latency on a typical row β one extra assignment in an UPDATE that was already touching the row. Storage for the duplicated column on a 40-million-row table is a few hundred megabytes. Throttled backfill of that same table takes 30β50 minutes at 5,000 rows per second and never blocks a live write, because SKIP LOCKED steps around anything a user transaction is holding.
The cost that actually bites is attention: three releases need to be sequenced, and the contract step is easy to forget for six months. Track outstanding contractions as real work items with the release number that created them, or the schema accumulates a permanent layer of columns nothing reads β each one a small tax on every future migration, every backup, and every engineer trying to work out which column is authoritative.
Troubleshooting
ERROR: canceling statement due to statement timeout during ALTER TABLE. The DDL queued behind a long-running transaction and could not acquire its lock. Find the blocker with SELECT pid, query, state FROM pg_stat_activity WHERE state <> 'idle' ORDER BY query_start;, then retry during a quieter window. Do not raise the timeout β it is the safety mechanism that stopped a table-wide lock from stalling every request.
Backfill converges, then the divergence count climbs again. A write path is still missing the dual-write. The usual suspects are a background job, a bulk import, or an admin interface that writes through a different repository. Search for direct table writes that bypass the shared repository function, rather than re-running the backfill.
Rollback succeeds, but the previous version writes nulls. The old version never knew about the new column, so rows it writes during the rollback window have display_name IS NULL. This is expected β it is also why step 4 keeps reading through a code path that can be reverted, and why the backfill script must be re-runnable after any rollback.
Canary aborts with errors on the stable version, not the canary. A contracting migration shipped with the canary. Confirm with the migration diff for that release; the fix is to restore the dropped structure and re-ship the change through the expand path. This is precisely the failure the CI gate above exists to prevent.
Frequently Asked Questions
Why can a rollback fail even though the deploy succeeded?
Because code is reversible and schema is not. Reverting the container restores an application version that references structures the migration removed, so the βsafeβ rollback rolls forward into a crash loop. Expand-contract keeps both versions runnable against a single schema, which is what makes the revert a non-event.
How long should the dual-write window stay open?
At least the length of your rollback window plus the backfill duration β for most teams one to two weeks. The cost of leaving it open longer is a duplicated column and a few percent of write latency; the cost of closing it early is discovering mid-incident that you cannot go back.
Should migrations run in the deploy job or as a separate step?
A separate step that completes before the new version receives traffic. Running migrations from application startup means every replica races to apply them, failures surface as unhealthy pods rather than a clear migration error, and a rollback restarts the race.
Are database migrations compatible with canary releases?
Additive ones are; contracting ones are not. During a canary both versions serve simultaneously against one database, so anything that removes or repurposes a structure breaks the stable version mid-rollout. Ship expansion with the canary and contraction in a later, canary-free release.
Related
- Canary Releases & Progressive Rollouts β the rollout mode that requires strictly additive migrations.
- Blue-Green Deployments for Full-Stack Apps β where the instant cutover depends on schema compatibility.
- Automated Rollback Triggers & Runbook Integration β the automation that assumes a reversible target.
- Database Mocking & Seeding for Ephemeral Environments β where migrations get rehearsed at realistic row counts.
β Back to Deployment Strategies & Progressive Delivery