Hard gate before any database schema change or data transformation in production. Requires a written migration plan with rollback strategy, dry run, and verification steps before execution. Prevents data loss, downtime, and irreversible schema changes.
Installs into .claude/skills of the current project.
Are you the author of Data Migration?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/rbraga01-data-migration)
---
name: data-migration
description: Hard gate before any database schema change or data transformation in production. Requires a written migration plan with rollback strategy, dry run, and verification steps before execution. Prevents data loss, downtime, and irreversible schema changes.
---
# Data Migration
## The Law
```
EVERY MIGRATION NEEDS A ROLLBACK.
A migration without a rollback plan is a one-way door.
Before you open it, know how to close it.
```
## When to Use
Use this skill before:
- Any `ALTER TABLE` in production
- Any column rename, type change, or removal
- Any large `UPDATE` or `DELETE` (more than 1000 rows)
- Any index creation on a large table
- Any data backfill or transformation script
## The Migration Process
### Step 1: Classify the Migration
| Type | Risk | Strategy |
|------|------|---------|
| Additive (new table, new nullable column) | Low | Single deploy |
| Non-breaking change (add index, add constraint) | Low–Medium | Verify impact first |
| Column rename or type change | High | Multi-phase deploy |
| Column or table removal | High | Multi-phase deploy |
| Large data backfill (>100k rows) | Medium | Batched, off-peak |
| Destructive (DROP TABLE, DELETE) | Critical | Backup + explicit approval |
### Step 2: Write the Migration Plan
Save to `docs/migrations/YYYY-MM-DD-description.md`:
```markdown
# Migration: [Title]
## Summary
[What changes and why]
## Risk Level
[Low / Medium / High / Critical]
## Migration Steps
### UP (apply)
\```sql
-- Step 1: Add new column (nullable — safe, no lock)
ALTER TABLE orders ADD COLUMN discount_amount NUMERIC(10,2);
-- Step 2: Backfill (batched — see script)
-- Run: scripts/migrations/backfill-discount.sql
-- Step 3: Add NOT NULL constraint after backfill
ALTER TABLE orders ALTER COLUMN discount_amount SET NOT NULL;
\```
### DOWN (rollback)
\```sql
ALTER TABLE orders DROP COLUMN IF EXISTS discount_amount;
\```
## Rollback Plan
[How to revert if something goes wrong — step by step]
## Estimated Duration
[How long will the migration take? Any locking risk?]
## Verification
- [ ] Row count before: SELECT COUNT(*) FROM orders;
- [ ] Row count after matches
- [ ] Sample rows spot-checked: SELECT * FROM orders LIMIT 10;
- [ ] Application queries tested against migrated schema
- [ ] No errors in application logs after deploy
```
### Step 3: Zero-Downtime Patterns
**Column rename** — never rename directly; multi-phase:
```
Phase 1: Add new column (old and new both exist)
Phase 2: Deploy app that writes to BOTH columns, reads from old
Phase 3: Backfill new column from old
Phase 4: Deploy app that writes to BOTH, reads from new
Phase 5: Drop old column
```
**Type change** — same multi-phase approach; never `ALTER COLUMN TYPE` on large tables in production directly.
**Index creation on large table** — always `CREATE INDEX CONCURRENTLY`:
```sql
-- [INVALID] — takes full table lock
CREATE INDEX idx_orders_user ON orders(user_id);
-- [VALID] — non-blocking on PostgreSQL
CREATE INDEX CONCURRENTLY idx_orders_user ON orders(user_id);
```
**Large backfill** — never in one transaction; batch it:
```sql
-- Process in batches of 1000 rows until done
DO $$
DECLARE
batch_size INT := 1000;
rows_updated INT;
BEGIN
LOOP
UPDATE orders
SET discount_amount = 0
WHERE discount_amount IS NULL
LIMIT batch_size;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
PERFORM pg_sleep(0.1); -- yield between batches
END LOOP;
END $$;
```
### Step 4: Pre-Migration Checklist
Before executing in production:
- [ ] Migration tested in staging with production-sized dataset
- [ ] DOWN migration tested — rollback actually works
- [ ] Row counts recorded (before state documented)
- [ ] Backup taken or verified recent backup exists
- [ ] Maintenance window announced if locking is expected
- [ ] Application can tolerate both old and new schema simultaneously (for multi-phase migrations)
- [ ] Monitoring alerts active during migration
### Step 5: Execute
```bash
# Dry run first (PostgreSQL)
psql $DATABASE_URL --dry-run -f migration.sql
# Real run with timing
psql $DATABASE_URL -f migration.sql --echo-all
# Check for locks during execution
SELECT pid, query, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE state != 'idle';
```
### Step 6: Verify
```sql
-- Row count matches expectations
SELECT COUNT(*) FROM affected_table;
-- Sample spot-check
SELECT * FROM affected_table LIMIT 20;
-- No NULL where NOT NULL expected
SELECT COUNT(*) FROM affected_table WHERE new_column IS NULL;
-- No application errors in logs for 10 minutes post-migration
```
## Rollback Decision Tree
```
Migration complete?
└── No errors in monitoring for 10 minutes? → DONE
└── Errors found?
├── Can app tolerate the error state? → Fix forward (hotfix)
└── Cannot tolerate? → ROLLBACK IMMEDIATELY
├── Run DOWN migration
├── Verify application recovers
└── Document in post-mortem
```
## Hard Rules
- **Never DROP a column in the same deploy as the last code that reads it** — always separate by at least one deploy
- **Never run destructive migrations (DROP TABLE, DELETE) without an explicit human approval step** — not just a code review
- **Never skip the DOWN migration** — "we can recreate it" is not a rollback strategy
- **Never migrate in peak traffic hours** — schedule for the lowest-traffic window