The dangerous migration is not the one that changes the schema. It is the one that assumes every running app instance, job worker, and background consumer will switch expectations at exactly the same instant.
That assumption is almost never safe in production.
What Expand and Contract Means
The pattern works because it accepts overlap between the old world and the new world:
expand the schema
keep old code working
backfill or dual-write
switch reads and writes
remove the legacy shape later
For example, moving from full_name to first_name and last_name usually starts with expansion:
UPDATE usersSET first_name = split_part(full_name, ' ', 1), last_name = substring(full_name from position(' ' in full_name) + 1);
Only after the application reads the new columns safely do you remove full_name.
Why This Pattern Is Worth the Extra Steps
Expand and contract is slower than a one-shot breaking migration, but it handles real production conditions better:
rolling deploys
background jobs on older versions
retries against mixed application versions
partial backfills that need monitoring
The extra steps are not ceremony. They are what buy you compatibility during change.
Trade-Offs
The cost is temporary complexity:
more code paths for a while
migration bookkeeping
cleanup work later
Teams get into trouble when they do the expand phase and forget the contract phase. If the legacy path never gets removed, the migration becomes permanent architecture debt.