| name | migrations |
| description | This skill should be used when the user asks about "database migration", "schema migration", "ALTER TABLE", "adding a column", "dropping a column", "renaming a column", "changing column type", "adding an index", "dropping an index", "zero-downtime migration", "blue-green migration", "expand-contract", "backfill", "lock contention", "table lock", "migration strategy", "forward migration", "rollback migration", "Flyway", "Liquibase", "Alembic", "Prisma migrate", "Rails migration", "Django migration". Also trigger for "how do I safely rename a column", "how to add NOT NULL constraint without downtime", "my migration is locking the table", or "apply migration without downtime". |
Database Migrations
Safely evolve database schemas in production with zero or minimal downtime.
The Expand-Contract Pattern
For any migration that would break the running application, use the three-phase expand-contract pattern:
Phase 1: EXPAND — Add new structure without removing old
Phase 2: MIGRATE — Backfill data; update app to use new structure
Phase 3: CONTRACT — Remove old structure after app fully migrated
Each phase is a separate deployment. This eliminates the window where the migration breaks the live application.
Operation Risk Reference
| Operation | Lock? | Risk | Strategy |
|---|
CREATE TABLE | No | Safe | Apply directly |
ADD COLUMN ... DEFAULT NULL | Brief | Safe | Apply directly |
ADD COLUMN ... NOT NULL DEFAULT x | Table lock (small tables only) | Risky | Add nullable, backfill, add constraint |
ADD COLUMN ... NOT NULL (no default) | Table lock | High | Expand-contract |
DROP COLUMN | Brief | App risk | Remove code first, then drop |
RENAME COLUMN | Brief | App breaks | Expand-contract |
ALTER COLUMN TYPE | Table lock | High | Expand-contract |
CREATE INDEX (naive) | Table lock | High | CREATE INDEX CONCURRENTLY |
CREATE UNIQUE INDEX (naive) | Table lock | High | CREATE UNIQUE INDEX CONCURRENTLY |
DROP INDEX | Brief | Safe | Apply directly |
ADD FOREIGN KEY | Lock, full scan | Risky | NOT VALID, then VALIDATE separately |
ADD CHECK CONSTRAINT | Table lock | High | NOT VALID, then VALIDATE |
DROP TABLE | Brief | Data loss! | Remove code references first |
Specific Migration Recipes
Add nullable column (safe)
BEGIN;
ALTER TABLE orders ADD COLUMN notes TEXT;
COMMIT;
ALTER TABLE orders DROP COLUMN notes;
Add NOT NULL column to large table (zero-downtime)
ALTER TABLE orders ADD COLUMN tier TEXT NOT NULL DEFAULT 'standard';
ALTER TABLE orders ADD COLUMN tier TEXT;
DO $$
DECLARE
batch_size INT := 1000;
last_id UUID;
rows_updated INT;
BEGIN
SELECT id INTO last_id FROM orders ORDER BY id LIMIT 1;
LOOP
UPDATE orders
SET tier = 'standard'
WHERE id IN (
SELECT id FROM orders
WHERE tier IS NULL
ORDER BY id
LIMIT batch_size
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
PERFORM pg_sleep(0.01);
END LOOP;
END $$;
ALTER TABLE orders ADD CONSTRAINT orders_tier_not_null CHECK (tier IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_tier_not_null;
Add index without locking (PostgreSQL)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders(customer_id);
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer;
Add foreign key without locking
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id)
NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;
Rename column (zero-downtime)
This is one of the most dangerous operations because renaming immediately breaks running code.
Full expand-contract for column rename:
ALTER TABLE orders ADD COLUMN customer_reference_id UUID;
CREATE INDEX CONCURRENTLY idx_orders_customer_ref ON orders(customer_reference_id);
UPDATE orders SET customer_reference_id = customer_id
WHERE customer_reference_id IS NULL;
ALTER TABLE orders DROP COLUMN customer_id;
Change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
ALTER TABLE users ADD COLUMN age_v2 BIGINT;
CREATE OR REPLACE FUNCTION sync_age_v2()
RETURNS TRIGGER AS $$
BEGIN
NEW.age_v2 := NEW.age::BIGINT;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_age_v2
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_age_v2();
UPDATE users SET age_v2 = age::BIGINT WHERE age_v2 IS NULL;
ALTER TABLE users ALTER COLUMN age_v2 SET NOT NULL;
DROP TRIGGER trg_sync_age_v2 ON users;
ALTER TABLE users DROP COLUMN age;
ALTER TABLE users RENAME COLUMN age_v2 TO age;
Migration File Best Practices
Naming
20240115_143000_add_customer_tier_to_orders.sql
YYYYMMDD_HHMMSS_short_description.sql
Structure
BEGIN;
ALTER TABLE orders ADD COLUMN customer_tier TEXT
CHECK (customer_tier IN ('standard', 'premium', 'enterprise'));
COMMENT ON COLUMN orders.customer_tier IS 'Customer tier at time of order. NULL = pre-tier-feature orders.';
COMMIT;
Idempotency
Migrations should be safe to apply twice:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_tier ON orders(customer_tier);
ALTER TABLE orders ADD COLUMN IF NOT EXISTS customer_tier TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'orders_tier_valid'
) THEN
ALTER TABLE orders ADD CONSTRAINT orders_tier_valid
CHECK (customer_tier IN ('standard', 'premium', 'enterprise'));
END IF;
END $$;
Pre-Production Checklist
Before running a migration in production:
Lock Monitoring During Migrations
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.query AS blocked_query,
blocking_locks.pid AS blocking_pid,
blocking_activity.query AS blocking_query,
blocked_activity.wait_event_type,
blocked_activity.wait_event
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks ON (
blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation = blocked_locks.relation
AND blocking_locks.pid != blocked_locks.pid
)
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
If the migration is holding locks longer than expected:
- Kill the migration:
SELECT pg_cancel_backend(pid)
- Kill blocking transactions if safe:
SELECT pg_terminate_backend(pid)
- Never kill without understanding what the blocking transaction was doing
Deeper Reference
For zero-downtime migration playbooks and lock mitigation SQL, see:
references/lock-mitigation.md — lock type reference table, lock monitoring queries, and step-by-step procedures for common high-lock operations (adding NOT NULL, renaming columns)
references/zero-downtime-patterns.md — expand-contract pattern, online index builds, table rewrites with pg_repack, and blue-green schema migration strategy