How to write a migration you can roll back.

•

•

Write the Down Migration First

I still remember the specific, hollow feeling of staring at a terminal at 3:14 AM, watching a deployment hang while the production database CPU spiked to 100%. I had written a “simple” schema change, but because I hadn’t actually figured out how to write a migration you can roll back without locking every table in the system, I was essentially holding the entire platform hostage. Most tutorials tell you to just write a `down` method that does the exact opposite of your `up` method, but that’s dangerous advice that ignores the reality of data mutation and state.

I’m not here to give you a lecture on database theory or which ORM is the most “modern.” Instead, I want to share the practical, slightly scarred patterns I’ve picked up from years of fixing things that should have never broken in the first place. We’re going to look at the trade-offs between additive changes and destructive ones, and how to structure your deployments so that a failure is just a minor inconvenience rather than a weekend-ruining catastrophe.

Table of Contents

Why Most Down Migration Best Practices Fail in Production

Why Most Down Migration Best Practices Fail in Production

The problem with most “best practices” is that they assume a happy path where the deployment fails before any real work is done. In the real world, a migration often fails halfway through a heavy data transformation, or worse, it succeeds, but the application code that depends on it starts throwing errors five minutes later. Most tutorials tell you to just write a `down` script that drops the new column, but they forget that dropping a column is a one-way street. If you’ve already populated that column with live customer data, your rollback script isn’t a safety net; it’s a data shredder.

We also see a lot of failure in how people approach database schema version control. People treat migrations like a sequence of undo buttons, but they rarely account for the state of the data itself. I’ve seen teams rely on idempotent migration scripts that work perfectly in a clean staging environment, only to choke in production because the table has fifty million rows and the lock duration exceeds the timeout. If your rollback strategy doesn’t account for the actual volume and state of the data at the moment of failure, you aren’t actually practicing disaster recovery—you’re just practicing hope.

The Brutal Cost of Ignoring Database Schema Version Control

The Brutal Cost of Ignoring Database Schema Version Control

I remember a Tuesday at the logistics startup where we treated our schema changes like they were disposable. We didn’t use any real database schema version control; we just ran SQL scripts against the staging environment and prayed they worked in production. When a deployment went sideways and we realized a column type change had corrupted a critical field, we didn’t have a way back. We were stuck in a loop of trying to manually patch data while the service was down, realizing too late that we hadn’t even thought about preventing data loss during rollback.

The cost isn’t just the downtime; it’s the mental debt. When you lack a reliable way to revert, every single deployment becomes a high-stakes gamble. I’ve sat through post-mortems where the team spent six hours trying to reconstruct a table because the “undo” script was just a half-baked `DROP TABLE` command. If you aren’t treating your migrations as code—complete with automated migration testing—you aren’t actually managing a system; you’re just hoping for the best. And in my experience, hope is a terrible strategy for a backend engineer.

Five rules for not losing your sleep (or your data)

  • Treat every “Up” as a contract. If your migration adds a column, the “Down” must explicitly drop it. I’ve seen teams skip the rollback script because “we’ll just fix it in the next deployment,” only to realize that when the deployment fails, you have no way to return to a known good state.
  • Avoid the “destructive rename” trap. If you need to rename a column, don’t just run a `RENAME` command. Add the new column, dual-write to both for a cycle, and only then drop the old one. Renaming a column in a live system is a great way to trigger a cascade of errors in every service that hasn’t been redeployed yet.
  • Keep data migrations and schema migrations in separate files. Mixing a `CREATE TABLE` with a `SELECT INTO` that processes ten million rows is asking for a timeout. If the data transformation takes twenty minutes, your schema migration shouldn’t be holding a lock on the entire table for those twenty minutes.
  • Test the rollback on a real data volume. It’s easy to write a rollback script that works on your local machine with fifty rows of dummy data. It is a completely different beast when that same script tries to undo a change on a production table with five hundred million rows. If it’s going to time out, I want to know during the staging run, not during the incident.
  • Write idempotent scripts. I’ve spent far too much time staring at logs because a migration failed halfway through and then refused to run again because it thought the first half was already finished. Use `IF NOT EXISTS` or check for column existence before you try to touch anything. It’s a small bit of extra work that prevents a massive headache.

The Hard Lessons

A rollback script is useless if it assumes the data hasn’t changed; you need to design migrations that account for the delta between the old schema and the new reality.

Stop treating migrations as a one-way street; if you can’t write the “undo” command in the same breath as the “do” command, you aren’t ready to deploy.

The goal isn’t perfect schema design, it’s predictable failure—aim for migrations that allow you to revert the application logic without needing to perform a full, terrifying database restore.

The Long View on Stability

The Long View on Stability in migrations.

At the end of the day, writing reversible migrations isn’t about following a textbook or satisfying a linter; it’s about respecting your future self. We’ve talked about why one-way migrations are a trap, why schema versioning is non-negotiable, and why the “just fix it in production” mentality is a recipe for a very long night. If you can’t write a down-migration that actually works, you haven’t finished the task. You’ve just created a ticking time bomb that will eventually go off when you’re least prepared to deal with it.

I’ve spent enough hours staring at a terminal in the dark to know that things will go wrong. You can have the best testing suite in the world, and a migration will still find a way to choke on a specific edge case in the live environment. But when that happens, the difference between a minor hiccup and a full-blown outage is often just a well-tested rollback script. Don’t aim for perfection—aim for recoverability. Build your systems with the assumption that you will eventually be wrong, and write your code so that being wrong doesn’t have to be a disaster.

Frequently Asked Questions

What happens if a migration succeeds in changing the schema but the application code fails to deploy?

This is exactly where the “successful” migration becomes a trap. If the schema changes—say, you renamed a column—but the new code fails to deploy, your old application instances are still running. They’re still looking for that old column name. Since the database has already moved on, your production environment effectively enters a state of permanent, high-latency failure. You haven’t just failed a deployment; you’ve desynchronized your data from your logic.

How do you handle a rollback when the migration has already transformed millions of rows of data?

If you’ve already touched millions of rows, a standard `down` migration is a fantasy. You can’t just “undo” a transformation that changed data state. At that scale, you stop trying to revert the schema and start focusing on data recovery. I usually lean on a side-by-side approach: keep the old column intact until the new one is verified, or use a shadow table. If you’ve already overwritten the source, you’re looking at a point-in-time recovery.

Is it actually safe to run a "down" migration on a production database that's been running for several hours?

Short answer: No, it’s rarely “safe.” If the migration has been running for hours, it means you’ve likely accumulated new data under the new schema. Running a `down` migration usually means dropping a column or changing a type, which effectively deletes whatever your users just wrote. You aren’t just reverting code; you’re performing a destructive surgery on live data. Unless you’ve built a way to preserve that delta, you’re just choosing a different way to lose data.

At what point does a migration become too complex to be reversible, and when should I just admit defeat and fix forward?

You’ve hit the wall when the data transformation becomes a one-way street. If you’re dropping a column to replace it with a more complex structure, or if a migration requires recalculating millions of rows based on logic that no longer exists in the old code, a rollback is a fantasy. At that point, trying to “undo” it just creates more state drift. Admit it, stop the deployment, and prepare a fix-forward patch instead.

Tomasz Wierzbicki

About Tomasz Wierzbicki

I am not interested in arguing about frameworks. I am interested in why the thing broke at two in the morning and what would have prevented it. Most of what I know came from being wrong in production and writing it down afterwards.