I still remember the specific, hollow sound of a Slack notification at 2:14 AM during my time at that logistics startup. It wasn’t a loud alarm, just that quiet ping that tells you the database has decided to stop cooperating. We had tried to run a simple migration on a massive table, thinking we could just “fix it in production,” and instead, we watched as the entire system locked up. Most tutorials make it sound like a trivial task, but if you don’t actually understand how to handle a schema change with live traffic, you aren’t just running a script—you’re playing Russian roulette with your uptime.
I’m not here to sell you on some expensive, over-engineered orchestration layer or a complex new framework. I’ve spent enough years cleaning up after “perfect” migrations to know that the real solution is usually much more boring and incremental than the hype suggests. In this note, I’m going to walk through the actual patterns I use to roll out changes without locking tables or killing the site, focusing on the trade-offs you’ll actually face when the load is high and the margin for error is zero.
Table of Contents
Why Your Migration Failed and How to Fix It

Most of my mistakes in this area come down to treating a database migration like a single, atomic event. In a local dev environment, running `ALTER TABLE` feels instant. In production, with millions of rows, that same command can trigger a metadata lock that hangs every incoming request, effectively turning your high-availability system into a very expensive brick. I once sat through a two-hour outage at a logistics firm simply because we tried to add a `NOT NULL` constraint to a massive table without a default value, and the database spent the entire time re-writing every single page on disk.
The fix isn’t a better tool; it’s a change in mindset. You have to move away from “one-and-done” scripts and toward the expand and contract pattern. This means you never actually rename or delete a column in a single step. Instead, you add the new column, start a dual writing strategy for schema updates where your application writes to both the old and new locations, and only once you’ve verified the data integrity do you finally drop the old field. It’s more work and more code to maintain temporarily, but it’s the only way to ensure you aren’t the person getting paged at 2 AM because a table lock decided to stay for breakfast.
Avoiding Database Locks During Migration While Users Are Active

The biggest mistake I see is treating a migration like a single, atomic event. You run one script, it hits a massive table, and suddenly your connection pool is exhausted because every other query is stuck waiting for a metadata lock. To avoid this, you have to stop thinking in terms of “renaming a column” and start thinking in terms of the expand and contract pattern. You don’t change the existing field; you add a new one, let the data flow into both, and only once you’re sure everything is stable do you retire the old one. It feels like more work—and it is—but it’s the only way to ensure you aren’t holding the entire system hostage while a single transaction runs.
When you’re dealing with high-traffic tables, you also need to be wary of how your migration tool handles the actual DDL statements. Even a simple `ALTER TABLE` can trigger a full table rewrite in some engines, which is a death sentence for availability. I’ve found that implementing a dual writing strategy for schema updates at the application level is often safer than relying on the database to do everything at once. You write to the old schema and the new schema simultaneously, slowly backfilling the historical data in small, manageable batches. It keeps the transaction sizes tiny and, more importantly, keeps your CPU usage from spiking into the red while your users are trying to actually use the product.
My survival checklist for schema changes
- Always write the code for the new column before you actually add it to the database. If the application doesn’t know the column exists yet, it won’t freak out when the database suddenly shows up with a new field.
- Never, ever use `SELECT *` in your application code. It feels lazy and efficient until you add a column and suddenly your ORM or your data mapper starts throwing errors because the result set doesn’t match what it expected.
- Treat every migration like it’s going to fail halfway through. I’ve learned that if you don’t have a verified rollback plan—and a way to test that rollback in a staging environment that actually looks like production—you’re just gambling.
- Run your migrations during the lowest traffic window you can find, but don’t assume that’s enough. A “quiet” period in a logistics startup is still a lot of background jobs running, so monitor your CPU and IOPS like a hawk the moment you hit ‘apply’.
- Use a multi-phase rollout: add the column, deploy code that writes to both old and new columns, migrate the old data in small batches, and finally, deploy code that only uses the new column. It’s more work, but it’s the difference between a boring Tuesday and a 2 AM incident report.
The Post-Mortem Checklist
Never assume a migration is “safe” just because it worked on your local machine or a small staging environment; if the table has millions of rows, the locking behavior changes entirely.
Always favor additive changes—adding a new column or a new table is almost always safer than trying to rename or delete an existing one while the application is still trying to use it.
If you can’t roll back the schema change without losing data, you haven’t designed a migration; you’ve designed a trap.
The Bottom Line

At the end of the day, handling live schema changes isn’t about finding a magic tool or a perfect framework; it’s about managing the reality of state. You have to move away from the idea of “all-at-once” deployments and embrace a workflow where your application code and your database schema are decoupled. This means writing code that can handle both the old and the new column simultaneously, rolling out changes in small, reversible increments, and—most importantly—testing your migration scripts against a production-sized dataset before you ever touch the live environment. If you don’t plan for the rollback before you hit enter, you aren’t performing a migration; you’re playing Russian roulette with your uptime.
I know it feels like extra overhead. It feels like you’re writing three times the amount of code for a single feature. But I’ve spent enough nights staring at a spinning terminal while a database lock brings a platform to its knees to know that this “extra” work is actually the only work that matters. We don’t build systems just to ship features; we build them to stay running. Write your migrations with the assumption that they will fail, and you’ll eventually reach a point where a failed migration is just a minor telemetry blip rather than a 2 AM emergency call.
Frequently Asked Questions
What happens if the migration succeeds but the application code rollback fails halfway through?
This is the nightmare scenario. You’ve successfully migrated the schema, but the new code is throwing 500s, and your rollback script just hung on a deployment step. Now you’re stuck in a “split-brain” state: the database is living in the future, but your application is stuck in the past.
How do I actually test these migrations against a realistic dataset without just guessing if it'll lock up?
You can’t guess your way out of a production outage. I used to think a local Docker container with a few hundred rows was “testing,” until a migration hit a 50-million-row table and froze everything. Now, I pull a sanitized snapshot of production data into a staging environment that mirrors our actual hardware specs. If the migration takes ten minutes on a subset, it’ll likely crawl on the real thing. Run it, time it, and watch the locks.
At what point do I decide that a migration is taking too long and just pull the plug?
The moment you see your application’s connection pool hitting its limit and latency spiking across the board, you’ve already crossed the line. If the migration is holding locks that are queuing up requests, don’t wait for a total outage to act. I usually set a “kill timer” based on my error rate thresholds. If it’s not moving and the site is slowing down, kill the migration, roll back, and figure out a smaller batch size tomorrow.
Is there a specific way to handle adding a NOT NULL constraint to a massive table that already has millions of rows?
Adding a NOT NULL constraint to a massive table is a classic “trap” because most databases will try to rewrite the entire table to validate the constraint. On a table with millions of rows, that’s a long-running transaction that’ll likely lock your writes and kill your latency.
