Zero-Downtime Column Rename Migration
Rename columns or change data types without locking tables by using views, triggers, and backfill strategies.
Overview
Renaming a column or changing its type on a busy table is risky because ALTER TABLE can acquire an exclusive lock and block reads and writes for minutes or hours. Zero-downtime migrations avoid this by adding a new column, backfilling data incrementally, synchronizing writes with triggers or views, and then switching over once the old and new values match.
When to Use
- For alternatives, see Database Migrations Safely.
Use this resource when:
- You need to rename a column in a production table without downtime.
- You are changing a data type and cannot afford a long lock.
- You are migrating a legacy column to a new format.
- Your application cannot tolerate a maintenance window.
Solution
Rename a column with zero downtime
-- Step 1: add the new column
ALTER TABLE customers ADD COLUMN email_address VARCHAR(255);
-- Step 2: create a trigger to keep both columns in sync
CREATE OR REPLACE FUNCTION sync_email() RETURNS TRIGGER AS $$
BEGIN
IF NEW.email_address IS DISTINCT FROM OLD.email_address THEN
NEW.email := NEW.email_address;
ELSIF NEW.email IS DISTINCT FROM OLD.email THEN
NEW.email_address := NEW.email;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER customers_sync_email
BEFORE INSERT OR UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION sync_email();
-- Step 3: backfill in batches
UPDATE customers
SET email_address = email
WHERE id BETWEEN 1 AND 1000
AND email_address IS NULL;
-- Step 4: verify all rows match, then drop old column and rename
Change a data type with zero downtime
-- Step 1: add new column with the target type
ALTER TABLE orders ADD COLUMN total_cents INTEGER;
-- Step 2: trigger to sync both columns
CREATE OR REPLACE FUNCTION sync_total() RETURNS TRIGGER AS $$
BEGIN
IF NEW.total_cents IS DISTINCT FROM OLD.total_cents THEN
NEW.total := NEW.total_cents / 100.0;
ELSIF NEW.total IS DISTINCT FROM OLD.total THEN
NEW.total_cents := (NEW.total * 100)::INTEGER;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_sync_total
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_total();
-- Step 3: backfill in batches with casting
UPDATE orders
SET total_cents = (total * 100)::INTEGER
WHERE id BETWEEN 1 AND 5000
AND total_cents IS NULL;
-- Step 4: verify
SELECT COUNT(*) FROM orders WHERE total_cents IS NULL;
SELECT COUNT(*) FROM orders WHERE total_cents != (total * 100)::INTEGER;
-- Step 5: switch app reads, add NOT NULL, add check constraint
ALTER TABLE orders ADD CONSTRAINT chk_total_cents CHECK (total_cents >= 0);
-- Step 6: drop old column
ALTER TABLE orders DROP COLUMN total;
ALTER TABLE orders RENAME COLUMN total_cents TO total;
DROP TRIGGER orders_sync_total ON orders;
DROP FUNCTION sync_total();
Backfill script with batch loop
DO $$
DECLARE
batch_count INTEGER := 0;
rows_updated INTEGER;
BEGIN
LOOP
UPDATE customers
SET email_address = email
WHERE id IN (
SELECT id FROM customers
WHERE email_address IS NULL
LIMIT 1000
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
batch_count := batch_count + 1;
RAISE NOTICE 'Batch %: updated % rows', batch_count, rows_updated;
PERFORM pg_sleep(0.1);
END LOOP;
RAISE NOTICE 'Backfill complete: % batches', batch_count;
END $$;
Verification query before cutover
-- Check for any mismatched rows
SELECT COUNT(*) AS mismatch_count
FROM customers
WHERE email_address IS DISTINCT FROM email;
-- Check for NULLs in new column
SELECT COUNT(*) AS null_count
FROM customers
WHERE email_address IS NULL;
-- Sample mismatched rows for manual inspection
SELECT id, email, email_address
FROM customers
WHERE email_address IS DISTINCT FROM email
LIMIT 10;
Explanation
The approach is called expand-contract. First expand the schema by adding the new column while keeping the old one. A trigger ensures that writes update both columns during the transition. Backfill the new column in small batches to avoid long locks. Once the new column is fully populated and applications have been switched to use it, drop the old column and optionally rename the new one. This lets the application switch at its own pace without a database-level cutover.
The four phases of expand-contract
- Expand: Add new columns, indexes, or tables without removing old ones. The application continues working with the old schema.
- Migrate: Backfill data in batches. A trigger or application-level dual-write keeps new and old columns in sync for concurrent writes.
- Verify: Run validation queries to confirm every row has matching values in both columns. Check for NULLs, type mismatches, and constraint violations.
- Contract: Drop the old column, remove the trigger, and rename the new column. Deploy the final application version that only references the new column.
Locking behavior of common DDL operations
| Operation | PostgreSQL | MySQL |
|---|---|---|
ADD COLUMN (nullable, no default) | Brief metadata lock | Brief metadata lock |
ADD COLUMN (with default) | Brief (PG 11+) | Full table rewrite (MySQL 5.6-) |
ALTER COLUMN TYPE | Full table rewrite | Full table rewrite |
DROP COLUMN | Brief metadata lock | Brief metadata lock |
CREATE INDEX | CONCURRENTLY = no lock | INPLACE = brief lock |
ADD CONSTRAINT | NOT VALID + VALIDATE | Brief lock |
Variants
| Step | Tool | Purpose |
|---|---|---|
| Add column | ALTER TABLE | Expand schema |
| Sync writes | Trigger or application | Dual-write |
| Backfill | Batched UPDATE | Migrate existing rows |
| Validate | COUNT(*) with mismatch filter | Confirm parity |
| Switch | Deploy new app version | Read from new column |
| Cleanup | DROP COLUMN + DROP TRIGGER | Contract schema |
What Works
- Always run migrations in a transaction when possible. This keeps the schema consistent.
- Backfill in small batches with a sleep between them. This reduces lock contention and replication lag.
- Use
IS DISTINCT FROMfor NULL-safe comparisons.NULL = NULLis unknown, so use the distinct operator. - Add a feature flag to switch reads. Switch the application to the new column once backfill is complete.
- Monitor replication lag during backfill. Large updates can overwhelm replicas; pause if lag grows.
- Create indexes concurrently. Use
CREATE INDEX CONCURRENTLYin PostgreSQL to avoid blocking writes. - Test the full migration on a staging copy. Measure timing, lock behavior, and resource usage before running in production.
- Keep the trigger until after cutover. Removing the trigger too early can cause data drift if old code paths still write to the old column.
Common Mistakes
- Running a single massive UPDATE. This locks the table and can roll back on failure.
- Forgetting to handle new writes during backfill. Without a trigger, rows inserted after the backfill start will be missing.
- Dropping the old column too early. Verify both columns match for every row before cutting over.
- Not indexing the new column. If the application queries the new column, add the needed indexes before switching.
- Ignoring foreign key references. Other tables or views may reference the old column by name.
- Not setting
statement_timeout. A long-running DDL can block all queries. Set a timeout to abort migrations that take too long. - Dropping the trigger before all app instances are updated. During a rolling deploy, old instances may still write to the old column.
Performance Tips
- Set
statement_timeoutbefore running DDL. This prevents a migration from blocking the database indefinitely:
SET statement_timeout = '30s';
ALTER TABLE customers ADD COLUMN email_address VARCHAR(255);
SET statement_timeout = '0'; -- reset to default
-
Use
CREATE INDEX CONCURRENTLYfor new indexes. This avoids blocking writes but takes longer than a regularCREATE INDEX. It cannot run inside a transaction. -
Monitor
pg_stat_activityduring backfill. Watch for long-running queries and lock waits:
SELECT pid, state, wait_event_type, wait_event,
now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;
- Adjust
work_memfor large batch updates. Increasingwork_memfor the migration session can speed up batch updates by allowing larger in-memory sorts:
SET work_mem = '256MB';
- Use
lock_timeoutto avoid waiting indefinitely. If a migration cannot acquire a lock, it is better to fail fast and retry than to block:
SET lock_timeout = '5s';
ALTER TABLE customers ADD COLUMN email_address VARCHAR(255);
- Use
pg_repackfor table bloat. After large backfills or updates, tables and indexes can become bloated.pg_repackrebuilds tables without exclusive locks:
pg_repack -d mydb -t customers -j 2
- Monitor
pg_stat_progress_create_index. Track progress of concurrent index creation to estimate completion time:
SELECT phase, blocks_done, blocks_total,
tuples_done, tuples_total
FROM pg_stat_progress_create_index;
- Use
temp_filesto detect spill-to-disk. Large batch updates that exceedwork_memspill to disk, slowing down the migration:
SELECT datname, temp_files, temp_bytes
FROM pg_stat_database
WHERE datname = 'mydb';
Advanced Techniques
Migration with application-level dual-write
Handle migrations entirely in the application layer without database triggers:
-- Step 1: add new column (nullable)
ALTER TABLE customers ADD COLUMN email_address VARCHAR(255);
-- Step 2: application code writes to both columns
-- (No trigger needed; application handles sync)
-- Step 3: backfill via application in batches
-- Application runs: UPDATE customers SET email_address = email WHERE id BETWEEN ? AND ?
-- Step 4: verify parity
SELECT COUNT(*) FROM customers WHERE email_address IS DISTINCT FROM email;
-- Step 5: switch application to read from new column
-- Step 6: drop old column
ALTER TABLE customers DROP COLUMN email;
Safe column rename using view migration
Rename a column without breaking existing queries:
-- Step 1: add new column
ALTER TABLE customers ADD COLUMN email_new VARCHAR(255);
-- Step 2: backfill data
UPDATE customers SET email_new = email WHERE email_new IS NULL;
-- Step 3: create view with renamed column
CREATE OR REPLACE VIEW customers_v1 AS
SELECT id, name, email_new AS email, created_at
FROM customers;
-- Step 4: migrate application to use view
-- Step 5: drop old column and rename new
ALTER TABLE customers DROP COLUMN email;
ALTER TABLE customers RENAME COLUMN email_new TO email;
-- Step 6: drop view and use table directly
DROP VIEW customers_v1;
Migration with check constraints for validation
Add constraints incrementally to validate data during migration:
-- Step 1: add new column
ALTER TABLE orders ADD COLUMN total_cents INTEGER;
-- Step 2: backfill with validation
UPDATE orders SET total_cents = (total * 100)::INTEGER
WHERE total_cents IS NULL;
-- Step 3: add constraint as NOT VALID (no lock)
ALTER TABLE orders ADD CONSTRAINT chk_total_cents_positive
CHECK (total_cents >= 0) NOT VALID;
-- Step 4: validate constraint later (brief lock)
ALTER TABLE orders VALIDATE CONSTRAINT chk_total_cents_positive;
-- Step 5: if validation passes, proceed with cutover
Handling foreign key migrations
Migrate foreign key columns without breaking referential integrity:
-- Step 1: add new FK column (nullable)
ALTER TABLE orders ADD COLUMN customer_id_new INTEGER;
-- Step 2: backfill from old FK
UPDATE orders SET customer_id_new = customer_id WHERE customer_id_new IS NULL;
-- Step 3: add FK constraint to new column
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer_new
FOREIGN KEY (customer_id_new) REFERENCES customers(id);
-- Step 4: switch application to use new FK
-- Step 5: drop old FK and column
ALTER TABLE orders DROP CONSTRAINT fk_orders_customer;
ALTER TABLE orders DROP COLUMN customer_id;
-- Step 6: rename new column
ALTER TABLE orders RENAME COLUMN customer_id_new TO customer_id;
Rollback strategy with shadow column
Keep a shadow column for quick rollback capability:
-- Step 1: add shadow column (not used by app)
ALTER TABLE customers ADD COLUMN email_shadow VARCHAR(255);
-- Step 2: backfill shadow column
UPDATE customers SET email_shadow = email WHERE email_shadow IS NULL;
-- Step 3: proceed with main migration
ALTER TABLE customers ADD COLUMN email_address VARCHAR(255);
-- ... backfill email_address ...
-- Step 4: if issues arise, rollback using shadow
UPDATE customers SET email = email_shadow WHERE email IS NULL;
-- Step 5: after successful cutover, drop shadow
ALTER TABLE customers DROP COLUMN email_shadow; Frequently Asked Questions
How long should a backfill take?
It depends on table size and write rate. Typical strategies process a few thousand rows per batch with a short sleep between batches to avoid peak-load impact. A 10M-row table might take 1-4 hours with 1000-row batches and 100ms sleeps.
Can I do this without triggers?
Yes, you can dual-write from the application layer. The database trigger is a safety net in case not all code paths are updated. Application-level dual-write gives you more control but requires every write path to be updated.
What if the new column has a different data type?
Cast values during backfill and update the trigger to handle conversions. Test the casting on a sample before running it on the full table. Watch for precision loss when converting between NUMERIC and INTEGER types.
How do I handle NOT NULL constraints?
Add the column as nullable first. After backfill is complete and verified, add the NOT NULL constraint: ALTER TABLE customers ALTER COLUMN email_address SET NOT NULL;. This requires a brief lock but is fast once all rows are populated.
Can I roll back after dropping the old column?
No. Once you drop the old column, the data is gone. Keep the old column until you are certain the new column works correctly in production. Consider archiving the old column data to a backup table before dropping.
How do I handle indexes on the new column?
Create indexes with CREATE INDEX CONCURRENTLY in PostgreSQL to avoid blocking writes. In MySQL, use ALGORITHM=INPLACE or pt-online-schema-change for large tables. Create indexes before switching application reads to the new column.
What about views that reference the old column?
Views in PostgreSQL store the column reference by name, not position. Dropping a column that a view references will cause the view to break. Recreate views to reference the new column before dropping the old one.
Related Resources
Database Schema Documentation Template
A template for documenting database schemas with entity relationships, field definitions, and migration history.
RecipeFind and Remove Duplicate Rows in SQL
Detect duplicate records in SQL tables using GROUP BY and HAVING, then remove them safely while keeping the canonical row.
RecipeAnalyze and Optimize SQL Indexes with EXPLAIN
Identify missing, unused, and inefficient indexes by reading execution plans and measuring query cost with EXPLAIN.
RecipePartition Large Tables by Date or Range
Split huge SQL tables into smaller partitions by date, range, or list to improve query performance and maintenance.
RecipeTraverse Hierarchical Data with Recursive CTEs
Query tree-like or graph-like structures in SQL using recursive common table expressions to walk parent-child relationships.
RecipeSet Up Full-Text Search Indexes
Configure full-text search indexes in PostgreSQL to query large text columns with ranking, stemming, and highlighting.