Skip to content

Zero-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.

  • 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 writesWhat it does on a large, busy tableWrite this instead
ALTER COLUMN … TYPE (most type changes)Rewrites the table under ACCESS EXCLUSIVEExpand-contract: new column, backfill, switch, drop
ALTER COLUMN … SET NOT NULLScans the whole table under ACCESS EXCLUSIVECHECK (col IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, then SET NOT NULL
CREATE INDEXBlocks writes for the whole buildCREATE INDEX CONCURRENTLY, in its own non-transactional migration
ADD FOREIGN KEY / ADD CHECKScans while blocking writesAdd as NOT VALID, then VALIDATE CONSTRAINT separately
One UPDATE over every rowLong transaction, row locks, table bloatBatches of about 1,000 rows by primary key, resumable
Any DDL with no timeoutCan queue behind a long query and block everything behind itSET 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.

  1. Expand. Add the nullable column. This is a metadata-only change, so it holds its lock for milliseconds.

    -- 001_expand_add_price_cents.sql
    SET lock_timeout = '5s';
    SET statement_timeout = '5s';
    ALTER TABLE products ADD COLUMN IF NOT EXISTS price_cents integer;
  2. 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 products
    SET price_cents = $1::integer, price = $1::integer / 100.0
    WHERE 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;
    }
  3. 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 NULL filter 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.sql
    UPDATE products
    SET price_cents = round(price * 100)
    WHERE id IN (
    SELECT id FROM products
    WHERE price_cents IS NULL
    AND price IS NOT NULL
    ORDER BY id
    LIMIT 1000
    );

    Rows where price is NULL stay NULL in price_cents, and the contract phase below cannot make that column NOT NULL while 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 keep price_cents nullable, leave out the check and SET NOT NULL in 003a to 003c, and still drop price.

  4. Prove parity. This query is the oracle for the whole migration. It must return 0 before reads switch, and again at the start of the contract release, while every instance still writes both columns.

    SELECT count(*) AS mismatched
    FROM products
    WHERE price_cents IS DISTINCT FROM round(price * 100);
  5. Switch reads to price_cents and keep dual-writing. Deploy code that reads only price_cents and still writes both columns. Run the parity query again after the deploy. Because price is still written, rolling the application back to the dual-write version stays safe for as long as this phase lasts.

  6. 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. If ADD CONSTRAINT and VALIDATE CONSTRAINT share a transaction, the ACCESS EXCLUSIVE lock from the first statement is held through the whole validation scan, which is exactly the outage this page prevents. lock_timeout does not help there: it limits how long a statement waits for a lock, not how long the scan holds it.

    -- 003a_contract_add_check.sql
    SET lock_timeout = '5s';
    SET statement_timeout = '5s';
    ALTER TABLE products
    ADD 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 wait
    ALTER 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, price goes stale, so it is the point of no cheap return.

    -- squawk-ignore-file adding-not-nullable-field, ban-drop-column
    -- 003c_contract_drop_price.sql
    SET lock_timeout = '5s';
    SET statement_timeout = '5s';
    ALTER TABLE products ALTER COLUMN price_cents SET NOT NULL; -- skips the scan: the check proves it
    ALTER 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-field cannot see the CHECK that 003b validated, so it reports a table scan that Postgres does not run. ban-drop-column warns 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 window
# 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=restricted

Add --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”.

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.

Terminal window
# 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.sql
runs=0
until [ "$(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.sql
done
psql "$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 window
# 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.json

Each 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.

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 l
LEFT JOIN users u ON u.legacy_id = l.id
WHERE 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.

PhaseRoll back withData at risk
ExpandALTER TABLE products DROP COLUMN IF EXISTS price_cents;None; the column held no data yet
Dual-write deployRedeploy the previous application versionNone; price is still written
BackfillStop the job; nothing to undoNone; re-run later
Switch readsRedeploy the dual-write versionNone; 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 003cBefore 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_centsHigh: 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.

Did this guide help you?

One tap, no sign-in.

not at all a lot