Changing a database that already holds live data is one of the riskier things you do routinely. A migration that runs instantly on an empty local database can lock a large table for minutes in production, and while it holds that lock, your product is effectively down. The good news is that almost any change can be made safely if you break it into steps.
The rule: never break the running code
At any moment during a deploy, the old version of your application and the new schema have to coexist, at least briefly. So the schema and the code have to stay compatible at every step. A migration that the currently-running code cannot tolerate is an outage waiting for the next request.
Expand, migrate, contract
The reliable pattern for a risky change is to do it in three phases rather than one:
- Expand — add the new column or table without removing anything. The old code ignores it; nothing breaks.
- Migrate — backfill data and switch the application to use the new shape, still leaving the old one in place.
- Contract — once nothing reads the old column, and you are confident, remove it in a later deploy.
Each step is small, reversible, and safe with the code running alongside it. Renaming a column becomes: add the new one, write to both, migrate reads, stop writing the old one, drop it — not a single rename that breaks every running request.
Watch for locks on large tables
Some operations lock a table while they run, and on a large table that can mean a long stall. Adding a column with a default, adding an index, changing a type — the exact behaviour depends on your database, so it is worth knowing which operations are cheap and which are not before you run them against millions of rows.
The question for any migration is not "does it work" but "what does the running application do while it is happening."
Backfills belong in batches
Updating every existing row in one statement can lock the table and strain the database. Backfilling in batches, with a pause between them, does the same work without the product noticing. It takes longer in wall-clock time and costs nothing in downtime, which is the right trade.
Always have a way back
Every migration should have a tested path to undo it, and destructive steps should come last and separately, once you are sure. The reason the contract step waits is exactly this: as long as the old column still exists, rolling back is easy. Once it is dropped, it is not.