All posts

Your Migration Rollback Plan Forgets the Backfill — and the Backfill Is the Part You Can't Undo

·5 min read·
database migrationsdata backfillreliability

Ask an engineer how they'll roll back a database migration and you'll almost always hear about the schema. They'll talk about the down migration that drops the column they just added, the expand-contract sequence that keeps the old structure around, the feature flag that lets them route traffic back to the old code path in seconds. All of that is real, and all of it is genuinely good practice. It's also answering the easy half of the question. The schema change is the part of a migration you can undo. The backfill is the part you can't, and it's the part nobody writes a rollback plan for.

I want to make an unfashionable claim: in a mature codebase, the DDL is rarely what turns a migration into an incident. ALTER TABLE ADD COLUMN is reversible. CREATE INDEX CONCURRENTLY is reversible. The dangerous, irreversible thing hiding inside most "migrations" is the data transformation that runs alongside them — recomputing a denormalized field across forty million rows, re-encoding a column, splitting one table's data into two, normalizing a currency format that was stored inconsistently for six years. That's the step that mutates the actual bytes your business runs on. And once you've overwritten a value in place, no down migration and no flag flip brings the original back.

The conversation about migrations is a conversation about schema

If you read the standard advice on zero-downtime database migration strategy, it's almost entirely about the schema and the code that talks to it. Make changes additive. Deploy code that understands both shapes. Keep the old column until you're confident. Coordinate schema migrations with application feature rollouts so the code never runs against a structure that isn't there yet. This is correct and it solves a real problem — the classic outage where new code hits an old schema.

But notice what that framework quietly assumes: that the data itself is either already in the right shape or gets there through a trivial, instantaneous operation. That assumption holds for adding a nullable column. It falls apart the moment you have to populate that column from existing data, or transform what's already there. The backfill is a second migration wearing the first one's clothes, and it has a completely different risk profile. The schema change is a discrete, transactional, reversible DDL statement. The backfill is a long-running, stateful, frequently destructive batch job that touches production data while production traffic is live.

Two ways the backfill kills you

The first failure mode is the in-place destructive update. You decide to normalize users.phone into E.164 format, so you run an UPDATE that rewrites the column. It works for 99.7% of rows. The other 0.3% had formats your regex didn't anticipate, and now they're mangled. There is no down migration for this. The pre-migration value is gone — you overwrote it. Your feature flag can route reads back to "the old code path," but the old code path is now reading corrupted data, because the corruption is in the data, not the code. This is exactly where the promise of database migration rollback without data loss evaporates: the flag protects the behavior, and the behavior was never the thing at risk.

The second failure mode is the long-running backfill that lives in a mixed state. A backfill over tens of millions of rows takes hours, sometimes days. During that entire window, some rows are migrated and some aren't, and your application has to be correct for both. If you abort the job halfway — because it's saturating replica lag, or because it's causing lock contention on a hot table at peak — you're now stranded in a partially-transformed state with no clean way forward or back. The schema is fine. The code is fine. The data is half in one world and half in another, and neither the up nor the down path was designed for "50% done."

Treat the backfill like a rollout, because it is one

The fix is to stop thinking of the backfill as a script you run and start thinking of it as a progressive rollout in its own right — with the same discipline you'd apply to shipping risky code behind a flag. Three principles do most of the work.

Never mutate in place. Write to a new location and keep the source of truth. If you're transforming a column, write the transformed value to a new column and leave the original untouched until you've validated everything. Now rollback is trivial and lossless: point reads back at the original column. The old data was never destroyed, so "roll back without data loss" is a description of what actually happens rather than an aspiration. This is the real version of coordinating schema migrations with application feature rollouts — the read path is what the flag controls, the write path dual-writes to both locations, and the backfill just populates the new one in the background.

Make the job chunked, idempotent, and resumable. A backfill that processes rows in bounded batches, records its progress, and can be safely re-run from where it stopped turns "we had to abort at 50%" from a crisis into a pause button. Aborting a good backfill should be as boring as pausing a rollout at 15% traffic.

Monitor it with abort criteria, not just a progress bar. The thing almost nobody does: define, before you start, what a bad backfill looks like. Parity checks between the old and new values on a sample. Error and skip rates per batch. Replica lag. Lock wait time. If any of those cross a threshold, the job halts itself — the same way a rollout halts when error rate diverges from baseline. A backfill running unwatched at 3 a.m. because "it's just a data job" is the same unmanaged risk as a deploy going straight to 100% with nobody watching the graphs.

The missing timeline

The deeper point is that a real migration has three independent timelines, not two: the schema, the read/write code path, and the data itself. The industry has good tooling and good instincts for the first two. Feature flags decouple the code from the schema and give you a fast, lossless control surface for behavior. But the data timeline — the backfill — is left to a hand-written script, a screen session, and hope. That's the timeline where the point of no return actually lives, because it's the only one of the three that destroys information when it goes wrong.

This is the gap DeployRamp is built to close. When a change couples a schema migration to a data transformation and a code path, wrapping the read path in a flag is only half the safety — the half everyone already knows how to do. The other half is treating the backfill as a first-class, monitored, reversible operation: dual-writing so the original data survives, ramping the transformation in resumable chunks, running parity checks against the old values, and feeding that signal into the same rollout control plane that decides whether the read cutover advances or reverts. Database migration rollback without data loss stops being a line in a runbook and becomes a property of the system — because the old data was never overwritten, the cutover was gated on validated parity, and the backfill could stop itself the moment the numbers stopped agreeing. The schema was never the scary part. The bytes were.

Let DeployRamp handle the flags

Install the GitHub App, drop in the SDK, and ship a flagged PR in minutes. Book a demo and we'll show you how.

We use cookies to analyze site usage and improve your experience.