# Database and code migrations with Claude Code

URL: https://developertoolkit.ai/en/claude-code/build/migrations/
Description: Run zero-downtime schema changes, framework upgrades and API version moves with Claude Code, phase by phase, each proven by a gate before the next ships.

A migration with Claude Code is a sequence of separately shipped phases (expand, backfill, switch, contract for a schema; codemod, agent fixes, verification for a framework upgrade) that Claude Code plans, writes and tests on a disposable database or branch. The qualifier that matters: each phase ships only after a gate proves it, and CI, not the agent, applies it.

This page is for developers who run the migration in the terminal and for tech leads who approve it. Your `users` table has 12 million rows and `name` has to become `first_name` and `last_name`. Express 4 has to become Express 5 across 180 route files. Your payment provider switches off its v1 API at the end of the quarter. Claude Code can write every file for all three in an afternoon, and one of those files can still take production down.

## What you'll walk away with from migrations in Claude Code

- A session setup that keeps the agent on a scratch database and a branch you can throw away.
- A plan-mode inventory, built by subagents, that tells you the blast radius before any file changes.
- Copy-paste prompts for an expand-contract schema change, a resumable backfill, a framework upgrade, an ORM upgrade and an API version move behind a flag.
- A gate for each kind of migration, so you check evidence rather than every line the agent wrote.
- The ways migrations break in Claude Code specifically, including what `/rewind` cannot undo, with the recovery for each.

## Which kind of migration are you running?

The workflow is the same for every migration: inventory, plan in phases, execute one phase, prove it, then ship it. What changes is the tool that does the bulk work and the evidence that proves a phase.

| Migration | Bulk work done by | Proof that a phase is safe | Deep dive |
| --- | --- | --- | --- |
| Schema change on a live table | Claude Code writes phase files; your migration tool applies them | Lock lint, up-down-up on a scratch database, a parity query that returns `0` | [Database migration patterns](/en/cookbook/database-recipes/migration-patterns/) |
| Framework or library major version | The vendor's codemod first, then Claude Code for what it missed | Type check, full test suite, a grep that finds no old API | [Batch operations](/en/claude-code/build/batch-operations/) |
| ORM major version | Claude Code, from the vendor's upgrade guide | The SQL the ORM emits, diffed before and after | [ORM patterns](/en/cookbook/database-recipes/orm-patterns/) |
| Third-party API version | Claude Code, behind a feature flag | Contract tests against recorded v2 responses, then a shadow comparison | [Progressive delivery](/en/workflows/ship/progressive-delivery/) |

Whole-system rewrites (a language change, a monolith split) are a program, not a migration. They need characterization tests and slice plans; see [legacy modernization with agents](/en/workflows/build/legacy-modernization/).

## Set up the session before the migration starts

Four steps keep a migration reversible. Do them in this order, because each one assumes the previous one.

1. **Work in a worktree.** `claude -w users-name-split` (`--worktree` in `claude --help` 2.1.283) creates a git worktree and starts the session inside it, so a failed attempt is a directory you delete.

2. **Point the shell at a scratch database.** Claude Code runs commands with your environment. With v2.1.283 or later (both release channels on 2026-10-12), auto mode is the starting permission mode for interactive terminal sessions on the models it supports, so a classifier, not you, approves most commands. If `DATABASE_URL` points at staging, one `migrate deploy` reaches it. Start a throwaway Postgres and export its URL before you run `claude`. [Database work in Claude Code](/en/claude-code/build/database-work/) has the container command, the deny rules for your migration tool and the read-only Postgres MCP Pro server for looking at staging:

   ```bash
   # Terminal, repo root: read-only Postgres MCP Pro for staging, from Database work
   claude mcp add -s project postgres -e 'DATABASE_URI=${STAGING_DATABASE_URL}' \
     -- uvx postgres-mcp --access-mode=restricted
   ```

3. **Commit the baseline.** Checkpoints and `/rewind` restore only edits made through Claude's file-editing tools. A codemod, `sed` or `mv` run through Bash is not tracked. A commit before each phase is your real undo.

4. **Write the migration rules into `CLAUDE.md`.** Every session, subagent and `/batch` worker reads them. See [the memory system](/en/claude-code/build/memory-system/) for placement.

   ```markdown
   ## Migrations
   - Never edit a migration file that has been applied anywhere. Write a new one.
   - One phase per migration file: expand, backfill, contract are separate files and separate deploys.
   - Every file starts with SET lock_timeout = '5s' and names the lock each statement takes.
   - The only database you may touch is $DATABASE_URL (local scratch). Never run migrate deploy.
   - Do not edit tests to make a migration pass. Report the failing test instead.
   ```

To make the first rule mechanical instead of a request, add a `PreToolUse` hook that refuses `Edit` and `Write` on applied migration directories. [Claude Code hooks](/en/claude-code/automate/hooks-automation/) has a tested `protect-paths.sh` you can adapt.

## Map the blast radius in plan mode

Plan mode lets Claude Code read and propose without editing. Press Shift+Tab until the mode indicator shows plan mode, or start with `claude --permission-mode plan`. Ask for an inventory from parallel Explore subagents: each searches one area and returns a summary, so the main session keeps its context for the plan.

**Copy-paste prompt: migration inventory and phase plan**

```text
We are splitting users.name into users.first_name and users.last_name on PostgreSQL 16.
The table has about 12 million rows and takes writes all day.

Use three Explore subagents in parallel:
1. Every read and write of users.name: ORM calls, raw SQL, views, triggers, seed files,
   fixtures, analytics exports and background jobs. File and line, grouped by read or write.
2. Every consumer outside this repo that we can see evidence of: API responses that
   expose name, event payloads, CSV exports, webhooks.
3. The migration history: which tool runs migrations, whether it wraps each file in a
   transaction, and how past migrations handled backfills.

Then write MIGRATION.md with one section per phase (expand, dual write, backfill,
switch reads, contract). For each phase give: the files it changes, the deploy it
belongs to, the gate that proves it, and the rollback. Do not edit any other file.
```

`MIGRATION.md` becomes the contract for the rest of the work. Review it for three things: the backfill comes after every instance dual-writes, the contract phase is a separate release, and every phase names its rollback. Approve the plan, then run each phase as its own task. If the session's context fills up, run `/clear` and start the next phase with "Read MIGRATION.md and do phase 3".

## Change a live schema with expand and contract

Expand-contract never changes a column in place. You add the new shape, write both shapes, copy the old data, switch reads, and drop the old shape a release later. Old and new application versions both work at every step, so a rollback between phases is a redeploy, not a restore.

**Copy-paste prompt: expand-contract column split**

```text
Implement phases 1 and 2 of MIGRATION.md (expand and dual write). Use our migration
tool's conventions from the last five files in db/migrations/.

Expand migration:
- SET lock_timeout = '5s'; add first_name and last_name as nullable text columns.
- A BEFORE INSERT OR UPDATE row trigger that assigns NEW.first_name and NEW.last_name
  from NEW.name on INSERT when they are null, and on UPDATE when name changed and they
  did not (NEW.name IS DISTINCT FROM OLD.name AND NEW.first_name IS NOT DISTINCT FROM
  OLD.first_name), so application instances still on the old version stay consistent.
  Never UPDATE the table from the trigger. Add a pg_trigger_depth() guard anyway.
- A down migration that drops the trigger and both columns.

Dual write: update the application so every write sets name, first_name and last_name.
Keep reads on name for now.

Splitting a name is lossy. Define the split rule in a SQL function, and write a query
that lists rows where first_name || ' ' || last_name would not reproduce name exactly.
Name the lock every statement takes. Run nothing except against $DATABASE_URL.
```

Prove the expand file on the scratch database before anything else: apply it, apply the down file, compare `pg_dump --schema-only` output before and after, and apply it again. [Database work in Claude Code](/en/claude-code/build/database-work/) has that up-down-up script. An empty schema diff proves the rollback without anyone reading the SQL. Then lint the file for dangerous locks with Squawk, the PostgreSQL migration linter; it exits non-zero on an error, so the same line works as a CI step:

```bash
# Terminal or CI: lint every migration file this branch adds
FILES=$(git diff --name-only --diff-filter=A origin/main -- 'db/migrations/*.sql')
[ -z "$FILES" ] || npx squawk-cli@2.66.0 $FILES
```

### Backfill a large table without locking it

A single `UPDATE` over 12 million rows holds its locks for one long transaction and bloats the table. Ask for a batch job that can stop and resume.

**Copy-paste prompt: resumable backfill**

```text
Write scripts/backfill-user-names.ts for phase 3 of MIGRATION.md.

- Keyset pagination: WHERE id > $cursor ORDER BY id LIMIT 5000. No OFFSET.
- Only rows where first_name IS NULL, so a re-run skips finished rows.
- One short transaction per batch, a 100 ms pause between batches, and a --start-after
  flag so a crashed run resumes from the last logged id.
- Log the last id and rows updated every 10 batches.
- Rows the split function cannot handle go to a user_name_review table, not an exception.
- Finish with the parity query from MIGRATION.md and print its count.

Run it against $DATABASE_URL after seeding 50,000 users that include empty names,
single-word names, names with several spaces and non-ASCII names. Show me the parity
count and the review-table count.
```

The order is the whole point. If the backfill starts before every instance dual-writes, an old instance can update `name` after the backfill passed that row, and the new columns go stale without any error. Lock-by-lock guidance, the Squawk lint gate and the rollback per phase are on [database migration patterns](/en/cookbook/database-recipes/migration-patterns/).

## Upgrade a framework: codemod first, agent second

A vendor codemod is deterministic and cheap, so run it before you spend tokens. Claude Code then handles what the codemod does not cover, reading the vendor's migration guide rather than its own memory of the API.

For Express 4 to 5, the Express team's `@expressjs/codemod` (0.0.5) ships four transforms: the magic `"back"` redirect, pluralized method names, the v4 deprecated signatures, and `req.param()`. npm marks the package as deprecated (checked 2026-09-26) and points to the Express codemods in the codemod.com registry (scope `expressjs`) instead. This page keeps the npm package because it still runs and covers these four transforms; treat the registry as the place to look for newer recipes. Its `upgrade` command has no dry run, so run it on a clean tree and let `git diff` be the preview; `git restore .` undoes it:

```bash
# Terminal, in the worktree, after the baseline commit
npx @expressjs/codemod upgrade src
git add -A && git commit -m "Express 5: apply official codemod"
```

Route path strings are a common gap: Express 5 parses them with `path-to-regexp` 8 (through `router` 2), so wildcard and optional-segment syntax needs changes the codemod does not make.

**Copy-paste prompt: finish a framework upgrade**

```text
We are upgrading Express 4 to Express 5. The official codemod has already run and is
committed. I am pasting the Express 5 migration guide below.

1. List every breaking change in the guide and, for each, grep the codebase for the
   patterns it affects. Mark each change as: done by the codemod, needs a change here,
   or does not apply. Show the table before editing.
2. Fix the "needs a change" items. Route path strings first, then error handling in
   async handlers, then the rest.
3. After each group: npx tsc --noEmit and npm test. Stop and report if a test fails for
   a reason you cannot explain from the guide.
4. Finish with a grep for every removed API that returns no matches, and paste it.
Do not edit test files. Do not change package.json except the express version.
```

When the change touches hundreds of files across many directories, `/batch` splits it into 5 to 30 units, each in its own worktree with its own pull request. Give it the verification recipe from steps 3 and 4 above; [batch operations](/en/claude-code/build/batch-operations/) covers the prompt and the merge order.

### Upgrade an ORM by diffing the SQL it emits

An ORM upgrade can pass every test and still change behaviour: a different default ordering, `NULL` handling in a filter, or a new query shape that skips an index. The type checker cannot see any of that. The SQL can.

**Copy-paste prompt: ORM upgrade with a SQL diff**

```text
We are moving our ORM up one major version. The vendor's upgrade guide is pasted below.

Before changing anything: enable query logging in the test setup, run the full test
suite on the current version, and save every distinct SQL statement (parameters
stripped, whitespace normalised, sorted) to tmp/sql-before.txt.

Then apply the upgrade: bump the package, regenerate the client, and fix every call
site the guide lists. Run the suite again and save tmp/sql-after.txt the same way.

Show me the diff between the two files. For each changed statement, name the test that
produced it and say whether the result set can differ (ordering, NULLs, joins, limits).
Do not change test expectations. List failing tests with your explanation instead.
```

The SQL diff is what the reviewer reads. A handful of changed statements, each explained, is a review of the whole upgrade; the call-site edits behind them are covered by the type check and the tests.

## Move a third-party API version behind a flag

An API version move changes code whose other side you do not control, so ship it dark first. Record real v2 responses in the provider's sandbox, build contract tests on them, and switch traffic with a flag.

**Copy-paste prompt: API version move behind a flag**

```text
Our payment provider retires its v1 API. The v2 migration guide is pasted below.

1. Find every call to the provider's API: SDK calls, raw HTTP, webhooks we receive.
   Map each v1 endpoint and webhook event to its v2 equivalent in a table. Flag any
   v1 behaviour with no v2 equivalent and stop there for those.
2. Put a PaymentsGateway interface in front of every call, with a v1 and a v2
   implementation. Select the implementation with the PAYMENTS_API_V2 flag, off by default.
3. Write contract tests for the v2 implementation against the recorded sandbox
   responses in tests/fixtures/payments-v2/. Cover the error format, idempotency keys
   and pagination.
4. Add a shadow mode: when PAYMENTS_API_SHADOW=true, read-only calls go to both
   versions and differences are logged, never returned to the user.
Run the unit and contract tests and show me the results.
```

Turn shadow mode on in production for read-only calls, watch the difference log reach zero, then move write traffic with the flag in steps. [Progressive delivery](/en/workflows/ship/progressive-delivery/) covers the rollout and the automatic rollback trigger.

## How do you know the migration is right without reading every line?

Each phase leaves evidence behind. The reviewer reads the evidence, and spends line-by-line attention only where no gate can judge.

| Phase output | Gate that proves it | Who signs off |
| --- | --- | --- |
| `MIGRATION.md` plan | Every phase names its deploy, gate and rollback; the backfill follows dual write | Tech lead, before plan mode is approved |
| Expand or contract file | Squawk passes; up, down, up on a scratch database with an empty schema diff; CI repeats it | Schema owner, through `CODEOWNERS` on the migrations directory |
| Backfill | Parity query returns `0` on seeded edge cases, then on production through a read-only role | Migration owner, with both counts in the pull request |
| Framework upgrade | Type check, full suite, and a grep for removed APIs that returns nothing | CI |
| ORM upgrade | SQL diff before and after, each changed statement explained | Reviewer of the pull request |
| API version move | Contract tests on recorded responses; shadow-mode difference log at zero | Service owner, before the flag moves |

Two things are always read by a human: the contract phase that drops data, and any step the plan marks irreversible. Migrations are one of the few change classes where that reading still pays, as [reading evidence instead of code](/en/ladder/evidence-not-diffs/) explains. Everything else goes into the [evidence bundle](/en/workflows/ship/evidence-bundle/) of the pull request.

To repeat the check in CI without a person, run a read-only review of the new migration files with `claude -p`. It starts in Manual mode (auto mode on third-party providers or with telemetry off, from v2.1.285, where a classifier decides) and cannot answer permission prompts, so the command passes `--permission-mode dontAsk`, which denies anything that would prompt (file reads in the working directories and read-only Bash commands still run). `--allowedTools` adds the tools the review needs, and `--json-schema` makes the output a file a later step can check:

```bash
# CI job on a trusted branch (push to main or a same-repo pull request)
FILES=$(git diff --name-only --diff-filter=A origin/main -- 'db/migrations/*.sql')
claude -p "Review these new migration files: $FILES. For every statement, name the lock it takes, whether the paired down migration reverses it, and whether it can lose data." \
  --bare --setting-sources "" --strict-mcp-config \
  --permission-mode dontAsk \
  --allowedTools "Read Grep Glob" \
  --output-format json \
  --json-schema "$(cat .github/migration-review.schema.json)" \
  > migration-review.json
jq -e '.subtype == "success" and .is_error == false' migration-review.json > /dev/null
```

The schema file describes an array of `{file, statement, lock, reversible, loses_data}` objects; a follow-up step fails the job when any entry has `loses_data: true` without a human approval label. The `jq -e` line fails the job when the run did not finish, so a crashed or capped review never passes as an empty one. `--bare --setting-sources "" --strict-mcp-config` keeps the branch's own hooks, settings and `.mcp.json` from loading next to `ANTHROPIC_API_KEY`; `--bare` authenticates only with that key. On a pull request the workflow, the schema file and the follow-up step come from the pull request itself, so put `.github/` under `CODEOWNERS` with required review, or a pull request can weaken the check in the same diff. Run it only on trusted branches, and never on fork code under `pull_request_target`: the hooks and MCP servers in the contributor's `.claude/` would run with the job's secrets. [CI/CD with Claude Code](/en/claude-code/ship/ci-cd/) has the safe workflow for that case.

## What breaks when you migrate with Claude Code

**`/rewind` did not undo the codemod.** Checkpoints track only edits made through Claude's file-editing tools, not files changed by a Bash command. Recover with git: `git restore .` back to the baseline commit, or `git reset --hard` in the worktree. Prevent it with a commit before every phase.

**The backfill runs out of memory or slows the primary.** The script loaded rows into memory or ran without pauses. Stop it; the `first_name IS NULL` filter means nothing is lost. Ask Claude Code to lower the batch size, stream with keyset pagination and log the last id, then resume with `--start-after`.

**The sync trigger recurses or doubles write latency.** A BEFORE row trigger that only assigns `NEW` columns does not recurse. If the agent wrote an AFTER trigger that updates its own table, it fires again: rewrite it as a BEFORE trigger and keep the `pg_trigger_depth()` guard. Either way, measure insert latency on the scratch database before and after the expand file. If the cost is too high, drop the trigger and rely on application dual write, which means the backfill must wait until no old instance is running.

**The parity query is not zero after the backfill.** An instance on the old version wrote `name` only, or the split function disagrees with itself on edge cases. Confirm every instance runs the dual-write version, run a repair batch that filters on the mismatch instead of `IS NULL`, and check the review table. Do not ship the contract phase until the count is `0`.

**The migration passes on seeded data and fails in production.** Production has values your seed does not: `NULL` where the schema allows it, empty strings, very long strings, unusual Unicode. Ask Claude Code for a pre-flight query that counts each of those in the affected columns, run it through the read-only role, and add the cases to the seed.

**Claude Code edited an applied migration or a test to get to green.** The session had write access to both. Revert the file from git, restate the rule, and make it mechanical with the `protect-paths.sh` hook and an `Edit` deny rule for test files. See [protecting the oracle](/en/workflows/test/protect-the-oracle/).

**A long upgrade loses the thread halfway.** The context filled with test output and the early decisions were summarized away. Keep state in `MIGRATION.md`, ask Claude Code to tick off each phase in it, and start each phase in a fresh session.

## Where to go next with migrations

- [Database work in Claude Code](/en/claude-code/build/database-work/) — The scratch database, deny rules and the up-down-up proof this page builds on.
- [Database migration patterns](/en/cookbook/database-recipes/migration-patterns/) — Lock table, Squawk gate, parity query and per-phase rollback, for all three tools.
- [Batch operations](/en/claude-code/build/batch-operations/) — Fan a framework upgrade out to parallel workers with /batch and prove each unit.
- [Testing with Claude Code](/en/claude-code/test/testing/) — Build the suite that every migration phase leans on, and keep the agent out of it.

The same work in the other tools: [codebase modernization in Codex](/en/codex/build/codebase-modernization/), [database work in Codex](/en/codex/build/database-work/) and [migrations in Cursor](/en/cursor-ide/build/migration/).
