Here’s a request that sounds trivial and isn’t: “rename the name column to full_name.” On your laptop, against a table with fifty rows, it’s one line of SQL and you’re done. In production, against a table with fifty million rows being read and written thousands of times a second by code you can’t pause, that same one line can lock the table, stall every request queued behind it, and take the product down for the length of the migration. The gap between those two worlds — the toy table and the live one — is one of the quiet dividing lines between writing code and running a system.
The trap is that a schema change and the code that uses it want to happen at the same instant, and in a live system they can’t. Your database and your running application are two separate things deploying on two separate timelines. For some window, old code and new code are both serving traffic, and old schema and new schema are both in play. If you change the column out from under the running app, the old code — still handling requests — suddenly references a column that no longer exists. Crash.
The way out has a couple of names — expand/contract, or parallel change — and its whole idea is to never make a breaking change in one move. You split the one scary change into a sequence of individually safe ones.
Expand. Add the new thing alongside the old one. Create full_name; leave name exactly where it is. Additive changes are safe because nothing existing depends on the new column yet. Then teach the app to write to both columns on every update — a dual-write — so from this moment on, no fresh data goes stale.
Migrate. Backfill the history: copy each existing row’s name into full_name, in batches, deliberately slowly, so you never lock the whole table at once. Then move reads over to the new column — ideally behind a flag, so you can flip back instantly if something looks wrong. Now the app writes both, reads the new one, and the two columns agree.
Contract. Only once nothing reads or writes the old column — verified, not assumed — do you stop writing it, and, later still, drop it. The scary, irreversible step happens last, when it has finally become boring.
Three phases, and notice what you bought: at no single moment was there a version of the running code pointing at something that didn’t exist. Each step was safe on its own, and safe steps deploy on the app’s normal timeline instead of demanding a synchronized big-bang.
The tax is real and worth stating out loud. For the length of the migration you’re maintaining two columns, writing twice, and carrying a backfill job and probably a feature flag — the system is genuinely more complicated during the change than before or after. That’s the price of not being able to stop the world. The instinct to “just rename it” is the instinct to change two coupled things atomically, and a live system is precisely where you don’t get atomicity across the app/database boundary for free.
There’s a general shape here beyond columns. Splitting one table into two, tightening a constraint, changing a type, dropping a field — the live-system version of nearly every “simple” schema change is the same dance: add the new, move traffic gradually, retire the old, and never leave running code without something valid to point at.
Liked this? Get the next one in Working Theory.
Going weekly in August (it's in beta now). One genuinely interesting read on building, the brain, and the science most people missed.