Database Migration Patterns
PLZero-downtime database migrations with AI agents split every risky schema change into expand, backfill, and contract phases, each deployed separately. Claude Code, Codex, or Cursor writes the SQL against a live schema read through a read-only MCP server, and a migration linter, a parity query, and a per-phase rollback prove it safe without a line-by-line review.
This recipe is for the developer who owns a Postgres schema change and the tech lead who has to approve it. You asked an agent to change products.price from dollars to integer cents. It returned one tidy ALTER TABLE products ALTER COLUMN price TYPE integer USING round(price * 100), the tests passed, and at 2 p.m. Postgres took an ACCESS EXCLUSIVE lock to rewrite a 40-million-row table. Every checkout query queued behind it. The migration was correct. It was not safe, and nothing in your pipeline was checking the difference.
What you’ll get from this recipe
Section titled “What you’ll get from this recipe”- A lock table that tells you which Postgres statements block traffic and what to write instead
- An expand-contract prompt that produces separate, independently deployable migrations, the dual-write code, and a rollback for each phase
- A read-only Postgres MCP server wired into Claude Code, Codex, and Cursor, so the agent plans against the real schema
- A verification gate you can put in CI: the Squawk migration linter, a throwaway-database apply, and a parity query that must return zero before the contract phase ships
- A recovery runbook for lock queues, half-finished backfills, invalid indexes, and stale rows written by old application versions
Why does a correct migration still take production down?
Section titled “Why does a correct migration still take production down?”Postgres runs most DDL under an ACCESS EXCLUSIVE lock, which blocks reads and writes on the table. A statement that holds that lock for milliseconds is harmless. A statement that holds it while it rewrites or scans a large table is an outage. Worse, a DDL statement that is merely waiting for the lock blocks every query that arrives after it, so a long-running report can turn a millisecond ALTER into a queue that stalls the whole table.
Agents write the short, obvious form of each statement because it is correct on an empty test database. The table below is the review knowledge the agent lacks; put it in the prompt and in the lint gate.
| Statement the agent writes | What it does on a large, busy table | Write this instead |
|---|---|---|
ALTER COLUMN … TYPE (most type changes) | Rewrites the table under ACCESS EXCLUSIVE | Expand-contract: new column, backfill, switch, drop |
ALTER COLUMN … SET NOT NULL | Scans the whole table under ACCESS EXCLUSIVE | CHECK (col IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, then SET NOT NULL |
CREATE INDEX | Blocks writes for the whole build | CREATE INDEX CONCURRENTLY, in its own non-transactional migration |
ADD FOREIGN KEY / ADD CHECK | Scans while blocking writes | Add as NOT VALID, then VALIDATE CONSTRAINT separately |
One UPDATE over every row | Long transaction, row locks, table bloat | Batches of about 1,000 rows by primary key, resumable |
| Any DDL with no timeout | Can queue behind a long query and block everything behind it | SET lock_timeout = '5s' first, then retry on failure |
VALIDATE CONSTRAINT takes only a SHARE UPDATE EXCLUSIVE lock, so reads and writes continue while it scans. The NOT NULL sequence in the third column works on PostgreSQL 12 and later: once a validated CHECK constraint proves the column has no nulls, SET NOT NULL skips the scan. We ran the full sequence below on PostgreSQL 16 on 2026-09-26 with SET client_min_messages = debug1, and Postgres reported at DEBUG1 “existing constraints on column “products.price_cents” are sufficient to prove that it does not contain nulls”.
Split a risky change into expand, backfill, and contract
Section titled “Split a risky change into expand, backfill, and contract”Expand-contract (also called parallel change) never mutates a column in place. You add the new shape, write to both shapes, copy the old data across, switch reads, and remove the old shape in a later release. Every phase is its own deploy, so you can stop, observe, and roll back between any two of them.
The order matters more than the SQL. The backfill runs after every application instance dual-writes; otherwise an old instance can update price after the backfill has passed that row, and price_cents silently goes stale.
-
Expand. Add the nullable column. This is a metadata-only change, so it holds its lock for milliseconds.
-- 001_expand_add_price_cents.sqlSET lock_timeout = '5s';SET statement_timeout = '5s';ALTER TABLE products ADD COLUMN IF NOT EXISTS price_cents integer; -
Deploy dual-write. Ship application code that writes both columns and reads the new one with a fallback. Wait until no instance of the old version is running.
// node-postgres. The ::integer casts are required: without them Postgres// rejects the statement with "inconsistent types deduced for parameter $1".async function setProductPrice(productId, priceCents) {await db.query(`UPDATE productsSET price_cents = $1::integer, price = $1::integer / 100.0WHERE id = $2`,[priceCents, productId],);}async function getProductPriceCents(productId) {// Round in SQL, exactly as the backfill does. In JavaScript 1.005 * 100 is// 100.49999..., so Math.round would return 100 where Postgres returns 101.const { rows } = await db.query('SELECT coalesce(price_cents, round(price * 100)::integer) AS cents FROM products WHERE id = $1',[productId],);return rows[0].cents;} -
Backfill in batches. Run this statement outside a transaction, repeatedly, until it updates zero rows. Each run is a short transaction, and a crashed run resumes where it stopped. The
price IS NOT NULLfilter matters: without it, rows with no price are “updated” to NULL and selected again on every run, so the loop never ends.-- 002_backfill_price_cents.sqlUPDATE productsSET price_cents = round(price * 100)WHERE id IN (SELECT id FROM productsWHERE price_cents IS NULLAND price IS NOT NULLORDER BY idLIMIT 1000);Rows where
priceis NULL stay NULL inprice_cents, and the contract phase below cannot make that columnNOT NULLwhile they exist. Count them now (SELECT count(*) FROM products WHERE price IS NULL) and decide before the contract release: fill them with an explicit default, or keepprice_centsnullable, leave out the check andSET NOT NULLin 003a to 003c, and still dropprice. -
Prove parity. This query is the oracle for the whole migration. It must return
0before reads switch, and again at the start of the contract release, while every instance still writes both columns.SELECT count(*) AS mismatchedFROM productsWHERE price_cents IS DISTINCT FROM round(price * 100); -
Switch reads to
price_centsand keep dual-writing. Deploy code that reads onlyprice_centsand still writes both columns. Run the parity query again after the deploy. Becausepriceis still written, rolling the application back to the dual-write version stays safe for as long as this phase lasts. -
Contract. In a later release, confirm parity is
0, then run three files, each in its own transaction. Most migration tools (Flyway, Rails, Prisma, node-pg-migrate, golang-migrate on Postgres) wrap each file in one transaction. IfADD CONSTRAINTandVALIDATE CONSTRAINTshare a transaction, theACCESS EXCLUSIVElock from the first statement is held through the whole validation scan, which is exactly the outage this page prevents.lock_timeoutdoes not help there: it limits how long a statement waits for a lock, not how long the scan holds it.-- 003a_contract_add_check.sqlSET lock_timeout = '5s';SET statement_timeout = '5s';ALTER TABLE productsADD CONSTRAINT price_cents_not_null CHECK (price_cents IS NOT NULL) NOT VALID;-- 003b_contract_validate_check.sql (SHARE UPDATE EXCLUSIVE: reads and writes continue)SET lock_timeout = '5s';SET statement_timeout = '30min'; -- sized to the full scan, not to the lock waitALTER TABLE products VALIDATE CONSTRAINT price_cents_not_null;Then deploy the code that stops writing
price, and run the last file straight after it. From this deploy on,pricegoes stale, so it is the point of no cheap return.-- squawk-ignore-file adding-not-nullable-field, ban-drop-column-- 003c_contract_drop_price.sqlSET lock_timeout = '5s';SET statement_timeout = '5s';ALTER TABLE products ALTER COLUMN price_cents SET NOT NULL; -- skips the scan: the check proves itALTER TABLE products DROP CONSTRAINT IF EXISTS price_cents_not_null;ALTER TABLE products DROP COLUMN IF EXISTS price;The first line silences two Squawk rules for this file only, on purpose.
adding-not-nullable-fieldcannot see theCHECKthat 003b validated, so it reports a table scan that Postgres does not run.ban-drop-columnwarns about the drop, which is the intended contract step. Leave every other rule on.
You do not have to write this sequence yourself. The agent writes it; your job is to hand it the constraints above and to check the result with the gate described later.
Give the agent the live schema through a read-only MCP server
Section titled “Give the agent the live schema through a read-only MCP server”The prompt above is only as good as the schema the agent reads. If you paste an old \d products output, the agent plans a migration for a table that no longer exists. A Postgres MCP server lets the agent read column types, row estimates, and existing indexes itself, and choose batch sizes to match.
Postgres MCP Pro (PyPI postgres-mcp, from Crystal DBA) has an explicit access mode: --access-mode=restricted limits it to read-only transactions with an execution-time limit. Run it with uvx. Two cautions from our check on 2026-09-26: the latest PyPI release is 0.3.0 from 2025-05-16, so pin it and watch the repository, and the npm package also named postgres-mcp is unrelated, so do not start it with npx. The old @modelcontextprotocol/server-postgres package is deprecated on npm and archived; do not use it.
The server configuration is the same for all three tools; only the way you register it differs.
# Terminal, in the repository root. Put the server name BEFORE -e:# -e takes several values, so a name after it is read as another variable.claude mcp add postgres -e DATABASE_URI=postgresql://agent_ro@localhost:5432/app_staging \ -- uvx postgres-mcp --access-mode=restrictedAdd --scope project to write it to .mcp.json so the whole team gets the same server. We ran both orders on Claude Code 2.1.283: the one above works, and --env KEY=value postgres -- … fails with “Invalid environment variable format: postgres”.
# Terminal. Registers the server in Codex's config.toml.codex mcp add postgres --env DATABASE_URI=postgresql://agent_ro@localhost:5432/app_staging \ -- uvx postgres-mcp --access-mode=restrictedcodex mcp list shows the result. Checked against codex mcp add --help in Codex CLI 0.157.1.
Add the server to .cursor/mcp.json in the project, the same mcpServers shape Claude Code writes to .mcp.json:
{ "mcpServers": { "postgres": { "command": "uvx", "args": ["postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "postgresql://agent_ro@localhost:5432/app_staging" } } }}If you want Postgres review knowledge rather than a live connection, a skill is lighter. Supabase’s supabase-postgres-best-practices skill covers Postgres performance and row-level security; install it with npx skills add supabase/agent-skills --skill supabase-postgres-best-practices (the skills CLI was 1.7.0 on npm on 2026-09-26). A skill does not read your database, so use it alongside the MCP server, not instead of it. Installing and managing skills covers scopes and updates.
Gate every migration with a linter, a real apply, and a parity query
Section titled “Gate every migration with a linter, a real apply, and a parity query”Asking the agent to review its own migration catches some hazards, but the same model that wrote the SQL judges it. Put a deterministic gate in front of the merge instead, and let the human review the evidence the gate produces.
Squawk is a linter for PostgreSQL migrations (npm squawk-cli, 2.66.0 on 2026-09-26). We ran it on a file containing the four dangerous statements from the lock table. It flagged all four, including “Setting a column NOT NULL blocks reads while the table is scanned”, added require-lock-timeout warnings, and exited with status 1, which is what fails a CI job. Squawk exits 1 on warnings as well as errors, so a green job means zero findings, not zero errors.
# CI or terminal: lint only the migrations this branch adds.npx squawk-cli@2.66.0 $(git diff --name-only --diff-filter=A origin/main -- 'db/migrations/*.sql')
# Apply the chain to a throwaway Postgres. The backfill does 1,000 rows per run,# so repeat it until no row is left, then check parity. The counter stops a loop# that never converges (1,000 runs covers a 1-million-row seed).psql "$TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f db/migrations/001_expand_add_price_cents.sqlruns=0until [ "$(psql "$TEST_DATABASE_URL" -At -c \ 'SELECT count(*) FROM products WHERE price_cents IS NULL AND price IS NOT NULL')" = 0 ]; do runs=$((runs + 1)) [ "$runs" -le 1000 ] || { echo "backfill did not converge" >&2; exit 1; } psql "$TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f db/migrations/002_backfill_price_cents.sqldonepsql "$TEST_DATABASE_URL" -At -c \ "SELECT count(*) FROM products WHERE price_cents IS DISTINCT FROM round(price * 100)"Three settings make Squawk fit your migration tool. If the tool wraps each file in a transaction, pass --assume-in-transaction so Squawk reasons about the real execution. In that mode Squawk 2.66.0 flags a VALIDATE CONSTRAINT in the same file as its NOT VALID add (“Using NOT VALID and VALIDATE CONSTRAINT in the same transaction will block all reads while the constraint is validated”), which is the trap the three contract files avoid; we checked this on 2026-09-26. Rules that do not apply to you go in .squawk.toml or --exclude, each with a reason. With the configuration below, all five files on this page pass Squawk 2.66.0 with exit status 0; we checked on 2026-09-26.
# .squawk.toml, in the repository root# Our migration tool wraps each file in its own transaction.assume_in_transaction = true# price_cents holds cents: int4 allows about 21 million dollars per product.excluded_rules = ["prefer-bigint-over-int"]Squawk can also comment on the pull request with squawk upload-to-github, or through the squawk-action.
Who signs off, and on what:
- CI blocks the merge on any Squawk finding (warnings included), any failed apply, or a non-zero parity count on the seeded test database.
- The migration owner reviews the lock-per-statement list the agent produced and the Squawk output, not every line of SQL.
- The contract release needs one more artifact in the pull request: the parity query result from production (run through the read-only role) showing
0. That goes in the evidence bundle for agent pull requests.
Run the migration loop in Claude Code, Codex, or Cursor
Section titled “Run the migration loop in Claude Code, Codex, or Cursor”The prompts and the gate are identical across tools. What differs is how you run the loop unattended and where you review the result.
Generate, lint, and apply in one headless run. --allowedTools pre-approves exactly these tools. A claude -p session starts in Manual mode, or in auto mode on third-party providers or with telemetry off (from v2.1.285), where a classifier decides instead of the allow list, so --permission-mode dontAsk is what refuses every other tool call that would prompt on every provider.
# Terminal or CI. TEST_DATABASE_URL points at a local throwaway database.claude -p "Write an expand-contract migration in db/migrations/ that replaces \products.price with products.price_cents, following docs/migrations.md. Then run \exactly 'npx squawk-cli@2.66.0 --assume-in-transaction <new files>', apply them \with 'npm run db:migrate' against the test database, run 'npm test -- migrations', \and report the lock each statement takes." \ --permission-mode dontAsk \ --allowedTools "Read" "Edit" "Write" "Bash(npx squawk-cli@2.66.0 *)" \ "Bash(npm run db:migrate)" "Bash(npm test *)" \ --output-format json > migration-run.jsonEach Bash(...) rule matches a command prefix, so Bash(npx squawk-cli *) would refuse the pinned npx squawk-cli@2.66.0; keep the rule and the prompt on the same spelling. --allowedTools skips prompts for the listed tools; to remove every other tool from the session entirely, use --tools instead. Keep migration-run.json as the CI record of what ran. Flags checked against claude --help 2.1.283.
Run the change in a new managed Git worktree so it never touches your working checkout.
# Terminal. network_access lets the sandboxed commands reach the local test database.codex exec --worktree --sandbox workspace-write \ -c sandbox_workspace_write.network_access=true \ "Write an expand-contract migration in db/migrations/ replacing products.price with \products.price_cents: expand, batched backfill run outside a transaction, and contract \split into add-check, validate and drop files that each run in their own transaction. Run npx squawk-cli@2.66.0 on the new files, apply them to TEST_DATABASE_URL, run the \migration tests, and list the lock each statement takes."In workspace-write, network access is off unless you set sandbox_workspace_write.network_access, so without it the apply step cannot reach Postgres. Export TEST_DATABASE_URL before the run. If the apply step reports that the variable is empty, your shell_environment_policy is filtering it out of sandboxed commands: add -c shell_environment_policy.inherit=all (the form codex --help shows) or write the URL into the prompt. Add --json for a JSONL event log. Flags checked against codex exec --help in Codex CLI 0.157.1.
Start in Plan Mode with the expand-contract prompt, so the agent proposes the file split before it writes anything. Check two things in the plan: the contract step is split into separate files (add check, validate, drop), apart from expand, and the backfill has a LIMIT and a resumable WHERE clause.
Then let the agent write the files and review the multi-file diff before you accept it. Run the same Squawk and apply commands from the gate section in the integrated terminal, or leave them to CI. Commit before you accept a large edit, so git restore is always your way back.
Copy data between tables with a resumable, validated batch job
Section titled “Copy data between tables with a resumable, validated batch job”When you reshape data rather than a column, such as moving legacy_users into users, the agent should produce a batch job with four properties: keyset pagination, idempotent writes, rejected rows recorded instead of crashing the run, and a reconciliation query at the end.
// Batched copy from legacy_users to users. db is a node-postgres client.async function migrateUsers({ batchSize = 1000, startAfterId = 0 } = {}) { let cursor = startAfterId; const rejected = [];
for (;;) { const { rows } = await db.query( 'SELECT id, email_address FROM legacy_users WHERE id > $1 ORDER BY id LIMIT $2', [cursor, batchSize], ); if (rows.length === 0) break;
for (const row of rows) { const email = row.email_address?.trim().toLowerCase(); if (!email?.includes('@')) { rejected.push({ id: row.id, reason: 'invalid email' }); continue; } await db.query( 'INSERT INTO users (email, legacy_id) VALUES ($1, $2) ON CONFLICT (legacy_id) DO NOTHING', [email, row.id], ); } cursor = rows[rows.length - 1].id; // log this: it is your resume point } return { lastId: cursor, rejected };}WHERE id > $1 ORDER BY id stays fast at any depth, where OFFSET gets slower with every page. ON CONFLICT (legacy_id) DO NOTHING makes a re-run safe, provided users.legacy_id has a unique constraint. The job is done when this query returns only the rows you rejected on purpose:
SELECT l.id FROM legacy_users lLEFT JOIN users u ON u.legacy_id = l.idWHERE u.legacy_id IS NULL;For MySQL, ask the agent to drive a proven online schema change tool rather than hand-writing triggers. pt-online-schema-change copies the table and keeps it in sync with triggers. gh-ost is triggerless: it replays changes from the binary log, which keeps trigger overhead off a write-heavy source table.
How do you roll back each migration phase?
Section titled “How do you roll back each migration phase?”Expand-contract gives you a cheap rollback at every phase except the last. Put this table in the pull request so the on-call engineer does not have to work it out at 2 a.m.
| Phase | Roll back with | Data at risk |
|---|---|---|
| Expand | ALTER TABLE products DROP COLUMN IF EXISTS price_cents; | None; the column held no data yet |
| Dual-write deploy | Redeploy the previous application version | None; price is still written |
| Backfill | Stop the job; nothing to undo | None; re-run later |
| Switch reads | Redeploy the dual-write version | None; price is still written in this phase |
| Contract: add and validate the check (003a, 003b) | ALTER TABLE products DROP CONSTRAINT IF EXISTS price_cents_not_null; | None; both columns are still written |
Contract: stop writing price, then 003c | Before 003c: re-backfill price from price_cents, then redeploy the dual-write version. After 003c: restore from backup, or re-add price and fill it from price_cents | High: price goes stale from the deploy on, and DROP COLUMN is the one step you cannot undo cleanly |
That last row is why the contract phase waits a release, needs the production parity result, and runs 003c straight after the deploy that stops writing price. Take a backup, or at least a pg_dump --table=products of the table you are about to change, before it runs.
When database migrations break
Section titled “When database migrations break”Where to go next
Section titled “Where to go next”- Before this page: database development with an MCP server covers schema design and query work with a live connection.
- Database MCP servers compared: which server fits your database and how to scope its access.
- ORM patterns: the same safety rules when Prisma or TypeORM generates the migration.
- SQL optimization patterns: index choices driven by
EXPLAIN ANALYZE, which your concurrent index builds should follow. - Tool deep dives: migrations in Claude Code and database work in Codex.
- After this page: progressive delivery for rolling the dual-write and read-switch deploys out gradually, and the evidence bundle for what the migration pull request must prove.
Did this guide help you?
One tap, no sign-in.
What will you do with it?
How did it read?
What got in the way? Pick any that apply
Your role?
Thanks. I read every answer. Michał Jaskólski
Did this guide help?
One tap each. Close whenever you like.
no sign-in