Migration files and Git do not mix
On this page
Your repository contains two version control systems.
One is Git. The other is your migration directory — a timestamped, append-only sequence of schema changes with its own applied-state tracking in the database. Two systems, two timelines, living in the same repo. And they do not synchronize.
Because migrations are files, it’s natural to assume they inherit the protections Git gives files: reviewable diffs, merge conflicts where work collides, meaningful revert, meaningful checkout. This post walks through those protections one by one and shows that, for migrations, each is quietly absent — not because Git is deficient, but because being stored in Git is not the same as being managed by Git. I think the pattern is underdiagnosed: a lot of “weird database problems” in day-to-day development are really this one mismatch wearing different costumes.
The control group: declarative schema files
To see what’s missing, look at a kind of file Git genuinely manages. A declarative schema file — schema.prisma, models.py, schema.rb, an Ent or sqlc definition — cooperates with Git almost perfectly:
- Diffs are readable. Open the PR and you see which columns changed, in place, in context.
- Conflicts happen when they should. Two people touch the same table, they touch the same lines, Git flags it. A human gets involved.
- Revert means something. Revert the commit and the declared state is restored.
- Checkout means something. Switch branches and you’re looking at that branch’s schema.
This is no accident. Git is built to manage stateful files evolving over time. A declarative schema is exactly that.
A migration directory is not that. It’s an event log wearing a file system costume. The costume is convincing — text files, in a directory, in the repo — and every Git operation degrades the moment it touches what’s underneath.
Editing is forbidden
Git’s core verb is “change this file.” Applied migrations must never change — edit one and every environment that already ran it now disagrees with your repo (your migration tool’s checksum validation will make this loudly clear).
So the migration directory prohibits, by convention, the fundamental operation of the system that stores it. Append-only. Git will happily let you violate this at any time, of course. It has no idea the convention exists — the convention isn’t expressible in anything Git understands.
The diff shows the change, not the result
A schema PR’s diff says: one new file. Inside it, an imperative step — ALTER TABLE users ADD COLUMN plan_id bigint REFERENCES plans.
What the reviewer actually needs to evaluate is the resulting table. Does users already have an index that covers this? Is there a conflicting nullable pattern? What does the table even look like now? None of that is in the diff — and none of it can be, because the event-log format only ever states deltas. Code review works because the diff shows the future state in context. Migration review inspects a delta against a state that isn’t on the screen, and the reviewer reconstructs the current schema from memory — or, more honestly, doesn’t.
Conflicts don’t happen when they should
This is the one that costs real incidents.
Two developers, two branches, both modify the users table in incompatible ways. If schemas were declarative files, they’d edit the same lines, and merging would produce a conflict. Someone would look.
With migrations, each developer creates a new file. Different filenames. Textually, there is nothing to conflict. Both branches merge clean. Both migrations apply. The changes are semantically incompatible, and no merge machinery — Git’s or anyone’s — will ever see it, because the migration format guarantees that concurrent changes never meet in the same file.
Git protects you when concurrent work collides in the same place. Migrations arrange for concurrent work never to collide in the same place. The safety mechanism isn’t weakened; it’s unplugged — by the format, not by the tool.
Merge order and apply order disagree
Branch A is cut first and adds a migration timestamped Monday. Branch B is cut later, migration timestamped Friday. B merges first.
The repository now shows Monday → Friday. Your shared environment applied Friday first, then Monday arrived. History as recorded and history as executed disagree — and since most tools track applied migrations as a set, nothing notices. The repo carries one timeline, each database carries its own, and the format has no way to state which is authoritative.
(Fairness requires a caveat: Django models migration dependencies as an explicit DAG and forces branch divergence to be resolved with a merge migration. This is a genuinely better design, and it narrows this particular failure. Maturity here varies a lot across ecosystems — most timestamp-ordered tools have nothing equivalent.)
Revert doesn’t revert
git revert on a code commit restores the previous behavior. git revert on a migration commit deletes a file. The database keeps the change; the history table keeps the record; the repo now claims a migration that the database remembers applying doesn’t exist. You haven’t undone anything — you’ve manufactured a new inconsistency.
The verb worked. It did what it does to files. It’s just that what you needed reverted was never in the files.
Checkout doesn’t check out
The everyday one. You run migrations on a feature branch, then switch back to main. The code time-travels back. Your local database stays in the future.
Bounce between a few feature branches for a week and your local database reaches a state that corresponds to no branch at all — a personal, unreproducible accumulation. I’d wager a meaningful fraction of “works on my machine / breaks only on my machine” traces back to exactly this. And because the state is unreproducible, nobody can share or verify it.
The snapshot patch
The ecosystem noticed. That’s why Rails maintains schema.rb / structure.sql, and why Prisma treats the declarative definition as the source that migrations are generated from. The fix is telling: put a declarative file back in the repo — reshape the schema into the kind of file Git actually manages — and diffs become readable again, conflicts fire again.
It’s a real improvement and worth using. Two limits keep it a patch rather than a fix:
- The snapshot is the output of replaying migrations — it describes what the files claim, not what any actual database is. Drift is invisible to it by construction.
- It’s one file everyone’s changes flow through, which makes it a conflict magnet of its own.
The snapshot repairs the repo’s relationship with intended schema state, precisely by giving Git a file in the shape it was built for. The relationship between intended and actual state — the drift problem — is untouched, and no file in the repository can touch it, because the information isn’t in the repository. It’s in the database.
What to take from this
Nothing here says migrations are wrong. Append-only event logs are how you replay changes across environments; that job is real and migrations do it. And nothing here says Git is deficient — every operation above did exactly what it does to files.
The point is narrower and more actionable: a workflow doesn’t earn Git’s protections by being made of files. Git’s guarantees attach to a specific kind of file — mutable, stateful, edited in place — and migrations are deliberately none of those things. For schema changes, what Git supplies is storage and ordering. Conflict detection, meaningful review context, revert, state synchronization: every protection you rely on for code is absent here, and was never on offer.
So the safety has to come from somewhere Git can’t see: the databases themselves. The repo states what the schema should be; only the live databases know what it is. Compare the two — the drift check from earlier in this series is one concrete way — and treat the repo’s version as a claim to be tested, not a fact.
Your migrations are in Git. That is storage, not management — and the difference is where the incidents come from.
I build DiffyPick, a desktop tool for comparing what your databases actually are — the part no file in your repo can tell you.