| name | migration-safety |
| type | skill |
| description | Safe database migrations in production — expand-and-contract, lock-safe DDL, timing estimation, rollback SQL. |
| related-rules | ["migration-runbook.md","backup-policy.md"] |
| allowed-tools | Read, Write, Edit, Bash |
Skill: Migration Safety
Expertise: Expand-and-contract, CREATE INDEX CONCURRENTLY, migration timing estimation, rollback planning.
When to load
When planning or executing a production database migration, estimating migration duration, or writing rollback SQL.
Expand-and-Contract Pattern
ALTER TABLE orders RENAME COLUMN user_id TO customer_id;
ALTER TABLE orders ADD COLUMN customer_id BIGINT;
UPDATE orders SET customer_id = user_id
WHERE customer_id IS NULL
AND id BETWEEN <batch_start> AND <batch_end>;
ALTER TABLE orders DROP COLUMN user_id;
Lock-Safe DDL
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
DROP INDEX CONCURRENTLY idx_orders_customer_id_invalid;
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
ALTER TABLE orders ADD COLUMN processed_at TIMESTAMPTZ;
ALTER TABLE orders ADD COLUMN processed_at TIMESTAMPTZ NOT NULL DEFAULT now();
ALTER TABLE orders ADD COLUMN processed_at TIMESTAMPTZ;
UPDATE orders SET processed_at = created_at WHERE processed_at IS NULL;
ALTER TABLE orders ALTER COLUMN processed_at SET NOT NULL;
Estimating Migration Duration
SELECT reltuples::BIGINT AS estimated_rows
FROM pg_class
WHERE relname = 'orders';
SELECT
phase,
blocks_done,
blocks_total,
round(100.0 * blocks_done / NULLIF(blocks_total, 0), 1) AS pct_done
FROM pg_stat_progress_create_index
WHERE relid = 'orders'::regclass;
SELECT
phase,
tuples_done,
tuples_total,
round(100.0 * tuples_done / NULLIF(tuples_total, 0), 1) AS pct_done
FROM pg_stat_progress_vacuum
WHERE relid = 'orders'::regclass;
Batched Backfill (avoid long transactions)
import psycopg2
BATCH_SIZE = 10_000
with psycopg2.connect(DSN) as conn:
with conn.cursor() as cur:
cur.execute("SELECT MIN(id), MAX(id) FROM orders WHERE customer_id IS NULL")
min_id, max_id = cur.fetchone()
batch_start = min_id
while batch_start <= max_id:
batch_end = batch_start + BATCH_SIZE
with conn.cursor() as cur:
cur.execute("""
UPDATE orders
SET customer_id = user_id
WHERE id >= %s AND id < %s AND customer_id IS NULL
""", (batch_start, batch_end))
conn.commit()
print(f"Backfilled {batch_start}–{batch_end}")
batch_start = batch_end
Rollback SQL Template
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id;
ALTER TABLE orders DROP COLUMN IF EXISTS customer_id;
Pre-Migration Checklist
pgbackrest --stanza=main --type=full backup
pgbackrest --stanza=main info
psql -c "SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC LIMIT 10;"
psql -c "SET statement_timeout = '10min'; <migration_sql>"