Software Development with AI 11 min read Updated Jul 16, 2026

How to Plan a Database Migration With AI Safely

The ALTER is one line. The migration is everything around it — because for a while, two versions of your code share one database. Here's how to turn a schema change on a live table into phases that stand alone, a backfill that can resume, and one step you can never take back.

Build a Migration Plan Prompt

The one-line change that isn't one

Splitting a name column into two is a five-second thought and a one-line statement. On an empty table it *is* a one-line statement. On four million rows with signups landing every second and four different things touching that column, the statement is the smallest part of the job — and the part least likely to hurt you. What hurts you is everything it doesn't say: that the old code is still deployed and still selecting the column you're about to drop, that a single UPDATE across four million rows is a very different event on your engine than on the one the model was thinking of, that once the original string is gone you have no way to check whether the split was right, and that nobody has decided how to split "Mary van der Berg" yet.

That gap is why asking an AI to "write the migration" gets you a confident script and no plan. The script is the easy half. A migration is a *schedule* — a sequence of changes, each of which has to leave the system working for whatever code happens to be running at that moment, with a way back from every step until the one where there isn't. The model can draft that schedule, but only from things it cannot know unless you write them down: your current shape and target shape, how many rows, what reads and writes them, what your traffic looks like, how you deploy, and how much you can tolerate going wrong. NewPrompt's part is the prompt and its structure — nothing touches your database: no connection to it, no reads of it, no writes to it, no DDL, and nothing run on your behalf. Any SQL that comes back is candidate text rather than a safe command: it hasn't met your engine, your version, your indexes, or your data, and it should not be assumed safe because it looks right. The plan is a draft your database owner, the people who own the applications reading that table, and whoever runs your operations all sign off on. And no plan buys a guarantee — not zero downtime, not a safe migration, not this time.

For a while, two versions of your code share one database

Almost every hard thing about migrating a live database comes out of one fact, and it's worth sitting with before any planning starts: a deploy is not instant. For some window — a rolling restart, a canary, a client that caches, a worker that's mid-job — old code and new code are both running, and they are both talking to the same tables. The old code knows the old shape. The new code wants the new one. The database can only be in one state.

That's the constraint every technique in this guide exists to satisfy. It's why the change gets split into phases instead of shipped as one statement: each phase has to be survivable by *both* versions, which means the schema has to stay compatible with the old code right up until the old code is gone — and the same rule, read backwards, is why the destructive step has to wait until nothing writes the old shape either. It's why a backfill isn't a one-shot UPDATE. And it's why the interesting question in a migration is never "does this statement work" — it's "what is running against this table while the statement runs, and what does it see?" A plan that can't answer that isn't a migration plan. It's a script with optimism around it.

  • Old code has to keep working — so nothing it reads or writes can move or disappear while it's still deployed. That cuts both ways, and the second way is the one people miss: you cannot drop a column that a running build still writes.
  • New code has to keep working — so the new shape has to exist before the code that uses it ships.
  • Existing rows are in the old shape and don't fix themselves — that's the backfill, and it runs while traffic keeps arriving.
  • Rows written *during* the migration are the ones people forget — if new writes keep landing in the old shape, your backfill is chasing a target that refills as fast as it drains.

Step 1: Write down both shapes, and everything that touches them

Start with the two shapes side by side: what the schema is now, exactly, and what you want it to be. Then the number that changes every decision downstream — how many rows. A migration on 4,000 rows and the same migration on 40 million are different problems with the same DDL, and a model given no row count will cheerfully hand you a plan that works fine on the first and takes the site down on the second. Add what the table's traffic actually looks like: reads and writes per second, whether there's a quiet window, whether writes are bursty. And name your engine and its version, because the same statement is free on one and a table rewrite on another.

Then the part people consistently skip, which is the inventory of everything that touches the table — and it has two columns, not one. The readers: the application, the admin tool nobody maintains, the nightly export, the dashboard someone pointed at a replica. The writers: the application, that same admin tool, the queue consumer, the support script someone runs by hand. Readers determine when it's safe to *change* a shape. Writers determine when it's safe to *stop maintaining* one — and they're the ones that decide whether your backfill ever finishes. A reader you forgot breaks at deploy time and you find out immediately. A writer you forgot keeps quietly producing rows in the old shape, and you find out from a count that won't settle. The model cannot discover either list for you; it will assume your application is the only thing touching the table, because that's the common case and it has nothing else to go on.

Step 2: Split it into phases that each stand alone

The sequencing pattern that falls out of the two-versions problem is expand, migrate, contract: add the new shape alongside the old one without disturbing it; move the data and the code over while both shapes are live; and only then remove the old shape, once nothing reads *or writes* it. The reason it's the default isn't elegance — it's that every phase is independently shippable and, for most of the sequence, independently reversible. You can stop after any of the early phases and be in a consistent state. A one-step migration gives you exactly one moment where everything must be correct at once, and no way to stand still.

It's worth separating the two things the word "dual" hides, because drafts blur them and they have different price tags. Dual-write — new code writing both shapes on every insert and update — is what makes the backfill's set finite; without it, old instances keep producing old-shape rows and the set never stably empties. You skip it only if you can freeze writes. Dual-read — both shapes being correct for two different readers at the same time — is the expensive half, and it's the one you can often skip: when nothing reads the new columns until cutover, they have to be right, not right-in-step. A model will propose both by default, because both are the conservative answer and conservative is a safe guess when you know nothing about the system. Only the first is usually the one you can't do without.

The order inside the pattern carries real weight, and it's where drafts most often go subtly wrong in both directions. At the front: dual-write has to be *fully deployed* before the backfill starts, not alongside it — ship them together and old instances keep creating old-shape rows while your backfill tries to finish, so you can never tell done from stuck. At the back, the mirror image: before you drop the old column, the code that still writes it has to be gone. Cutover switches *reads*; it doesn't stop writes. If the drop lands while the deployed build is still writing that column, every insert and update fails on contact — which is the same two-versions mistake as the naive plan, just wearing a more careful-looking schedule.

Step 3: Make the backfill boring

A backfill touches every row you have, and the goal is for it to be the least interesting thing that happens that week. Three properties get it there. **Batched**: a bounded number of rows per pass, walking a key range, so no single transaction runs long enough to matter. **Resumable**: it can stop at any point — a deploy, a timeout, someone's laptop closing — and pick up where it left off, which means its progress lives in the data rather than in the process's memory. **Idempotent**: running it twice over the same rows changes nothing the second time, because the condition that selects rows is the condition the work satisfies. A status column is the whole trick: rows leave the set as they're marked, so a rerun is free and a partial run is just a shorter run.

Run it beside production rather than through it. A backfill that borrows the application's connection pool competes with the traffic it's supposed to be invisible to, and one that loops row-by-row through an ORM turns four million rows into four million round trips. Throttle on purpose, and gate each batch on replica lag if you have replicas — finishing in twenty minutes by saturating your write capacity is worse than taking six hours in the background, because the fast one is an incident and the slow one is a job. Days is normal on a genuinely large table.

The other half of boring is refusing to guess. When a row can't be transformed unambiguously, the backfill marks it for review — and *marking* is not the same as leaving it alone. A row left silently unconverted is indistinguishable from a row the backfill never reached, which quietly breaks the completeness check in the next step and puts that row on a path toward the drop with nothing in the new columns. Give the backfill three outcomes rather than two: converted, needs-review, and not-yet-touched. Then the exceptions are a work queue with a name, instead of a slow leak you find later.

Step 4: Prove the data before you trust it

"The backfill finished" is a statement about a job, not about your data. Before anything switches over, the plan needs checks that establish the new shape is actually correct — written down as part of the plan, not improvised at cutover by whoever's awake. Completeness is the obvious one: every row has been triaged, so nothing is still untouched. The more interesting one is drift: with dual-write fully deployed, no *new* untouched rows should be appearing at all, so a count that won't settle is telling you something is still writing the old shape and you haven't found it. That's the check that catches the writer you missed in Step 1 — and it's worth being precise about what it looks like, because it rarely looks like a count stuck above zero. It looks like a count that reaches zero, then doesn't stay there.

The exceptions need their own gate, separate from completeness. Rows marked needs-review are not done; they're a decision someone owes you. They have to be resolved by a person before the old column can be dropped, because after that the material those rows were flagged against is gone. A plan that counts them as "handled" because they're recorded has smuggled the failure past the one check designed to catch it.

Then reconcile rather than count, because a count only proves rows were touched, not that the values are right. Compare a sample of real rows across both shapes by hand — a few hundred, chosen randomly rather than from the top of the table, since the top is your oldest and least representative data. Look specifically at the ugly cases you already know about. And hold parity across a full traffic cycle before you believe it: a quiet Sunday proves nothing about Monday's signup burst, and the failure you're looking for is a code path that only runs under load or only for one kind of customer.

Step 5: Find the step where back stops being an option

Every migration has a moment after which "undo it" stops being a real answer, and the plan's job is to name that moment before you're standing on it. It's almost always the contract step — the drop. Up to that point the old shape still exists, so reverting a deploy puts you back somewhere consistent. After it, a code rollback doesn't restore the column: the old code comes back and finds the data gone. Recovery becomes a restore to a point before the drop — losing whatever happened after that point, or reconciling it by hand — or a fix that goes forward. This is the practical reason a backup is not a rollback plan: it's a floor under total loss, not a way back to five minutes ago, and the difference stops being academic at exactly the hour you'd rather it didn't.

So mark the phases by what they cost to reverse, and let that drive the schedule. The early ones are cheap to undo, which is why they can ship quickly. The drop is not, which is why it ships alone, later, deliberately — after reads have moved, after the writes to the old column have stopped, and after the new shape has survived real traffic for a while. You want distance between the moment you stop needing the old column and the moment you destroy it. For irreversible steps the question also changes from "how do we roll back" to "what's the forward fix": sometimes going forward is genuinely safer than going back, and a plan that only knows how to reverse has no answer at the one moment it matters.

Cutover deserves a criterion rather than a date. "We switch reads on Thursday" is a plan to switch reads whether or not the data is ready. "We switch reads when every row is triaged, no exceptions are outstanding, and the sample comparison has been accepted by the person who owns this data" is a criterion the data either meets or doesn't. Same for done: the migration is complete when the old shape is gone *and* nothing broke, not when the last statement returned. Name who signs off on crossing into the irreversible phase, by role — not because process is nice, but because that's the one boundary where you want a second person to have looked.

Common mistakes

The failures that don't show up in the plan, and do show up in production:

  • Dropping a column the deployed build still writes. Cutover moves reads; it doesn't stop writes. If the drop ships before the build that stopped writing that column has fully rolled out, every insert fails on contact — the naive plan's mistake with a better schedule around it.
  • Assuming an added column is invisible to old code. It usually is, until something does `SELECT *` into a strict deserializer, or `INSERT`s without a column list, or uses an ORM whose model is frozen at the old shape. The admin tool nobody maintains is exactly that code.
  • Running the backfill through the application. Borrowing the production connection pool means competing with the traffic you're trying not to disturb, and looping row-by-row through an ORM turns millions of rows into millions of round trips. Run it beside the app, batched, throttled, and gated on replica lag.
  • Testing the migration against an empty staging database. Every interesting failure here is a function of volume and concurrency. A migration that passes on 200 rows with no traffic has demonstrated that the syntax is valid and nothing else.
  • Re-adding NOT NULL at the end as if it were free. Validating a constraint against a full table can hold it for a long time; engines that support adding it unvalidated and checking it in a separate pass exist for exactly this reason. It's the last step and it deserves the same care as the first.
  • Counting rows and calling it verified. A count proves rows were touched, not that the values are right — and a count that reaches zero and won't stay there is a writer you haven't found, not a rounding error.
  • Running the AI's SQL because it parses. It hasn't seen your engine, your version, your indexes, or your row count, and those are what decide whether a statement is instant or an outage — NewPrompt never touches your database, so every statement is a draft for someone who knows your system.

Splitting a name column on a live table

A change small enough to describe in one sentence, worked out to the point where the interesting parts are visible.

A name-column split on 4.2M live rows, phase by phase, each marked by what it costs to reverse — with the split rule pulled out as a decision that blocks phase 2, and the drop gated behind both the writes stopping and every flagged row being resolved
WHAT'S CHANGING:
  users.full_name TEXT NOT NULL  ->  users.given_name + users.family_name
  4.2M rows | live signup and login traffic, ~40 writes/sec at peak
  engine and version: <yours -- it decides half of what follows>

  readers:  the web app | the admin tool | a nightly export |
            a dashboard pointed at the replica
  writers:  the web app | the admin tool | the signup queue consumer
            ^ this column is the one that decides when you can drop

PHASE 0 - DECIDE THE SPLIT RULE
  not a migration step. a product decision, and it blocks phase 2.
  how do you split "Mary van der Berg"? "Dr. Jose Luis Garcia
  Marquez"? a mononym? asked to fill the column, a model will split on
  the first space and write family_name = "van" 4.2 million times.
  this is a question about your users, not about SQL.
  owner: whoever owns the customer record.
  rows the rule can't decide get marked, not guessed (phase 3).

THE ONE-STEP VERSION, AND ITS THREE FAILURES:
  ALTER TABLE users ADD COLUMN given_name TEXT NOT NULL;
     -> engine-dependent, and the failure modes differ in kind.
        Postgres rejects it: the column would contain nulls.
        MySQL will often accept it and fill 4.2M rows with an implicit
        empty string -- at which point given_name IS NULL matches
        nothing and a backfill keyed on it reports success having done
        no work at all. that is the dangerous one: it looks like it
        worked. (the variant that rewrites the table under lock is
        ADD COLUMN ... NOT NULL DEFAULT 'x', on Postgres before 11.)
        this is the whole argument for phase 1: add it nullable and
        let the backfill own the values.
  UPDATE users SET given_name = split_part(full_name, ' ', 1);
     -> 4.2M rows in one statement. depending on the engine it either
        locks or bloats -- and it discards "der Berg" on every name
        with more than two words. outside a transaction, a failure at
        row 3M leaves the table half done with nothing recording where
        it stopped.   (split_part is Postgres-specific; yours varies.)
  ALTER TABLE users DROP COLUMN full_name;
     -> needs no help from the database to hurt you. every instance
        still running the old build selects full_name and starts
        failing the moment it lands.

PHASE 1 - EXPAND                              reversible: drop them
  add given_name TEXT NULL, family_name TEXT NULL, split_status TEXT NULL
  nullable, no defaults. nothing reads or writes them yet.
  old code cannot tell this happened -- unless it does SELECT * into a
  strict mapper or INSERTs without a column list. that's what the
  reader inventory is for.

PHASE 2 - DUAL-WRITE                       reversible: redeploy
  new build writes full_name AND the split columns on every insert and
  update, using the phase 0 rule. it still READS full_name.
  wait until every instance runs this build before phase 3. this is
  the ordering that makes the backfill's set finite.

PHASE 3 - BACKFILL                    reversible: nothing reads them
  UPDATE ... WHERE split_status IS NULL     -- batched by id range,
  5k rows a pass, sleep between passes, gate on replica lag.
  idempotent and resumable: rows leave the set as they're marked, so a
  rerun is free and a partial run is just shorter.
  three outcomes, not two:
     split_status = 'auto'          the rule decided it
     split_status = 'needs_review'  the rule couldn't -- a human owes
                                    an answer. columns stay NULL.
     split_status IS NULL           not reached yet
  run it beside the app, not through its connection pool.

PHASE 4 - VERIFY                              nothing to reverse yet
  complete:   rows WHERE split_status IS NULL  -> must be zero
  drift:      that count must reach zero AND STAY there. if it settles
              and then refills, something still writes the old shape --
              the writer you missed, not the reader.
  exceptions: rows WHERE split_status = 'needs_review' -> a work queue.
              these are NOT done. they are the reason phase 7 has a
              gate, because phase 7 destroys what they'd be resolved
              against.
  sample:     200 random rows compared by hand, ugly cases included.
  hold parity across a full traffic cycle. Sunday proves nothing.

PHASE 5 - CUTOVER                     reversible: switch reads back
  reads move to given_name/family_name. writes STILL fill both.
  the criterion is the data, not the calendar:
     zero untouched, zero needs_review outstanding, sample accepted
     by the data owner

PHASE 6 - STOP DUAL-WRITE                  reversible: redeploy
  new build writes only the split columns. wait until every instance
  runs it.
  this phase exists because phase 5 didn't do it. cutover moved the
  reads; the writes kept going. skip this and the drop takes down
  every write on the next phase.

PHASE 7 - CONTRACT                    IRREVERSIBLE - the last exit
  gate: nothing writes full_name (phase 6 fully rolled out) AND no row
        is still needs_review.
  drop full_name.
  ships alone, in its own release, after phase 5 has lived through real
  traffic. from here a code rollback does NOT bring the column back:
  recovery is a restore to a point before the drop, or a forward fix.

WHY THE DROP IS TRULY ONE-WAY:
  the split loses two different things. the naive rule throws tokens
  away -- "Mary van der Berg" split on the first space gives given
  "Mary", family "van", and "der Berg" is simply gone. and even a split
  that keeps every token can be wrong in a way you can never test:
  once full_name is dropped there is no original to check a partition
  against, and nothing to re-derive a corrected one from. that's what
  makes a bad split permanent AND unprovable -- not that you can't
  concatenate the parts back, but that you can't tell which partition
  was ever the right one.

Where this fits in NewPrompt

The concrete pieces are small and specific. The Database Migration Plan Prompt is a fill-in prompt whose role line is "planning a database migration that must deploy without downtime or data loss, while old and new code run simultaneously" — it asks for the expand step, the backfill, the dual-write and cutover, the contract, the reversibility of each step, and the steps that could lock a large table. You paste your current schema, your target, and your data into its one input slot; the Markdown Output Builder is the tool that builds that prompt, with those sections already in its setup, and it emits prompt text you run in your own AI assistant. Worth knowing before you use it: its sections list the backfill before the dual-write. That's a document order, not an execution order — and the execution order is the thing this guide is about, which is a good illustration of the general rule that a template hands you a default and your case decides whether it holds. Two neighbours sit either side: the Zero-Downtime Deployment Prompt covers keeping the service up across versions and explicitly sends the data specifics back to the migration prompt, and the Rollback Plan Prompt — built with the Multi-Step Prompt Builder, which emits an ordered sequence of prompts rather than running anything — does the reversal thinking as its own document.

It's worth being precise about which database problem this is, because there are several and they don't share solutions. Designing a schema is deciding what the shape should be, on paper, before there's data — the choices are about the domain, and being wrong is cheap. This is the other end: the shape is already decided, the table is already full, and the difficulty is entirely in getting from one to the other without breaking the code that's running. Tuning a slow query is a third thing, and it assumes a schema that isn't moving. There's also a version of this problem that isn't this guide at all: moving data from one system into another, exporting and importing and switching over. That's a data move, and its risks live in the transfer. What's here is in-place evolution — the same database, the same table, changing shape underneath live traffic — where the risk lives in the window when both shapes have to be true at once.

Migrations fail in a specific and unfair way: not when you run them, but later, quietly, to a subset of your rows. A backfill that guessed produces data that looks fine until someone reads it. A drop that landed while something was still writing takes down the writes, not the deploy you were watching. A count that said zero was measuring the wrong thing. None of it surfaces during the migration itself, so the tests you'd normally trust don't catch it and the graph stays flat. What a good plan buys you isn't confidence — it's phases small enough that each one can be wrong on its own, checks that would notice, and one clearly marked step where you stop being able to change your mind. The model can draft that structure from what you tell it in minutes. Whether the row count is right, whether the fifth writer exists, and how to split the awkward names are things it has no way to know, and they're what the migration actually turns on.

Tools for this guide

Each generates the prompt described above — you run it in your own AI assistant.

Ready-made resources

Reusable prompts and templates for the exact steps in this guide.

FAQ

Can NewPrompt look at my database to plan the migration?

It has no way to. Everything here happens in your browser and produces text — there's no connection to a database or a database server, no introspection of your existing schema, no row counts, no query plans, no lock or replication monitoring. It also runs nothing: no DDL, no backfill, no migration, no backup, no restore, no cutover. So everything the plan is built on has to come from you — the current shape, the target shape, how many rows, which code reads and writes the table, what the traffic does, how you deploy, and which engine you're on. Whatever the model returns is a draft, and the people who own the database, the applications, and the operations are the ones who decide whether any of it is safe to run.

The AI wrote the migration SQL — can I run it?

Treat it as text that looks like SQL, because that's what it is. The same DDL can be free on one engine and a full table rewrite on another; the same UPDATE is trivial at ten thousand rows and an incident at forty million; the same `ADD COLUMN ... NOT NULL` is rejected outright by one engine and silently filled with empty strings by another, which is worse, because then your backfill finds nothing to do and reports success. Those differences are exactly what the model can't see, and they're what decides whether a statement is safe. So the SQL is a starting draft for someone who knows your system to check, rewrite, and test against a realistic copy — not a command to paste into production because it parsed.

Do I really need dual-write, or is that overkill?

Two different things hide behind the word "dual," and they have different answers. Dual-write — the new build writing both shapes — is what makes the backfill's set finite: without it, instances running the old build keep producing old-shape rows, and the set of unmigrated rows never stably empties, so you can't tell finished from stuck. You skip that only if you can freeze writes for the duration. Dual-read — keeping both shapes correct for two different readers at once — is the expensive half, and it's the one that's genuinely often skippable: if nothing reads the new columns until cutover, they need to be right, not right-in-step. A model will suggest both, because both are conservative and conservative is what you guess when you know nothing about the system. Your reader and writer inventory is what tells you which half you actually need.

How is a migration plan different from a rollback plan or a deployment runbook?

They answer three different questions about the same change. A migration plan is the strategy: which phases exist, what stays compatible with the old code while they run, how the data moves, what proves it moved correctly, and where reversibility ends. A rollback plan is about reversal specifically — the triggers that say it's time, the reverse of each forward step, and the point past which undoing is worse than continuing. A deployment runbook is the operational document someone executes: ordered steps, a verification at each one, and what to do when a step fails. The migration plan is the thing the runbook carries out, and the rollback plan is where one of its failure branches points. What makes the migration plan its own document is the part neither of the others has room for: the phase geometry — that reversibility isn't a property of the change, it's a property of which phase you're standing in.