Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Write a safe, zero-downtime database migration plan for a schema change. Use when asked to plan a database migration, design a zero-downtime schema change, document an expand/contract migration, produce a rollback procedure for a database change, or coordinate a database schema update with a deployment. Produces a structured migration plan covering migration objectives, backward compatibility analysis, expand/contract phase breakdown, exact SQL, rollback steps per phase, data validation queries,
.claude/skills/mohitagw15856-database-migration-plan/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 65% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 218% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 189% | 0% |
| case-22 | ✗→✓ | ▲ Improved | 205% | 0% |
| case-19 | ✓→✗ | ▼ Worse | 229% | 0% |
Produce a complete, safe database migration plan for a schema change. A migration plan is not just the SQL — it is a coordinated sequence of steps that ensures the application stays available, data stays consistent, and every step can be rolled back independently.
The expand/contract pattern is the default approach: expand the schema to support both old and new states, migrate the application, then contract to remove the old state. Never combine schema changes and data backfills in a single migration that runs during deployment.
Ask for these if not already provided:
Service: Name] | Team: Team name] Author: Name] | Reviewed by: Name / DBA] Date: Date] | Target deploy date: Date] Database engine: PostgreSQL X.X / MySQL X.X] Ticket: JIRA-XXX]
What is changing: 1–2 sentences: the specific schema change — e.g. "Adding a non-nullable organisation_id column to the users table and backfilling it from the accounts table."]
Why: 1–2 sentences: the business or technical reason driving the change.]
Migration type: Additive only / Additive + backfill / Column rename / Column type change / Table restructure / Index change]
Zero-downtime: Yes — using expand/contract / No — requires maintenance window — state duration]
Estimated migration duration:
Before writing a single line of SQL, assess whether each change is backward compatible with the currently deployed application code.
| Change | Backward compatible? | Risk | Notes | |---|---|---|---| | e.g. Add nullable column org_id] | Yes | Low | Old app ignores new column | | e.g. Backfill org_id] | Yes | Medium | Old app unaffected; new app reads backfilled values | | e.g. Add NOT NULL constraint to org_id] | No | High | Old app that inserts without org_id will fail | | e.g. Drop old column account_id] | No | High | Old app that reads account_id will fail | | e.g. Add index on org_id] | Yes | Low | Additive; no breaking change | | e.g. Rename column] | No | High | Never rename in one step; use expand/contract |
Summary: e.g. "This migration requires the expand/contract pattern across 3 deployment phases because steps 3 and 4 are not backward compatible."]
Phase 1 — EXPAND
Deploy migration: add new column (nullable), create new indexes
Old app: continues to work (ignores new column)
New app: not yet deployed
Duration: [~X min] | Rollback: trivial — drop new column
│
▼
Phase 2 — BACKFILL + DUAL-WRITE
Deploy app update: writes to both old and new columns
Run backfill: populate new column for existing rows
Validate: confirm 100% of rows have non-null new column
Duration: [~X hours depending on data volume]
Rollback: deploy previous app version; new column is still nullable
│
▼
Phase 3 — ENFORCE + SWITCH
Deploy migration: add NOT NULL constraint, drop old column/index
Deploy app update: reads only from new column
Duration: [~X min] | Rollback: requires forward-fix (constraint must be dropped first)
│
▼
Phase 4 — CONTRACT (optional cleanup)
Deploy migration: drop deprecated columns, rename if needed
Final state matches target schema
Rollback: not recommended — contract changes are destructiveGoal: Add the new column and structures without breaking the existing application. Deploy order: Run migration first, then (optionally) deploy app. Application state: Old app running; no app changes required yet.
sql-- Migration: 001_add_org_id_to_users.sql BEGIN; -- Add nullable column (safe — old app ignores it) ALTER TABLE users ADD COLUMN org_id UUID NULL REFERENCES organisations(id) ON DELETE RESTRICT; -- Add index NOW, not in Phase 3 — building index on large table during Phase 3 is risky CREATE INDEX CONCURRENTLY users_org_id_idx ON users (org_id); -- Note: CONCURRENTLY does not lock the table; safe on live traffic -- Note: Cannot run CONCURRENTLY inside a transaction block; run separately if needed COMMIT;
Validation after Phase 1:
sql-- Confirm column exists and is nullable SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'org_id'; -- Expected: is_nullable = 'YES' -- Confirm index exists SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'users' AND indexname = 'users_org_id_idx';
Rollback (Phase 1 only):
sqlBEGIN; DROP INDEX CONCURRENTLY IF EXISTS users_org_id_idx; ALTER TABLE users DROP COLUMN IF EXISTS org_id; COMMIT;
Goal: Populate the new column for all existing rows before enforcing NOT NULL. When to run: After Phase 1 is live and stable. Can be run as a background job or a one-time script. Application state: Deploy app version that dual-writes to both old and new columns.
App code change required:
// All INSERT and UPDATE operations must now set BOTH old_column and new_column
// until Phase 3 is complete. This ensures new rows are populated during the backfill window.Backfill script — batch processing:
sql-- Run in batches to avoid locking. Adjust batch size based on table size and DB load. -- Target: no single batch takes more than 5 seconds. DO $$ DECLARE batch_size INT := 1000; affected INT; BEGIN LOOP UPDATE users SET org_id = accounts.organisation_id FROM accounts WHERE users.account_id = accounts.id AND users.org_id IS NULL LIMIT batch_size; GET DIAGNOSTICS affected = ROW_COUNT; EXIT WHEN affected = 0; -- Pause between batches to avoid saturating I/O PERFORM pg_sleep(0.1); END LOOP; END $$;
Monitoring during backfill:
sql-- Check progress — run periodically during backfill SELECT COUNT(*) FILTER (WHERE org_id IS NOT NULL) AS backfilled, COUNT(*) FILTER (WHERE org_id IS NULL) AS remaining, COUNT(*) AS total, ROUND( 100.0 * COUNT(*) FILTER (WHERE org_id IS NOT NULL) / COUNT(*), 2 ) AS pct_complete FROM users;
Backfill completion validation:
sql-- Must return 0 before proceeding to Phase 3 SELECT COUNT(*) AS unbackfilled_rows FROM users WHERE org_id IS NULL; -- Confirm no new rows written without org_id (dual-write working) SELECT COUNT(*) AS recent_missing FROM users WHERE org_id IS NULL AND created_at > now() - INTERVAL '1 hour';
Rollback (Phase 2 — app only):
org_id column remains nullable; no data is lostGoal: Add NOT NULL constraint and remove dependency on the old column. Prerequisites: Phase 2 backfill must be 100% complete (zero rows with org_id IS NULL). Deploy order: Run migration, then deploy app version that reads only from org_id.
PostgreSQL — use NOT VALID + VALIDATE for large tables:
sql-- Step 1: Add constraint as NOT VALID (no full table scan — instant) ALTER TABLE users ADD CONSTRAINT users_org_id_not_null CHECK (org_id IS NOT NULL) NOT VALID; -- Step 2: VALIDATE CONSTRAINT (takes a SHARE UPDATE EXCLUSIVE lock — allows reads and writes) -- Run this separately, as it can take minutes on large tables ALTER TABLE users VALIDATE CONSTRAINT users_org_id_not_null; -- Step 3: Once validated, convert to actual NOT NULL -- (PostgreSQL trusts the validated check constraint — this is instant) ALTER TABLE users ALTER COLUMN org_id SET NOT NULL; -- Step 4: Drop the now-redundant check constraint ALTER TABLE users DROP CONSTRAINT users_org_id_not_null;
Validation after Phase 3:
sql-- Confirm NOT NULL is enforced SELECT column_name, is_nullable FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'org_id'; -- Expected: is_nullable = 'NO' -- Test that insert without org_id fails (run in a transaction and roll back) BEGIN; INSERT INTO users (email) VALUES ('test@example.com'); -- Expected: ERROR: null value in column "org_id" violates not-null constraint ROLLBACK;
Rollback (Phase 3):
sql-- Drop the NOT NULL constraint (restores nullable state) ALTER TABLE users ALTER COLUMN org_id DROP NOT NULL; -- Then deploy previous app version (dual-write) -- Note: Once app code reading the new column is live, rolling back the constraint -- without rolling back the app will cause issues — plan this carefully.
Goal: Remove the old column once the app no longer references it. Prerequisites: Phase 3 fully deployed and stable for at least X days/hours rollback window]. Warning: This phase is destructive — the old column's data is permanently deleted.
sqlBEGIN; -- Drop the old column ALTER TABLE users DROP COLUMN account_id; -- Drop any indexes that referenced the old column DROP INDEX IF EXISTS users_account_id_idx; COMMIT;
Pre-drop validation:
sql-- Confirm no application queries still reference the old column -- (Check this in code review and via a search of the codebase before running) -- grep -r "account_id" app/ -- Confirm the column is safe to drop SELECT COUNT(*) FROM users WHERE account_id IS NOT NULL; -- Should be 0 (or irrelevant once new column is canonical)
Rollback: Not straightforward — dropped column data cannot be recovered. Only proceed to Phase 4 after the rollback window has passed and the change is confirmed stable.
Run these queries before and after the full migration to confirm data integrity.
Pre-migration baseline:
sql-- Record these values before any migration step SELECT COUNT(*) AS total_users FROM users; SELECT COUNT(*) AS total_orgs FROM organisations; SELECT MIN(created_at), MAX(created_at) FROM users; -- Check for any anomalies in the source data before backfill SELECT COUNT(*) AS users_without_account FROM users WHERE account_id IS NULL;
Post-backfill integrity check:
sql-- All users have an org that exists SELECT COUNT(*) AS orphaned_org_refs FROM users u WHERE u.org_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM organisations o WHERE o.id = u.org_id ); -- Expected: 0 -- org_id matches expected value from source column SELECT COUNT(*) AS mismatched_backfill FROM users u JOIN accounts a ON u.account_id = a.id WHERE u.org_id != a.organisation_id; -- Expected: 0 -- Row count unchanged (no rows created or deleted by migration) SELECT COUNT(*) AS total_users_after FROM users; -- Must match pre-migration baseline
Post-contract final check:
sql-- Old column is gone SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'account_id'; -- Expected: 0 -- New column is NOT NULL SELECT is_nullable FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'org_id'; -- Expected: NO
| Step | Lock type | Lock duration | Traffic impact | |---|---|---|---| | Add nullable column | ACCESS EXCLUSIVE | Milliseconds | Negligible | | CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | Minutes (proportional to table size) | Reads and writes continue | | Batch backfill | Row-level locks only | <5s per batch | Low if batches are small | | ADD CONSTRAINT NOT VALID | ACCESS EXCLUSIVE | Milliseconds | Negligible | | VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | Minutes | Reads and writes continue | | ALTER COLUMN SET NOT NULL | ACCESS EXCLUSIVE | Milliseconds (if check constraint validated) | Negligible | | DROP COLUMN | ACCESS EXCLUSIVE | Milliseconds | Negligible |
Expected load increase during backfill:
Backfill rate estimate:
Follow this checklist on the day of migration. Mark each step as done before proceeding.
Pre-migration (day before):
Phase 1 — Expand (T+0):
001_expand_add_org_id.sql on productionPhase 2 — Backfill (T+X hours]):
Phase 3 — Enforce (T+X days]):
Phase 4 — Contract (T+X days after rollback window]):
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 33,699 | 29,875 | -11% | 1 | 1 | 0% | 6,287 | 10,550 | +68% | 0 | 0 | — |
case-02 | fail→pass | 30,514 | 29,869 | -2% | 1 | 1 | 0% | 6,271 | 10,337 | +65% | 0 | 0 | — |
case-03 | fail→fail | 49,517 | 30,941 | -38% | 1 | 1 | 0% | 6,275 | 10,537 | +68% | 0 | 0 | — |
case-04 | pass→pass | 12,325 | 16,907 | +37% | 1 | 1 | 0% | 2,266 | 7,566 | +234% | 0 | 0 | — |
case-05 | fail→pass | 12,444 | 12,984 | +4% | 1 | 1 | 0% | 2,056 | 6,531 | +218% | 0 | 0 | — |
case-06 | pass→pass | 14,579 | 12,925 | -11% | 1 | 1 | 0% | 2,361 | 6,661 | +182% | 0 | 0 | — |
case-07 | fail→pass | 12,046 | 12,157 | +1% | 1 | 1 | 0% | 2,139 | 6,188 | +189% | 0 | 0 | — |
case-08 | pass→pass | 9,765 | 10,706 | +10% | 1 | 1 | 0% | 1,886 | 6,145 | +226% | 0 | 0 | — |
case-09 | fail→fail | 11,513 | 22,140 | +92% | 1 | 1 | 0% | 2,320 | 8,263 | +256% | 0 | 0 | — |
case-10 | fail→fail | 12,845 | 11,313 | -12% | 1 | 1 | 0% | 2,369 | 6,570 | +177% | 0 | 0 | — |
case-11 | pass→pass | 10,053 | 12,695 | +26% | 1 | 1 | 0% | 2,083 | 6,820 | +227% | 0 | 0 | — |
case-12 | pass→pass | 10,086 | 5,959 | -41% | 1 | 1 | 0% | 1,797 | 5,238 | +191% | 0 | 0 | — |
case-13 | fail→fail | 15,928 | 13,654 | -14% | 1 | 1 | 0% | 2,737 | 6,733 | +146% | 0 | 0 | — |
case-14 | fail→fail | 12,779 | 12,945 | +1% | 1 | 1 | 0% | 2,297 | 6,441 | +180% | 0 | 0 | — |
case-15 | pass→pass | 6,231 | 6,713 | +8% | 1 | 1 | 0% | 966 | 5,078 | +426% | 0 | 0 | — |
case-16 | pass→pass | 17,419 | 24,716 | +42% | 1 | 1 | 0% | 2,610 | 8,453 | +224% | 0 | 0 | — |
case-17 | pass→pass | 4,778 | 5,901 | +24% | 1 | 1 | 0% | 741 | 5,222 | +605% | 0 | 0 | — |
case-18 | pass→pass | 15,196 | 37,906 | +149% | 1 | 1 | 0% | 2,499 | 8,053 | +222% | 0 | 0 | — |
case-19 | pass→fail | 12,194 | 17,670 | +45% | 1 | 1 | 0% | 2,230 | 7,335 | +229% | 0 | 0 | — |
case-20 | pass→pass | 16,047 | 16,776 | +5% | 1 | 1 | 0% | 2,460 | 6,815 | +177% | 0 | 0 | — |
case-21 | pass→pass | 13,221 | 11,518 | -13% | 1 | 1 | 0% | 2,528 | 6,294 | +149% | 0 | 0 | — |
case-22 | fail→pass | 12,804 | 12,145 | -5% | 1 | 1 | 0% | 2,013 | 6,139 | +205% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +14 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.