
In one sentence: the previous article showed you how to version schema changes — this one answers two tougher production questions. How do you change a schema without interrupting reads and writes at all? And once a migration has written bad data, how do you back out safely?
Reference the previous article in this series:
By the end, you'll be able to:
NOT VALID constraints, three-phase type change).down — it means rolling forward a compensating migration — and know when the backup is the right tool.migrations table is the git log."You did it exactly "by the book" from the last article: you wrote a migration, ran drizzle-kit migrate, deployed. Everything looked fine. Then, ten minutes later, the alert fires: database connections maxed out, P99 latency jumping to tens of seconds.
What did you break? It was something that looked completely harmless:
ALTER TABLE orders ADD COLUMN region text;Then, to fill it in from a related table:
UPDATE orders o
SET region = c.region
FROM customers c
WHERE o.customer_id = c.id;That single UPDATE on a few million rows is one enormous transaction. It holds locks on every row it touches, and because it scans and writes so many rows, it pins the shared buffer and the pending write queue for a long time. Meanwhile your app is doing the same kind of writes — they all queue up behind it. Postgres is fine with many readers, but when those readers start waiting, the whole site stalls.
Now the second variant, the one that makes you sweat at a different hour: you deploy a migration that copies price from integer to numeric, and a backfill that joins an old orders_legacy table. It "worked," but for a batch of older orders the join found no match, so those rows ended up price = NULL — and your new frontend code happily multiplied NULL into a menu price. Bad data is now in production, and the app has already served it.
These are the two problems this article is about. Same migration, two flavors of harm: downtime (the schema change itself blocks traffic) and corruption (the change shipped wrong data).
Let's be precise about where the risk actually sits. From the last article you know: an ALTER TABLE that triggers a full table rewrite holds an ACCESS EXCLUSIVE lock — the strongest lock there is — and that blocks every read and write for as long as the rewrite lasts.
But there's a subtler failure mode with the ordering of deploy, not the DDL itself. Consider the two players in play:
If you change the schema in a way that breaks either old or new code, someone is always broken, no matter how fast you deploy. Classic examples:
NOT NULL column the new code inserts but the old code doesn't → old process fails its INSERT.So the real target of zero-downtime migration is not "run the DDL fast." It's: make the schema a superset the old code can live with, and the new code can live with, at every point in time. That way the database never becomes a place where only one generation of the app can run.
This is the principle the whole article hangs on. Now let's turn it into a concrete pattern.
The industry-standard solution is expand/contract (also called "parallel change"). It breaks the fear into three steps, each of which is individually backward-compatible:
Expand Migrate Contract
schema ──── add new ─▶ backfill ─▶ swap app ─▶ drop old
(old code (fill data (app reads/writes (remove the
still works) with new) the new shape) dead column)Three rules keep it safe:
INSERTs just don't write it; old SELECTs never touch it.UPDATE (that was our 2 a.m. mistake). This step runs while both codebases are still alive.Let's see each phase in action against our orders example.
Add the column without breaking old code. Note we avoid NOT NULL for now:
ALTER TABLE orders ADD COLUMN region text;On PostgreSQL 11+, if you want it NOT NULL right away, give it a constant default — that's the metadata-only fast path, no rewrite:
ALTER TABLE orders ADD COLUMN region text NOT NULL DEFAULT 'unknown';Because the default is a constant (not now() or something volatile), existing rows "pretend" to have it via the missing-value trick, and the lock is very short. A volatile default (DEFAULT now()) would rewrite the whole table — avoid it here.
Now fill real values, but never as one huge transaction. Batch by keyset pagination, one small transaction per batch:
-- backfill in batches: one short transaction per batch; idempotent, resumable
WITH batch AS (
SELECT o.id, c.region
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.region = 'unknown' -- only fill rows that aren't filled yet (idempotent)
AND o.id > $last_id
ORDER BY o.id
LIMIT 1000
)
UPDATE orders o
SET region = b.region
FROM batch b
WHERE o.id = b.id;The WHERE o.id > $last_id ... LIMIT is keyset pagination: each batch is a few hundred rows, each its own tiny transaction, each holding locks for milliseconds. Because the predicate "only fill rows still equal to the placeholder" is idempotent, you can stop, resume, or re-run safely. That's the antidote to the single big UPDATE.
Once the app has been deployed and every process is reading/writing the new shape, tighten and clean up. Now you can safely add the stricter constraints:
-- add the constraint but leave it "not yet validated", so we skip a full-table scan
ALTER TABLE orders ADD CONSTRAINT orders_region_not_null
CHECK (region IS NOT NULL) NOT VALID;NOT VALID skips scanning the existing rows — it only enforces the rule for new writes. Then validate it in one pass once you're confident, using a lock that still allows reads:
ALTER TABLE orders VALIDATE CONSTRAINT orders_region_not_null;Validation takes a weaker lock (SHARE UPDATE EXCLUSIVE) that blocks concurrent writes to the table but not reads — so it can be done cheaply. Finally, drop what's no longer used:
ALTER TABLE orders DROP COLUMN region; -- if replaced by a separate table
DROP TABLE orders_legacy; -- delete only after confirming nobody references itThat's the whole trick: never let a single deploy make the database unreachable to one generation of the app.
Some DDL you'll want ready. The rule of thumb: prefer each of these because they don't take a long exclusive lock.
| Change | Safe approach | Why it's safe |
|---|---|---|
| Add a column | constant default (PG 11+) or nullable | metadata-only, no rewrite |
| Add an index | CREATE INDEX CONCURRENTLY name ON t (col) | builds without blocking reads/writes |
| Add a constraint/FK | add NOT VALID, then VALIDATE | no full scan during the add |
| Change a column type | three-phase: add new col → backfill → swap/drop | never rewrites the live one |
| Backfill data | keyset-batch, idempotent where-clause | short locks, resumable |
Two warnings:
CREATE INDEX CONCURRENTLY can't run inside a transaction block (Drizzle migrations wrap in transactions by default — keep it standalone), and if it fails it can leave an invalid index behind.ALTER COLUMN ... TYPE, which rewrites the whole table.Now the harder one. In our scenario the migration produced NULL prices. The natural instinct is "just run the down migration and go back." Let's check why that's almost never the right answer in production.
down is the wrong reflexRemember what a down migration actually is: it's the exact structural reverse of up. It assumes the table looks exactly like the moment up finished. But by now:
price as numeric. If you down to integer, the running app breaks immediately.up ran, new orders were inserted, old orders updated. The down would undo those good writes too — or fail, because the shapes no longer line up.down reverses the schema, but your problem is the values in it. Running down doesn't repair the NULLs; it just refuses the column, and the bad values are gone or still wrong elsewhere.So the down migration is really a pre-production or everything-is-stopped tool (that's why test/CI environments love it). In a running system it's a liability.
The production answer is to roll forward to a newer, correct state rather than back to an older one. You write a migration that fixes the bad rows, make it idempotent, and ship it. Think of it as git revert in Git: you don't travel back in time, you apply a new commit that happens to undo the damage.
For the NULL prices, that looks like a data-repair migration:
-- restore prices that were wrongly set to NULL (re-runnable, idempotent)
UPDATE orders o
SET price = (
SELECT c.legacy_price
FROM orders_legacy c
WHERE c.order_id = o.id
)
WHERE o.price IS NULL
AND EXISTS (SELECT 1 FROM orders_legacy c WHERE c.order_id = o.id);The keys to a safe compensating migration:
WHERE o.price IS NULL).SELECT.Sometimes the damage is too broad to repair by hand. Then the honest options are:
The decision tree is: can you repair the specific rows with a forward migration? → do that. Is the corruption pervasive or does it cascade into derived/aggregate data? → then pause the app and restore/repair in a controlled window.
The best "rollback" is the one you never need. Three habits cut this risk dramatically:
Before you touch a live schema, run through these:
ADD COLUMN / INDEX / CONSTRAINT avoid a full-rewrite / long exclusive lock?UPDATE?CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE, three-phase type change, keyset-batch backfill.down it; write an idempotent compensating migration. Reserve backup/restore for pervasive corruption, done in a controlled window.Keywords: zero-downtime migration, expand/contract, parallel change, CREATE INDEX CONCURRENTLY, NOT VALID, keyset pagination, backfill, compensating migration, roll forward, point-in-time restore, feature flag.
UPDATE and watch pg_stat_activity while you query. Then redo it with keyset batches and feel the difference.NULL on a subset), then write a correct, idempotent compensating migration and apply it. Confirm re-running it changes nothing.