Database Migration Plan Skill

Write a safe, zero-downtime database migration plan for a schema change.

Database Migration Plan Skill — The Skill Playground: pick the Executive Update skill, fill in a few notes, hit run, and watch a structured executive… (from the mohitagw15856/pm-claude-skills README)

From the mohitagw15856/pm-claude-skills README — shows the whole collection, not only this skill. · view on GitHub

How to use it

Claude Code
  1. Run the line below. It pulls the whole folder into ~/.claude/skills/database-migration-plan.
  2. Describe your job in plain words. Claude Code follows the skill from there.
Claude Code — installs the whole folder, not just SKILL.md
npx degit mohitagw15856/pm-claude-skills/skills/database-migration-plan#main ~/.claude/skills/database-migration-plan

For one project only, change the path to .claude/skills/database-migration-plan.

Claude (web or desktop app)
  1. On this page open ⋯ → Download .md.
  2. Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
  3. Pick the file and Save. Claude shows the name and description and runs a security scan.
  4. Check the skill is switched on.
  5. Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
  1. ChatGPT: make a Project and paste it into Instructions.
  2. 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.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Source of Database Migration Plan Skill

Show the full text463 lines
namedescription
database-migration-planWrite 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_id column 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.sql on 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---
2name: database-migration-plan
3description: "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 
8Produce 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 
10The 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 
14Ask 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 
58Before 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```
78Phase 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 
87Phase 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 
97Phase 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 
105Phase 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```sql
120-- Migration: 001_add_org_id_to_users.sql
121BEGIN;
122 
123-- Add nullable column (safe — old app ignores it)
124ALTER 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
129CREATE 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 
134COMMIT;
135```
136 
137**Validation after Phase 1:**
138```sql
139-- Confirm column exists and is nullable
140SELECT column_name, data_type, is_nullable
141FROM information_schema.columns
142WHERE table_name = 'users' AND column_name = 'org_id';
143-- Expected: is_nullable = 'YES'
144 
145-- Confirm index exists
146SELECT indexname, indexdef
147FROM pg_indexes
148WHERE tablename = 'users' AND indexname = 'users_org_id_idx';
149```
150 
151**Rollback (Phase 1 only):**
152```sql
153BEGIN;
154DROP INDEX CONCURRENTLY IF EXISTS users_org_id_idx;
155ALTER TABLE users DROP COLUMN IF EXISTS org_id;
156COMMIT;
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```sql
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 
178DO $$
179DECLARE
180 batch_size INT := 1000;
181 affected INT;
182BEGIN
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;
197END $$;
198```
199 
200**Monitoring during backfill:**
201```sql
202-- Check progress — run periodically during backfill
203SELECT
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
210FROM users;
211```
212 
213**Backfill completion validation:**
214```sql
215-- Must return 0 before proceeding to Phase 3
216SELECT COUNT(*) AS unbackfilled_rows
217FROM users
218WHERE org_id IS NULL;
219 
220-- Confirm no new rows written without org_id (dual-write working)
221SELECT COUNT(*) AS recent_missing
222FROM users
223WHERE 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```sql
242-- Step 1: Add constraint as NOT VALID (no full table scan — instant)
243ALTER 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
249ALTER 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)
254ALTER TABLE users
255 ALTER COLUMN org_id SET NOT NULL;
256 
257-- Step 4: Drop the now-redundant check constraint
258ALTER TABLE users
259 DROP CONSTRAINT users_org_id_not_null;
260```
261 
262**Validation after Phase 3:**
263```sql
264-- Confirm NOT NULL is enforced
265SELECT column_name, is_nullable
266FROM information_schema.columns
267WHERE 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)
271BEGIN;
272INSERT INTO users (email) VALUES ('[email protected]');
273-- Expected: ERROR: null value in column "org_id" violates not-null constraint
274ROLLBACK;
275```
276 
277**Rollback (Phase 3):**
278```sql
279-- Drop the NOT NULL constraint (restores nullable state)
280ALTER 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```sql
295BEGIN;
296 
297-- Drop the old column
298ALTER TABLE users DROP COLUMN account_id;
299 
300-- Drop any indexes that referenced the old column
301DROP INDEX IF EXISTS users_account_id_idx;
302 
303COMMIT;
304```
305 
306**Pre-drop validation:**
307```sql
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
313SELECT 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 
323Run these queries before and after the full migration to confirm data integrity.
324 
325**Pre-migration baseline:**
326```sql
327-- Record these values before any migration step
328SELECT COUNT(*) AS total_users FROM users;
329SELECT COUNT(*) AS total_orgs FROM organisations;
330SELECT MIN(created_at), MAX(created_at) FROM users;
331 
332-- Check for any anomalies in the source data before backfill
333SELECT COUNT(*) AS users_without_account
334FROM users WHERE account_id IS NULL;
335```
336 
337**Post-backfill integrity check:**
338```sql
339-- All users have an org that exists
340SELECT COUNT(*) AS orphaned_org_refs
341FROM users u
342WHERE 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
349SELECT COUNT(*) AS mismatched_backfill
350FROM users u
351JOIN accounts a ON u.account_id = a.id
352WHERE u.org_id != a.organisation_id;
353-- Expected: 0
354 
355-- Row count unchanged (no rows created or deleted by migration)
356SELECT COUNT(*) AS total_users_after FROM users;
357-- Must match pre-migration baseline
358```
359 
360**Post-contract final check:**
361```sql
362-- Old column is gone
363SELECT COUNT(*) FROM information_schema.columns
364WHERE table_name = 'users' AND column_name = 'account_id';
365-- Expected: 0
366 
367-- New column is NOT NULL
368SELECT is_nullable FROM information_schema.columns
369WHERE 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 
402Follow 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

Alternatives

Also in DatabasesSee all 533 in Development →