Own Every Layer

Sixty-two migrations and no rollback button

Belief 05 · 13 min read ·

We have sixty-two migration files. They are plain SQL, written by hand, reviewed like code, and applied in order at service start. There is no generate-from-entities step and no rollback button.

That last absence is the one people question, so it is worth answering directly: a down migration is a comforting fiction for anything that loses data. You cannot un-drop a column. What you can do is never write a migration that requires undoing — which is a design discipline, not a tooling feature.

Why the SQL is handwritten

An ORM that generates schema from entity classes is genuinely convenient, and it quietly moves an important decision to a place where nobody reviews it.

When a schema change is a side effect of editing a class, the review question becomes “does this class look right?” When it is a file of SQL, the review question becomes “what will this statement do to a table with real rows in it, on the day it runs?” Those are different questions and only the second one catches the lock that holds a table for four minutes during a deploy.

The schema is the most permanent thing you own. Application code can be rewritten in an afternoon; the data outlives every line of it.

The one absolute rule

Never edit a migration that has been applied. Not to fix a typo, not to add a column you forgot, not because it is only on staging. Add a new numbered file instead.

The mechanical reason is that the migration tool stores a checksum of each applied file. Edit an applied file and the checksum no longer matches, validation fails, and the service refuses to start. But the mechanical reason is downstream of the real one: databases in different environments have already run the old version. Editing history does not change what those databases did. It only makes the repository disagree with reality, and the disagreement surfaces as a boot failure on the environment you least wanted to touch.

The incident: every deploy exited 1

Here is what that looks like when it goes wrong, from our own logs rather than from a textbook.

Two branches were open at once. One added V47 and V48. The other added V49. The second merged and deployed first, so the production database ran V49. Then the first branch merged, and the repository now contained two migrations numbered below the highest one already applied.

The default behaviour is to treat that as a corrupted history and abort. It does so before the application context finishes starting, so the failure is not a degraded feature. It is the service exiting 1 at boot.

Symptom   every deploy exits 1 during startup, from 2026-08-21
Cause     V47, V48 merged after V49 had already been applied
Effect    database pinned at V49 while the repository reached V60
Duration  weeks — the service ran on its last good container

The detail that makes this instructive is the gap in that table. The database sat at V49 while the repository moved on to V60. Eleven migrations of schema change existed in code, were reviewed, were merged, and had never touched a database. Every feature depending on them was shipped in the sense that it was on the default branch, and unshipped in the sense that no user could ever have reached it.

A deploy that fails loudly is an incident. A deploy that fails at boot and leaves the previous container serving traffic is an incident wearing a disguise.

The fix, and the obligation it creates

We enabled out-of-order migration. A migration numbered below the current high water mark is applied when it arrives rather than treated as corruption:

flyway:
  out-of-order: true
  baseline-on-migrate: false

This is a trade, not a free fix, and the comment in our configuration says so at length. Branches merge in an order nobody controls, so a late-arriving migration should run rather than wedge the service. The price is that migrations must now be independent of each other’s ordering.

That is a real constraint on how you write SQL. You may not assume the file numbered above yours has run. If a migration genuinely depends on an earlier one, the dependency has to be expressed in the SQL — a guard, a conditional, an explicit check — rather than implied by an integer in a filename. Version numbers look like sequence and are actually just names.

We also hit the sibling problem: two branches both claiming the same version number. Two files called V60 is not an ordering question, it is an ambiguity, and the tool refuses. The resolution is renaming the unapplied one, which is safe precisely because it has not run anywhere yet. It is the one moment when renaming a migration is correct, and it is only correct before it applies.

Writing a migration that never needs undoing

The practice that makes the absence of rollbacks survivable is expand and contract. A change that would break the running version is split into steps that each leave the system working:

  1. Expand. Add the new column, nullable, with no constraint. Old code ignores it. Nothing breaks.
  2. Migrate. Deploy code that writes both old and new, and backfill existing rows.
  3. Switch. Deploy code that reads the new column.
  4. Contract. Only once nothing reads the old column, and only in a later release, drop it.

Four deploys to rename a column, which sounds absurd until the first time you do it in one step and discover that a deploy is not atomic. For a period measured in minutes, old and new code are both running against one database. Every schema change has to be correct for both, and expand-contract is simply the discipline of taking that seriously.

The other habit worth naming is that a destructive step gets its own migration. Never a drop in the same file as an add. One file, one intent, so the risky ones are visible in a diff by their filename.

Try this

  1. Look at the last schema change you shipped and ask what would have happened if the old and new application versions had both been running against it for four minutes. That is not a hypothetical; it is what a rolling deploy is.
  2. Check whether a failed migration in your stack stops the service or lets it start degraded. Both are defensible. Not knowing which is not.
  3. Find out what your deploy does when a migration fails at boot. If the answer is “keeps serving the old container”, make sure something tells a human, because nothing else will.

Own every layer.