Apply a Zero-Downtime PostgreSQL Column Migration
Use the expand-contract pattern with lock timeouts, batched backfills, and a bidirectional trigger to rename a column without breaking rolling deploys.
Task
Migrate a PostgreSQL column through expand and contract phases so old and new application versions never read or write a missing column.
When to use this
Renaming a column in one deploy breaks the previous application version still running during a rolling rollout, causing 500 errors and failed writes.
Prerequisites
- PostgreSQL 12+ with the app.orders table and a unique id column
- A bash shell for the online backfill loop
- A migration process that can run SQL outside a transaction for CREATE INDEX CONCURRENTLY
- Two deploy cycles available for the expand and contract phases
- A staging copy with production-like row counts for lock and backfill testing
Steps
1. Expand the schema by adding the new nullable column and a trigger that keeps both columns in sync in either direction.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE app.orders ADD COLUMN customer_email text;
CREATE OR REPLACE FUNCTION app.sync_order_email() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.customer_email := COALESCE(NEW.customer_email, NEW.email);
NEW.email := COALESCE(NEW.email, NEW.customer_email);
ELSIF NEW.customer_email IS DISTINCT FROM OLD.customer_email THEN
NEW.email := NEW.customer_email;
ELSIF NEW.email IS DISTINCT FROM OLD.email THEN
NEW.customer_email := NEW.email;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER orders_sync_email
BEFORE INSERT OR UPDATE ON app.orders
FOR EACH ROW EXECUTE FUNCTION app.sync_order_email();
COMMIT;
SQL
Expected output: psql reports ALTER TABLE, CREATE FUNCTION, CREATE TRIGGER, and COMMIT; the new column is nullable and both directions are synchronized.
2. Backfill existing rows in small batches, re-checking the null condition in the update so a concurrent new-version write is never overwritten.
set -euo pipefail
while true; do
updated=$(psql "$DATABASE_URL" -Atc "
WITH batch AS (
SELECT id FROM app.orders
WHERE customer_email IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE app.orders o
SET customer_email = o.email
FROM batch
WHERE o.id = batch.id AND o.customer_email IS NULL
RETURNING 1" | wc -l | tr -d '[:space:]')
if [ "$updated" -eq 0 ]; then
break
fi
echo "backfilled $updated rows"
done
Expected output: Each iteration prints a batch size and the loop exits when the final batch returns 0 rows; no row is left with a null customer_email.
3. Add and validate a not-null check without a long table scan, then set the column to NOT NULL and drop the temporary check.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE app.orders
ADD CONSTRAINT orders_customer_email_not_null
CHECK (customer_email IS NOT NULL) NOT VALID;
ALTER TABLE app.orders VALIDATE CONSTRAINT orders_customer_email_not_null;
ALTER TABLE app.orders ALTER COLUMN customer_email SET NOT NULL;
ALTER TABLE app.orders DROP CONSTRAINT orders_customer_email_not_null;
COMMIT;
SQL
Expected output: The constraint validates successfully, SET NOT NULL completes without a full scan, and the temporary check is removed.
4. Deploy the new application version that reads and writes only customer_email, while the trigger keeps email populated for old instances.
const { rows } = await pool.query(
`UPDATE app.orders
SET customer_email = $1
WHERE id = $2
RETURNING id, customer_email`,
[email, orderId],
);
Expected output: New instances write customer_email; the trigger copies it to email, so old instances that still select email continue to see the value.
5. Verify there is no drift, and simulate an old-version insert in staging to prove the trigger still fills the new column.
psql "$DATABASE_URL" -c "
SELECT
count(*) FILTER (WHERE customer_email IS NULL) AS missing_new,
count(*) FILTER (WHERE email IS DISTINCT FROM customer_email) AS drift
FROM app.orders;"
psql "$DATABASE_URL" -c "
INSERT INTO app.orders (id, email)
VALUES (gen_random_uuid(), '[email protected]')
RETURNING id, email, customer_email;"
Expected output: The drift query returns 0 | 0; the simulated old-version insert returns the same address in both email and customer_email.
6. After every old instance is gone, remove the trigger and the old column in a short transaction with a lock timeout.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
SET LOCAL lock_timeout = '3s';
DROP TRIGGER orders_sync_email ON app.orders;
DROP FUNCTION app.sync_order_email();
ALTER TABLE app.orders DROP COLUMN email;
COMMIT;
SQL
Expected output: The trigger, function, and old column are dropped; new code continues to use customer_email with no synchronization overhead.
7. Build any required index concurrently, outside a transaction, and confirm it is valid.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_customer_email_idx ON app.orders (customer_email);"
psql "$DATABASE_URL" -Atc "SELECT indisvalid FROM pg_index WHERE indexrelid = 'app.orders_customer_email_idx'::regclass;"
Expected output: The first command prints CREATE INDEX and the second prints t, proving the concurrent index is valid.
8. Confirm the old column is gone and the new column is not nullable.
psql "$DATABASE_URL" -c "
SELECT column_name, is_nullable
FROM information_schema.columns
WHERE table_schema = 'app' AND table_name = 'orders'
AND column_name IN ('email', 'customer_email');"
Expected output: Exactly one row remains: customer_email with is_nullable equal to NO.
Troubleshooting
Problem
The ALTER TABLE times out waiting for a lock
Solution
Retry during lower traffic and find the blocker with SELECT pid, state, query FROM pg_stat_activity. Do not remove lock_timeout, or the migration can queue every later query.
Problem
The backfill overwrites a value written by the new application
Solution
The batch update must re-check customer_email IS NULL in the UPDATE, not only in the CTE that selects IDs.
Problem
CREATE INDEX CONCURRENTLY leaves an invalid index
Solution
A concurrent build can fail without dropping the index. Drop it and rebuild; check pg_index.indisvalid before relying on it.
Limitations
- Expand-contract requires two deploys and temporarily doubles storage and write amplification for the migrated column.
- The synchronization trigger adds overhead to every insert and update until the contract phase removes it.
- SET NOT NULL still takes a brief exclusive lock, so lock_timeout is required even when the constraint is already validated.
Expand, migrate, contract
A rolling deploy runs old and new code at the same time. A single ALTER TABLE ... RENAME COLUMN is therefore a breaking change: old instances still query the old name, and some of them are still serving traffic. Expand-contract avoids that window. The expand phase adds a new nullable column and a trigger that keeps both names synchronized. The application is deployed to use the new name while old instances continue to use the old one. Once no old instance remains, the contract phase drops the trigger and the old column.
The trigger must synchronize in both directions. An insert from either version should populate both names. An update from the new version writes customer_email, so the trigger copies it to email; an update from the old version writes email, so the trigger copies it to customer_email. The trigger checks which column changed rather than blindly assigning both, because assigning both could discard the value the caller just wrote.
Backfills are online writes
A single UPDATE over millions of rows holds locks, generates WAL, and can bloat the table. Batch the backfill and re-check the null condition in the update itself. If the batch only filters when selecting IDs, a new-version write can land between the select and the update, and the backfill will overwrite it with the old value. Each batch commits independently, which keeps the transaction short but means the job must be restartable. The loop in this recipe is restartable because it always selects the next rows with a null new column.
After the backfill, add a CHECK ... NOT VALID constraint and validate it separately. Validation scans the table without blocking writes. SET NOT NULL can then use the validated check to avoid another full scan, but it still takes a brief exclusive lock, so keep lock_timeout small and retry if it cannot acquire the lock.
Verify compatibility, not just the final schema
The drift query catches rows where the trigger failed to synchronize: email IS DISTINCT FROM customer_email should be zero. The simulated old-version insert proves the trigger still works in the direction the new code does not exercise. Run this in staging before the contract deploy. Once the old column is dropped, the old application version cannot start, so the deploy order matters: finish the rolling update, confirm no old pods remain, then contract.
Build indexes concurrently after the data is correct. A normal CREATE INDEX blocks writes for the duration of the build; a concurrent build takes longer and can fail, leaving an invalid index. Check pg_index.indisvalid and rebuild if necessary.
Pitfalls
Do not wrap CREATE INDEX CONCURRENTLY in a transaction; it will fail immediately. Do not drop the old column while old instances are still running, even if they are only receiving a small amount of traffic. Do not use a trigger that assigns both columns unconditionally; it can clobber the new value during a rolling deploy. And always test the migration on a staging copy with production-like row counts, because lock behavior and backfill duration do not reproduce on an empty table.
Example
const { rows } = await pool.query(
`UPDATE app.orders
SET customer_email = $1
WHERE id = $2
RETURNING id, customer_email`,
[email, orderId],
);