Database Migration Plan Skill
Write a safe, zero-downtime database migration plan for a schema change.
How to use it
Claude Code
- Run the line below. It pulls the whole folder into
~/.claude/skills/database-migration-plan. - Describe your job in plain words. Claude Code follows the skill from there.
npx degit mohitagw15856/pm-claude-skills/skills/database-migration-plan#main ~/.claude/skills/database-migration-planFor one project only, change the path to .claude/skills/database-migration-plan.
Claude (web or desktop app)
- On this page open ⋯ → Download .md.
- Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
- Pick the file and Save. Claude shows the name and description and runs a security scan.
- Check the skill is switched on.
- Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
- ChatGPT: make a Project and paste it into Instructions.
- Neither? Paste it at the top of a new chat — it works for that chat.
Not working?
- Check which app you pasted it into — the steps above name the right one.
- Some skills need the paid tier of Claude or ChatGPT.
Paste into Claude, ChatGPT or Cursor.
Source of Database Migration Plan Skill
Show the full text463 lines
| name | description |
|---|---|
| database-migration-plan | 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, and a deployment runbook. |
Database Migration Plan Skill
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.
Required Inputs
Ask for these if not already provided:
- Current schema state — the DDL or description of the table(s) as they are now
- Target schema state — the DDL or description of what the table(s) should look like after migration
- Migration reason — why this change is being made (new feature, performance fix, normalization, compliance)
- Database engine — PostgreSQL, MySQL, SQLite, CockroachDB, etc.
- Estimated data volume — approximate number of rows in affected tables
- Deployment constraints — is any downtime allowed? What is the expected traffic level during migration? Are there multiple app instances running?
- Rollback window — how long after deploy can the team roll back before the migration becomes irreversible?
Output Format
Database Migration Plan: [Migration Name]
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]
1. Migration Overview
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:
- Expand phase: [~X minutes]
- Data backfill: [~X minutes/hours — based on X rows at Y rows/second]
- Contract phase: [~X minutes after app version deployed]
2. Backward Compatibility Analysis
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."]
3. Expand/Contract Phases
Phase Overview
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 destructive
Phase 1 — Expand Schema
Goal: 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.
-- 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:
-- 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):
BEGIN;
DROP INDEX CONCURRENTLY IF EXISTS users_org_id_idx;
ALTER TABLE users DROP COLUMN IF EXISTS org_id;
COMMIT;
Phase 2 — Backfill Existing Data
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:
-- 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:
-- 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:
-- 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):
- Deploy previous app version (single-write to old column)
org_idcolumn remains nullable; no data is lost- Backfilled values remain; harmless
Phase 3 — Enforce Constraints
Goal: 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:
-- 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:
-- 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 ('[email protected]');
-- Expected: ERROR: null value in column "org_id" violates not-null constraint
ROLLBACK;
Rollback (Phase 3):
-- 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.
Phase 4 — Contract (Remove Old Column)
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.
BEGIN;
-- 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:
-- 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.
4. Data Validation Plan
Run these queries before and after the full migration to confirm data integrity.
Pre-migration baseline:
-- 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:
-- 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:
-- 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
5. Performance Impact Assessment
| 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:
- DB CPU: [estimated % increase during batch writes]
- DB I/O: [estimated increase]
- Monitoring threshold to pause backfill: [e.g. DB CPU > 80% for >2 minutes]
Backfill rate estimate:
- Table size: [X million rows]
- Batch size: [1000 rows]
- Pause between batches: [100ms]
- Estimated total duration: [X hours at Y rows/second]
6. Deployment Runbook
Follow this checklist on the day of migration. Mark each step as done before proceeding.
Pre-migration (day before):
- DBA / tech lead has reviewed the migration plan
- Performance impact assessed; monitoring dashboards ready
- Backfill script tested on a staging DB with production-scale data
- Rollback procedure tested on staging
- On-call engineer briefed; Slack channel [#db-migrations] set up for coordination
- Maintenance window scheduled (if required)
Phase 1 — Expand (T+0):
- Take a manual DB snapshot / verify automated backup is recent
- Run
001_expand_add_org_id.sqlon production - Run Phase 1 validation queries — confirm pass
- Deploy app version with dual-write
- Monitor error rate for [10 minutes]
Phase 2 — Backfill (T+[X hours]):
- Confirm Phase 1 has been stable for [X hours]
- Start backfill script in a screen/tmux session
- Monitor progress via backfill progress query every [5 minutes]
- Monitor DB CPU and I/O — pause if thresholds exceeded
- Run completion validation — confirm 0 unbackfilled rows
- Run integrity checks — confirm 0 orphaned refs, 0 mismatches
Phase 3 — Enforce (T+[X days]):
- Confirm backfill 100% complete and stable for [X hours]
- Add NOT VALID constraint
- Run VALIDATE CONSTRAINT (monitor duration and lock waits)
- Alter column to NOT NULL
- Run Phase 3 validation queries
- Deploy app version reading only from new column
- Monitor error rate for [30 minutes]
Phase 4 — Contract (T+[X days after rollback window]):
- Confirm rollback window has passed — no incidents, no rollback needed
- Search codebase for references to old column — confirm zero
- Run DROP COLUMN migration
- Run final integrity checks
- Close migration ticket; update schema documentation
Quality Checks
- Every migration phase has an independent rollback procedure — no phase assumes the next one has run
- Batch backfill script includes a pause between batches to avoid saturating I/O
- NOT NULL constraints use the NOT VALID + VALIDATE pattern on tables with >100k rows
- The app dual-write period is explicitly defined — old column writes are not dropped until Phase 3 is deployed
- Data validation queries include a row count check to confirm no data loss
- Lock types are identified for every DDL statement — no "should be fine" assumptions
- The deployment runbook names who runs each step, not just what to run
- Phase 4 (contract) is explicitly gated on the rollback window passing — not run on the same day as Phase 3
Anti-Patterns
- Do not combine the expand and contract phases into a single deployment — they must be separated by a deployment cycle
- Do not run DDL changes without first testing on a production-sized data clone
- Do not skip the NOT VALID + VALIDATE pattern for constraint additions on large tables — it causes full table locks
- Do not define a rollback as "restore from backup" — each phase must have an explicit, fast rollback procedure
- Do not omit dual-write logic during the transition period — removing the old column before all writers are updated causes data loss
| 1 | |
| 2 | name database-migration-plan |
| 3 | description "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, and a deployment runbook." |
| 4 | |
| 5 | |
| 6 | # Database Migration Plan Skill |
| 7 | |
| 8 | 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. |
| 9 | |
| 10 | 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. |
| 11 | |
| 12 | ## Required Inputs |
| 13 | |
| 14 | Ask for these if not already provided: |
| 15 | **Current schema state** — the DDL or description of the table(s) as they are now |
| 16 | **Target schema state** — the DDL or description of what the table(s) should look like after migration |
| 17 | **Migration reason** — why this change is being made (new feature, performance fix, normalization, compliance) |
| 18 | **Database engine** — PostgreSQL, MySQL, SQLite, CockroachDB, etc. |
| 19 | **Estimated data volume** — approximate number of rows in affected tables |
| 20 | **Deployment constraints** — is any downtime allowed? What is the expected traffic level during migration? Are there multiple app instances running? |
| 21 | **Rollback window** — how long after deploy can the team roll back before the migration becomes irreversible? |
| 22 | |
| 23 | ## Output Format |
| 24 | |
| 25 | |
| 26 | |
| 27 | # Database Migration Plan: [Migration Name] |
| 28 | |
| 29 | **Service:** [Name] | **Team:** [Team name] |
| 30 | **Author:** [Name] | **Reviewed by:** [Name / DBA] |
| 31 | **Date:** [Date] | **Target deploy date:** [Date] |
| 32 | **Database engine:** [PostgreSQL X.X / MySQL X.X] |
| 33 | **Ticket:** [JIRA-XXX] |
| 34 | |
| 35 | |
| 36 | |
| 37 | ## 1. Migration Overview |
| 38 | |
| 39 | **What is changing:** |
| 40 | [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."] |
| 41 | |
| 42 | **Why:** |
| 43 | [1–2 sentences: the business or technical reason driving the change.] |
| 44 | |
| 45 | **Migration type:** [Additive only / Additive + backfill / Column rename / Column type change / Table restructure / Index change] |
| 46 | |
| 47 | **Zero-downtime:** [Yes — using expand/contract / No — requires maintenance window — state duration] |
| 48 | |
| 49 | **Estimated migration duration:** |
| 50 | Expand phase: [~X minutes] |
| 51 | Data backfill: [~X minutes/hours — based on X rows at Y rows/second] |
| 52 | Contract phase: [~X minutes after app version deployed] |
| 53 | |
| 54 | |
| 55 | |
| 56 | ## 2. Backward Compatibility Analysis |
| 57 | |
| 58 | Before writing a single line of SQL, assess whether each change is backward compatible with the currently deployed application code. |
| 59 | |
| 60 | | Change | Backward compatible? | Risk | Notes | |
| 61 | |---|---|---|---| |
| 62 | | [e.g. Add nullable column `org_id`] | Yes | Low | Old app ignores new column | |
| 63 | | [e.g. Backfill `org_id`] | Yes | Medium | Old app unaffected; new app reads backfilled values | |
| 64 | | [e.g. Add NOT NULL constraint to `org_id`] | **No** | High | Old app that inserts without `org_id` will fail | |
| 65 | | [e.g. Drop old column `account_id`] | **No** | High | Old app that reads `account_id` will fail | |
| 66 | | [e.g. Add index on `org_id`] | Yes | Low | Additive; no breaking change | |
| 67 | | [e.g. Rename column] | **No** | High | Never rename in one step; use expand/contract | |
| 68 | |
| 69 | **Summary:** [e.g. "This migration requires the expand/contract pattern across 3 deployment phases because steps 3 and 4 are not backward compatible."] |
| 70 | |
| 71 | |
| 72 | |
| 73 | ## 3. Expand/Contract Phases |
| 74 | |
| 75 | ### Phase Overview |
| 76 | |
| 77 | |
| 78 | Phase 1 — EXPAND |
| 79 | Deploy migration: add new column (nullable), create new indexes |
| 80 | Old app: continues to work (ignores new column) |
| 81 | New app: not yet deployed |
| 82 | Duration: [~X min] | Rollback: trivial — drop new column |
| 83 | |
| 84 | │ |
| 85 | ▼ |
| 86 | |
| 87 | Phase 2 — BACKFILL + DUAL-WRITE |
| 88 | Deploy app update: writes to both old and new columns |
| 89 | Run backfill: populate new column for existing rows |
| 90 | Validate: confirm 100% of rows have non-null new column |
| 91 | Duration: [~X hours depending on data volume] |
| 92 | Rollback: deploy previous app version; new column is still nullable |
| 93 | |
| 94 | │ |
| 95 | ▼ |
| 96 | |
| 97 | Phase 3 — ENFORCE + SWITCH |
| 98 | Deploy migration: add NOT NULL constraint, drop old column/index |
| 99 | Deploy app update: reads only from new column |
| 100 | Duration: [~X min] | Rollback: requires forward-fix (constraint must be dropped first) |
| 101 | |
| 102 | │ |
| 103 | ▼ |
| 104 | |
| 105 | Phase 4 — CONTRACT (optional cleanup) |
| 106 | Deploy migration: drop deprecated columns, rename if needed |
| 107 | Final state matches target schema |
| 108 | Rollback: not recommended — contract changes are destructive |
| 109 | |
| 110 | |
| 111 | |
| 112 | |
| 113 | ### Phase 1 — Expand Schema |
| 114 | |
| 115 | **Goal:** Add the new column and structures without breaking the existing application. |
| 116 | **Deploy order:** Run migration first, then (optionally) deploy app. |
| 117 | **Application state:** Old app running; no app changes required yet. |
| 118 | |
| 119 | |
| 120 | -- Migration: 001_add_org_id_to_users.sql |
| 121 | BEGIN; |
| 122 | |
| 123 | -- Add nullable column (safe — old app ignores it) |
| 124 | ALTER TABLE users |
| 125 | ADD COLUMN org_id UUID NULL |
| 126 | REFERENCES organisations(id) ON DELETE RESTRICT; |
| 127 | |
| 128 | -- Add index NOW, not in Phase 3 — building index on large table during Phase 3 is risky |
| 129 | CREATE INDEX CONCURRENTLY users_org_id_idx ON users (org_id); |
| 130 | |
| 131 | -- Note: CONCURRENTLY does not lock the table; safe on live traffic |
| 132 | -- Note: Cannot run CONCURRENTLY inside a transaction block; run separately if needed |
| 133 | |
| 134 | COMMIT; |
| 135 | |
| 136 | |
| 137 | **Validation after Phase 1:** |
| 138 | |
| 139 | -- Confirm column exists and is nullable |
| 140 | SELECT column_name, data_type, is_nullable |
| 141 | FROM information_schema.columns |
| 142 | WHERE table_name = 'users' AND column_name = 'org_id'; |
| 143 | -- Expected: is_nullable = 'YES' |
| 144 | |
| 145 | -- Confirm index exists |
| 146 | SELECT indexname, indexdef |
| 147 | FROM pg_indexes |
| 148 | WHERE tablename = 'users' AND indexname = 'users_org_id_idx'; |
| 149 | |
| 150 | |
| 151 | **Rollback (Phase 1 only):** |
| 152 | |
| 153 | BEGIN; |
| 154 | DROP INDEX CONCURRENTLY IF EXISTS users_org_id_idx; |
| 155 | ALTER TABLE users DROP COLUMN IF EXISTS org_id; |
| 156 | COMMIT; |
| 157 | |
| 158 | |
| 159 | |
| 160 | |
| 161 | ### Phase 2 — Backfill Existing Data |
| 162 | |
| 163 | **Goal:** Populate the new column for all existing rows before enforcing NOT NULL. |
| 164 | **When to run:** After Phase 1 is live and stable. Can be run as a background job or a one-time script. |
| 165 | **Application state:** Deploy app version that dual-writes to both old and new columns. |
| 166 | |
| 167 | **App code change required:** |
| 168 | |
| 169 | // All INSERT and UPDATE operations must now set BOTH old_column and new_column |
| 170 | // until Phase 3 is complete. This ensures new rows are populated during the backfill window. |
| 171 | |
| 172 | |
| 173 | **Backfill script — batch processing:** |
| 174 | |
| 175 | -- Run in batches to avoid locking. Adjust batch size based on table size and DB load. |
| 176 | -- Target: no single batch takes more than 5 seconds. |
| 177 | |
| 178 | DO $$ |
| 179 | DECLARE |
| 180 | batch_size INT := 1000; |
| 181 | affected INT; |
| 182 | BEGIN |
| 183 | LOOP |
| 184 | UPDATE users |
| 185 | SET org_id = accounts.organisation_id |
| 186 | FROM accounts |
| 187 | WHERE users.account_id = accounts.id |
| 188 | AND users.org_id IS NULL |
| 189 | LIMIT batch_size; |
| 190 | |
| 191 | GET DIAGNOSTICS affected = ROW_COUNT; |
| 192 | EXIT WHEN affected = 0; |
| 193 | |
| 194 | -- Pause between batches to avoid saturating I/O |
| 195 | PERFORM pg_sleep(0.1); |
| 196 | END LOOP; |
| 197 | END $$; |
| 198 | |
| 199 | |
| 200 | **Monitoring during backfill:** |
| 201 | |
| 202 | -- Check progress — run periodically during backfill |
| 203 | SELECT |
| 204 | COUNT(*) FILTER (WHERE org_id IS NOT NULL) AS backfilled, |
| 205 | COUNT(*) FILTER (WHERE org_id IS NULL) AS remaining, |
| 206 | COUNT(*) AS total, |
| 207 | ROUND( |
| 208 | 100.0 * COUNT(*) FILTER (WHERE org_id IS NOT NULL) / COUNT(*), 2 |
| 209 | ) AS pct_complete |
| 210 | FROM users; |
| 211 | |
| 212 | |
| 213 | **Backfill completion validation:** |
| 214 | |
| 215 | -- Must return 0 before proceeding to Phase 3 |
| 216 | SELECT COUNT(*) AS unbackfilled_rows |
| 217 | FROM users |
| 218 | WHERE org_id IS NULL; |
| 219 | |
| 220 | -- Confirm no new rows written without org_id (dual-write working) |
| 221 | SELECT COUNT(*) AS recent_missing |
| 222 | FROM users |
| 223 | WHERE org_id IS NULL |
| 224 | AND created_at > now() - INTERVAL '1 hour'; |
| 225 | |
| 226 | |
| 227 | **Rollback (Phase 2 — app only):** |
| 228 | Deploy previous app version (single-write to old column) |
| 229 | `org_id` column remains nullable; no data is lost |
| 230 | Backfilled values remain; harmless |
| 231 | |
| 232 | |
| 233 | |
| 234 | ### Phase 3 — Enforce Constraints |
| 235 | |
| 236 | **Goal:** Add NOT NULL constraint and remove dependency on the old column. |
| 237 | **Prerequisites:** Phase 2 backfill must be 100% complete (zero rows with `org_id IS NULL`). |
| 238 | **Deploy order:** Run migration, then deploy app version that reads only from `org_id`. |
| 239 | |
| 240 | **PostgreSQL — use NOT VALID + VALIDATE for large tables:** |
| 241 | |
| 242 | -- Step 1: Add constraint as NOT VALID (no full table scan — instant) |
| 243 | ALTER TABLE users |
| 244 | ADD CONSTRAINT users_org_id_not_null |
| 245 | CHECK (org_id IS NOT NULL) NOT VALID; |
| 246 | |
| 247 | -- Step 2: VALIDATE CONSTRAINT (takes a SHARE UPDATE EXCLUSIVE lock — allows reads and writes) |
| 248 | -- Run this separately, as it can take minutes on large tables |
| 249 | ALTER TABLE users |
| 250 | VALIDATE CONSTRAINT users_org_id_not_null; |
| 251 | |
| 252 | -- Step 3: Once validated, convert to actual NOT NULL |
| 253 | -- (PostgreSQL trusts the validated check constraint — this is instant) |
| 254 | ALTER TABLE users |
| 255 | ALTER COLUMN org_id SET NOT NULL; |
| 256 | |
| 257 | -- Step 4: Drop the now-redundant check constraint |
| 258 | ALTER TABLE users |
| 259 | DROP CONSTRAINT users_org_id_not_null; |
| 260 | |
| 261 | |
| 262 | **Validation after Phase 3:** |
| 263 | |
| 264 | -- Confirm NOT NULL is enforced |
| 265 | SELECT column_name, is_nullable |
| 266 | FROM information_schema.columns |
| 267 | WHERE table_name = 'users' AND column_name = 'org_id'; |
| 268 | -- Expected: is_nullable = 'NO' |
| 269 | |
| 270 | -- Test that insert without org_id fails (run in a transaction and roll back) |
| 271 | BEGIN; |
| 272 | INSERT INTO users (email) VALUES ('[email protected]'); |
| 273 | -- Expected: ERROR: null value in column "org_id" violates not-null constraint |
| 274 | ROLLBACK; |
| 275 | |
| 276 | |
| 277 | **Rollback (Phase 3):** |
| 278 | |
| 279 | -- Drop the NOT NULL constraint (restores nullable state) |
| 280 | ALTER TABLE users ALTER COLUMN org_id DROP NOT NULL; |
| 281 | -- Then deploy previous app version (dual-write) |
| 282 | -- Note: Once app code reading the new column is live, rolling back the constraint |
| 283 | -- without rolling back the app will cause issues — plan this carefully. |
| 284 | |
| 285 | |
| 286 | |
| 287 | |
| 288 | ### Phase 4 — Contract (Remove Old Column) |
| 289 | |
| 290 | **Goal:** Remove the old column once the app no longer references it. |
| 291 | **Prerequisites:** Phase 3 fully deployed and stable for at least [X days/hours rollback window]. |
| 292 | **Warning:** This phase is destructive — the old column's data is permanently deleted. |
| 293 | |
| 294 | |
| 295 | BEGIN; |
| 296 | |
| 297 | -- Drop the old column |
| 298 | ALTER TABLE users DROP COLUMN account_id; |
| 299 | |
| 300 | -- Drop any indexes that referenced the old column |
| 301 | DROP INDEX IF EXISTS users_account_id_idx; |
| 302 | |
| 303 | COMMIT; |
| 304 | |
| 305 | |
| 306 | **Pre-drop validation:** |
| 307 | |
| 308 | -- Confirm no application queries still reference the old column |
| 309 | -- (Check this in code review and via a search of the codebase before running) |
| 310 | -- grep -r "account_id" app/ |
| 311 | |
| 312 | -- Confirm the column is safe to drop |
| 313 | SELECT COUNT(*) FROM users WHERE account_id IS NOT NULL; |
| 314 | -- Should be 0 (or irrelevant once new column is canonical) |
| 315 | |
| 316 | |
| 317 | **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. |
| 318 | |
| 319 | |
| 320 | |
| 321 | ## 4. Data Validation Plan |
| 322 | |
| 323 | Run these queries before and after the full migration to confirm data integrity. |
| 324 | |
| 325 | **Pre-migration baseline:** |
| 326 | |
| 327 | -- Record these values before any migration step |
| 328 | SELECT COUNT(*) AS total_users FROM users; |
| 329 | SELECT COUNT(*) AS total_orgs FROM organisations; |
| 330 | SELECT MIN(created_at), MAX(created_at) FROM users; |
| 331 | |
| 332 | -- Check for any anomalies in the source data before backfill |
| 333 | SELECT COUNT(*) AS users_without_account |
| 334 | FROM users WHERE account_id IS NULL; |
| 335 | |
| 336 | |
| 337 | **Post-backfill integrity check:** |
| 338 | |
| 339 | -- All users have an org that exists |
| 340 | SELECT COUNT(*) AS orphaned_org_refs |
| 341 | FROM users u |
| 342 | WHERE u.org_id IS NOT NULL |
| 343 | AND NOT EXISTS ( |
| 344 | SELECT 1 FROM organisations o WHERE o.id = u.org_id |
| 345 | ); |
| 346 | -- Expected: 0 |
| 347 | |
| 348 | -- org_id matches expected value from source column |
| 349 | SELECT COUNT(*) AS mismatched_backfill |
| 350 | FROM users u |
| 351 | JOIN accounts a ON u.account_id = a.id |
| 352 | WHERE u.org_id != a.organisation_id; |
| 353 | -- Expected: 0 |
| 354 | |
| 355 | -- Row count unchanged (no rows created or deleted by migration) |
| 356 | SELECT COUNT(*) AS total_users_after FROM users; |
| 357 | -- Must match pre-migration baseline |
| 358 | |
| 359 | |
| 360 | **Post-contract final check:** |
| 361 | |
| 362 | -- Old column is gone |
| 363 | SELECT COUNT(*) FROM information_schema.columns |
| 364 | WHERE table_name = 'users' AND column_name = 'account_id'; |
| 365 | -- Expected: 0 |
| 366 | |
| 367 | -- New column is NOT NULL |
| 368 | SELECT is_nullable FROM information_schema.columns |
| 369 | WHERE table_name = 'users' AND column_name = 'org_id'; |
| 370 | -- Expected: NO |
| 371 | |
| 372 | |
| 373 | |
| 374 | |
| 375 | ## 5. Performance Impact Assessment |
| 376 | |
| 377 | | Step | Lock type | Lock duration | Traffic impact | |
| 378 | |---|---|---|---| |
| 379 | | Add nullable column | ACCESS EXCLUSIVE | Milliseconds | Negligible | |
| 380 | | CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | Minutes (proportional to table size) | Reads and writes continue | |
| 381 | | Batch backfill | Row-level locks only | <5s per batch | Low if batches are small | |
| 382 | | ADD CONSTRAINT NOT VALID | ACCESS EXCLUSIVE | Milliseconds | Negligible | |
| 383 | | VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | Minutes | Reads and writes continue | |
| 384 | | ALTER COLUMN SET NOT NULL | ACCESS EXCLUSIVE | Milliseconds (if check constraint validated) | Negligible | |
| 385 | | DROP COLUMN | ACCESS EXCLUSIVE | Milliseconds | Negligible | |
| 386 | |
| 387 | **Expected load increase during backfill:** |
| 388 | DB CPU: [estimated % increase during batch writes] |
| 389 | DB I/O: [estimated increase] |
| 390 | Monitoring threshold to pause backfill: [e.g. DB CPU > 80% for >2 minutes] |
| 391 | |
| 392 | **Backfill rate estimate:** |
| 393 | Table size: [X million rows] |
| 394 | Batch size: [1000 rows] |
| 395 | Pause between batches: [100ms] |
| 396 | Estimated total duration: [X hours at Y rows/second] |
| 397 | |
| 398 | |
| 399 | |
| 400 | ## 6. Deployment Runbook |
| 401 | |
| 402 | Follow this checklist on the day of migration. Mark each step as done before proceeding. |
| 403 | |
| 404 | **Pre-migration (day before):** |
| 405 | [ ] DBA / tech lead has reviewed the migration plan |
| 406 | [ ] Performance impact assessed; monitoring dashboards ready |
| 407 | [ ] Backfill script tested on a staging DB with production-scale data |
| 408 | [ ] Rollback procedure tested on staging |
| 409 | [ ] On-call engineer briefed; Slack channel [#db-migrations] set up for coordination |
| 410 | [ ] Maintenance window scheduled (if required) |
| 411 | |
| 412 | **Phase 1 — Expand (T+0):** |
| 413 | [ ] Take a manual DB snapshot / verify automated backup is recent |
| 414 | [ ] Run `001_expand_add_org_id.sql` on production |
| 415 | [ ] Run Phase 1 validation queries — confirm pass |
| 416 | [ ] Deploy app version with dual-write |
| 417 | [ ] Monitor error rate for [10 minutes] |
| 418 | |
| 419 | **Phase 2 — Backfill (T+[X hours]):** |
| 420 | [ ] Confirm Phase 1 has been stable for [X hours] |
| 421 | [ ] Start backfill script in a screen/tmux session |
| 422 | [ ] Monitor progress via backfill progress query every [5 minutes] |
| 423 | [ ] Monitor DB CPU and I/O — pause if thresholds exceeded |
| 424 | [ ] Run completion validation — confirm 0 unbackfilled rows |
| 425 | [ ] Run integrity checks — confirm 0 orphaned refs, 0 mismatches |
| 426 | |
| 427 | **Phase 3 — Enforce (T+[X days]):** |
| 428 | [ ] Confirm backfill 100% complete and stable for [X hours] |
| 429 | [ ] Add NOT VALID constraint |
| 430 | [ ] Run VALIDATE CONSTRAINT (monitor duration and lock waits) |
| 431 | [ ] Alter column to NOT NULL |
| 432 | [ ] Run Phase 3 validation queries |
| 433 | [ ] Deploy app version reading only from new column |
| 434 | [ ] Monitor error rate for [30 minutes] |
| 435 | |
| 436 | **Phase 4 — Contract (T+[X days after rollback window]):** |
| 437 | [ ] Confirm rollback window has passed — no incidents, no rollback needed |
| 438 | [ ] Search codebase for references to old column — confirm zero |
| 439 | [ ] Run DROP COLUMN migration |
| 440 | [ ] Run final integrity checks |
| 441 | [ ] Close migration ticket; update schema documentation |
| 442 | |
| 443 | |
| 444 | |
| 445 | ## Quality Checks |
| 446 | |
| 447 | [ ] Every migration phase has an independent rollback procedure — no phase assumes the next one has run |
| 448 | [ ] Batch backfill script includes a pause between batches to avoid saturating I/O |
| 449 | [ ] NOT NULL constraints use the NOT VALID + VALIDATE pattern on tables with >100k rows |
| 450 | [ ] The app dual-write period is explicitly defined — old column writes are not dropped until Phase 3 is deployed |
| 451 | [ ] Data validation queries include a row count check to confirm no data loss |
| 452 | [ ] Lock types are identified for every DDL statement — no "should be fine" assumptions |
| 453 | [ ] The deployment runbook names who runs each step, not just what to run |
| 454 | [ ] Phase 4 (contract) is explicitly gated on the rollback window passing — not run on the same day as Phase 3 |
| 455 | |
| 456 | ## Anti-Patterns |
| 457 | |
| 458 | [ ] Do not combine the expand and contract phases into a single deployment — they must be separated by a deployment cycle |
| 459 | [ ] Do not run DDL changes without first testing on a production-sized data clone |
| 460 | [ ] Do not skip the NOT VALID + VALIDATE pattern for constraint additions on large tables — it causes full table locks |
| 461 | [ ] Do not define a rollback as "restore from backup" — each phase must have an explicit, fast rollback procedure |
| 462 | [ ] Do not omit dual-write logic during the transition period — removing the old column before all writers are updated causes data loss |
| 463 |
Discussion
Browse more free Claude skills or everything in Development.


