teachyou.ai academy
← All posts
Claude Code

Claude Code for Database Migrations: A Safe Workflow

Ira Menon · Jun 25, 2026 · 13 min read

Why database migrations are the scariest thing you'll ask an AI to help with

Every other task you hand to Claude Code has an undo button. Refactor a function badly, and git checkout puts you back where you started. Generate a bad component, and you delete the file. Database migrations don't work that way. Once a migration runs against production, it has usually already dropped a column, backfilled a table, or rewritten an index on live data — and by the time you notice something is wrong, the "before" state may not exist anymore.

That asymmetry is exactly why database migrations deserve a dedicated workflow rather than "ask Claude Code to write a migration and run it." The model is genuinely good at this task — it can read your schema, understand your ORM's migration format, and write syntactically correct SQL or migration files faster than you can. The risk isn't that Claude Code writes bad SQL. The risk is that a *correct* migration gets applied in the *wrong order*, against the *wrong environment*, without a *rollback path*, and nobody catches it until users start filing tickets.

This article is a practical workflow for using Claude Code on migrations safely: how to structure the conversation, what guardrails to put in CLAUDE.md, how to force a review step before anything touches a real database, and how to make rollback a first-class part of every migration you generate. We'll use Postgres with a couple of common tooling setups (raw SQL migrations, Prisma, and Django) as running examples, but the workflow generalizes to any migration system.

The core principle: separate "write" from "run"

The single most important rule in this workflow is that Claude Code should never be the thing that executes a migration against a database that matters. It writes the migration file. A human — or a controlled CI pipeline — applies it.

This sounds obvious, but it's easy to erode in practice. You start a session by asking Claude Code to "add a last_login_at column to the users table," and three prompts later you've said "ok now run it" without thinking about which database URL is sitting in your .env file. Claude Code will happily run npx prisma migrate dev or python manage.py migrate if you ask it to, and it has no independent way of knowing whether that command points at your laptop's Docker container or a shared staging database with real customer data in it.

So the first concrete step is environment isolation, enforced structurally, not just by convention:

# .env.development — safe for Claude Code to read and use
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_dev

# .env.production — never loaded in a Claude Code session
DATABASE_URL=postgres://prod-user:***@prod-host.internal:5432/app_prod

Keep production credentials out of any file or shell environment that a Claude Code session can see. If you work in a sandboxed environment or container, run Claude Code inside a network boundary that simply cannot reach the production database host. This isn't a Claude Code-specific precaution — you'd apply the same rule to a junior engineer's laptop — but it matters more here because an agentic loop can execute a chain of commands in seconds, faster than you can interrupt it if something looks wrong.

Setting migration guardrails in CLAUDE.md

Claude Code reads CLAUDE.md at the start of a session, and this is the right place to encode migration rules once so you don't have to repeat them every time. A minimal but effective block looks like this:

## Database migrations

- NEVER run a migration command against anything other than DATABASE_URL
  from .env.development. If asked to migrate staging or production,
  stop and print the exact command for a human to run instead.
- ALWAYS generate an "up" and a "down" (or reversible) migration.
  If the ORM does not support automatic down migrations for a change
  (e.g. Django's irreversible operations), write the down migration
  manually and flag it in the PR description.
- ALWAYS check for existing data before writing a migration that adds
  a NOT NULL column, changes a column type, or drops a column. Propose
  a backfill step, not just a schema change.
- NEVER write a migration that drops a column or table in the same
  migration that stops the application code from using it. Use the
  expand/contract pattern (see below) across at least two deploys.
- Migrations must be reviewed by a human before merge. Do not merge
  your own migration PRs.

This isn't decoration — it changes what Claude Code actually proposes. Without the "expand/contract" instruction, a model asked to "rename email to email_address" will often generate a single migration that renames the column outright, which breaks the currently-running application the instant it's applied (the old code is still querying email). With the instruction in place, Claude Code will instead propose the multi-step version, which is the version you actually want.

The expand/contract pattern, and why it matters more with an AI in the loop

Expand/contract (also called parallel change) is the standard technique for making schema changes without downtime, and it's worth having as an explicit pattern name in your prompts because it gives Claude Code a concrete shape to reach for instead of improvising.

The pattern has three phases:

  1. Expand — add the new structure alongside the old one. New column, new table, new index. Nothing is removed yet, so both old and new application code keep working.
  2. Migrate — backfill data into the new structure, and deploy application code that writes to both old and new (or reads from new, falls back to old).
  3. Contract — once the new structure is confirmed correct and the old one is unused, drop the old structure in a separate migration.

Here's what that looks like for the "rename email to email_address" example, split across three migrations instead of one:

-- Migration 1 (expand): add the new column, nullable, no backfill yet
ALTER TABLE users ADD COLUMN email_address TEXT;

-- Down
ALTER TABLE users DROP COLUMN email_address;
-- Migration 2 (migrate): backfill in batches, then add constraint
-- Run as a data migration, not a blocking schema change
UPDATE users
SET email_address = email
WHERE email_address IS NULL
  AND id BETWEEN :batch_start AND :batch_end;

-- Once backfill is confirmed complete for all rows:
ALTER TABLE users ALTER COLUMN email_address SET NOT NULL;

-- Down
ALTER TABLE users ALTER COLUMN email_address DROP NOT NULL;
-- Migration 3 (contract): only after app code no longer reads/writes `email`
ALTER TABLE users DROP COLUMN email;

-- Down (data loss on rollback is expected here — this is the point
-- where you've accepted the old column is gone; keep a backup)
ALTER TABLE users ADD COLUMN email TEXT;

Notice migration 2 batches the backfill rather than doing a single UPDATE users SET email_address = email. On a users table with a few million rows, an unbatched update takes a long-lived lock and can stall other writes or blow past a statement timeout. Ask Claude Code explicitly for batched backfills on any table you expect to have non-trivial row counts — it's a good habit to state a threshold in your prompt ("this table has ~2M rows, batch the backfill") rather than assuming the model will guess your data size.

A concrete session workflow

Here's the actual sequence I use when asking Claude Code to produce a migration, structured so review happens before anything runs.

Step 1 — Describe the change and ask for a plan first, not code.

"I need to add a subscription_tier enum column to the accounts table, defaulting existing rows to 'free'. Don't write the migration yet — first tell me the plan: what phases this needs, whether it's reversible, and what could go wrong on a table with production data."

Asking for the plan before the code forces the model to reason about the shape of the change (single migration vs. expand/contract) before committing to SQL. It also gives you a checkpoint to catch a bad plan cheaply, before you've reviewed fifty lines of generated migration code.

Step 2 — Generate the migration against your local/dev database only.

Once the plan looks right, let Claude Code generate the actual migration file and apply it locally:

# Prisma example
npx prisma migrate dev --name add_subscription_tier

# Django example
python manage.py makemigrations accounts
python manage.py migrate accounts

Because DATABASE_URL in your dev environment points at a disposable local database, this step is safe to let the agent run autonomously. If it's wrong, you drop the dev database and start over — that's the whole point of doing it here first.

Step 3 — Ask Claude Code to write (or generate) the down migration and test it.

Don't assume the up migration is reversible just because the ORM generated something. Explicitly round-trip it:

# Apply, then immediately roll back, then re-apply
python manage.py migrate accounts 0012
python manage.py migrate accounts 0011  # roll back one step
python manage.py migrate accounts 0012  # forward again

If this round trip fails locally, it will fail in production too, and you want that failure now, in a session you can inspect, not during an incident.

Step 4 — Have Claude Code write a verification query, not just the migration.

This step gets skipped constantly and it's the one that catches real bugs. Ask for a query that checks the migration actually did what it claims:

-- Verify: every account has a non-null subscription_tier
-- and the distribution matches expectations
SELECT subscription_tier, COUNT(*)
FROM accounts
GROUP BY subscription_tier
ORDER BY COUNT(*) DESC;

-- Verify: no orphaned rows from the backfill
SELECT COUNT(*) FROM accounts WHERE subscription_tier IS NULL;

Run this against your dev database after the migration, look at the actual numbers, and only then move to staging.

Step 5 — Promote through staging manually, never automatically.

The migration file goes into a normal pull request, gets reviewed by a human (or a second Claude Code session acting purely as a reviewer — see below), and gets applied to staging through your normal deploy pipeline, not by Claude Code invoking a remote migration command directly. If your CI pipeline runs migrations as part of deploy, that's fine — the point is that the *decision* to apply the migration to staging or production is a deploy-pipeline event, not a chat-session event.

Using Claude Code as a second reviewer

One underused pattern: after generating a migration in one session, open a second, independent Claude Code session (or use a subagent) whose only job is to review the migration file with fresh eyes, no memory of the conversation that produced it. Feed it the migration file and the current schema, and ask specific questions:

Review this migration against the current schema.sql. Answer specifically:
1. Does this migration lock any table for longer than a few hundred
   milliseconds on a table with 5M+ rows?
2. Is there a rollback path, and does it lose data? If so, is that
   documented?
3. Does this migration assume the application code has already been
   deployed, or does it assume the old code is still running?
4. Are there any implicit foreign key or index changes that aren't
   explicit in this diff?

This works well precisely because a fresh session isn't anchored to the reasoning that produced the migration — it's the same benefit you get from asking a human colleague who wasn't in the room when the first draft was written. It won't catch everything, but it reliably catches the "this ALTER TABLE will lock the whole table" class of mistake that's easy to miss when you're focused on getting the logic right.

Handling irreversible migrations honestly

Some migrations genuinely can't be cleanly reversed — dropping a column that had data in it, merging two tables, deleting rows as part of a cleanup. Don't let Claude Code paper over this by generating a "down" migration that silently produces a different state than what you started with. Instead, make the irreversibility explicit:

# Django example: marking a migration as intentionally data-destructive
class Migration(migrations.Migration):
    dependencies = [("accounts", "0011_add_subscription_tier")]

    operations = [
        migrations.RemoveField(
            model_name="account",
            name="legacy_plan_code",
        ),
    ]

    # Note: reverse_code is not provided. Reversing this migration
    # will NOT restore legacy_plan_code data. If rollback is needed,
    # restore from the pre-migration backup taken 2026-07-03.

Ask Claude Code to add exactly this kind of comment whenever a migration is one-way. The goal isn't to make rollback possible where it isn't — it's to make the *absence* of a rollback path visible in the code, so nobody discovers it by trying migrate down during an incident at 2 a.m.

A pre-flight checklist worth pasting into every migration PR

Whether or not Claude Code wrote the migration, this is the checklist that catches most real incidents before they happen:

  • Does this migration run in under your deploy pipeline's timeout on a table with production-scale row counts, not just your dev database's few hundred rows?
  • Is the migration backward-compatible with the *currently deployed* application code, not just the *new* code you're about to ship?
  • Is there a tested down migration, or an explicit, documented reason there isn't one?
  • Has a batched backfill been used for any UPDATE touching more than a few thousand rows?
  • Has someone other than the author (human or AI) reviewed the migration file directly, not just the feature it supports?
  • Is there a recent backup or point-in-time recovery window covering the moment this migration will run?

Put this list in your PR template. It costs thirty seconds to read and it turns "did we check for locking issues" from a question someone asks after the incident into a checkbox someone answers before merge.

Where this workflow breaks down

It's worth being honest about the limits here. Claude Code doesn't know your production data's actual shape — cardinality, null rates, skew — unless you tell it or give it read access to run diagnostic queries. A migration that's instant on a dev database with 200 rows can take twenty minutes and hold a lock on a production table with 200 million rows. If your tables are large, get the actual row counts and index sizes into the conversation before asking for a migration plan, or better, let Claude Code run EXPLAIN and size-estimation queries against a read replica first.

Similarly, this workflow assumes you have a staging environment that's a reasonable approximation of production. If your staging database is a tiny fraction of the size or has different data characteristics, "it worked in staging" doesn't prove much for lock duration or backfill time. That gap isn't something Claude Code can close for you — it's a gap in your infrastructure that predates any AI tooling and that no workflow change fixes.

Bringing it together

The workflow described here isn't complicated, but it depends on discipline that's easy to skip when a model can generate a working migration in ten seconds: plan before code, expand before contract, dev before staging before production, and a second set of eyes — human or a fresh AI session — before anything touches real data. None of these steps exist because Claude Code writes bad SQL. They exist because migrations are one of the few places in software where "mostly right" and "verified right" have very different blast radii, and the speed of an agentic workflow makes it easier than ever to skip straight from "looks right" to "applied in production" without the verification step in between.

If you want to go deeper on structuring Claude Code sessions, writing effective CLAUDE.md guardrails, and building multi-step workflows like this one for your own team's tooling, our Claude Code Tutorial for Beginners course on teachyou.ai walks through exactly this kind of practical, production-grade setup — not just how to prompt the model, but how to build the surrounding process that makes its output safe to ship.