<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>DiffyPick Blog — git</title><description>Posts tagged &quot;git&quot; from the DiffyPick Blog.</description><link>https://diffy-pick.com/</link><language>en</language><item><title>Migration files and Git do not mix</title><link>https://diffy-pick.com/blog/migration-files-and-git-do-not-mix/</link><guid isPermaLink="true">https://diffy-pick.com/blog/migration-files-and-git-do-not-mix/</guid><description>Two conflicting schema changes never produce a merge conflict, because they live in separate files. Being stored in Git is not the same as being managed by it.</description><pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Your repository contains two version control systems.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;strong&gt;being stored in Git is not the same as being managed by Git&lt;/strong&gt;. 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.&lt;/p&gt;
&lt;h2 id=&quot;the-control-group-declarative-schema-files&quot;&gt;The control group: declarative schema files&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-control-group-declarative-schema-files&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To see what’s missing, look at a kind of file Git genuinely manages. A declarative schema file — &lt;code&gt;schema.prisma&lt;/code&gt;, &lt;code&gt;models.py&lt;/code&gt;, &lt;code&gt;schema.rb&lt;/code&gt;, an Ent or sqlc definition — cooperates with Git almost perfectly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Diffs are readable.&lt;/strong&gt; Open the PR and you see which columns changed, in place, in context.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Conflicts happen when they should.&lt;/strong&gt; Two people touch the same table, they touch the same lines, Git flags it. A human gets involved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Revert means something.&lt;/strong&gt; Revert the commit and the declared state is restored.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checkout means something.&lt;/strong&gt; Switch branches and you’re looking at that branch’s schema.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is no accident. Git is built to manage &lt;em&gt;stateful files evolving over time&lt;/em&gt;. A declarative schema is exactly that.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;editing-is-forbidden&quot;&gt;Editing is forbidden&lt;a class=&quot;heading-anchor&quot; href=&quot;#editing-is-forbidden&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;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).&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;the-diff-shows-the-change-not-the-result&quot;&gt;The diff shows the change, not the result&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-diff-shows-the-change-not-the-result&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A schema PR’s diff says: one new file. Inside it, an imperative step — &lt;code&gt;ALTER TABLE users ADD COLUMN plan_id bigint REFERENCES plans&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What the reviewer actually needs to evaluate is the &lt;em&gt;resulting&lt;/em&gt; table. Does &lt;code&gt;users&lt;/code&gt; 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 &lt;em&gt;can&lt;/em&gt; 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.&lt;/p&gt;
&lt;h2 id=&quot;conflicts-dont-happen-when-they-should&quot;&gt;Conflicts don’t happen when they should&lt;a class=&quot;heading-anchor&quot; href=&quot;#conflicts-dont-happen-when-they-should&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This is the one that costs real incidents.&lt;/p&gt;
&lt;p&gt;Two developers, two branches, both modify the &lt;code&gt;users&lt;/code&gt; table in incompatible ways. If schemas were declarative files, they’d edit the same lines, and merging would produce a conflict. Someone would look.&lt;/p&gt;
&lt;p&gt;With migrations, each developer creates a &lt;strong&gt;new file&lt;/strong&gt;. 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, &lt;strong&gt;because the migration format guarantees that concurrent changes never meet in the same file&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;merge-order-and-apply-order-disagree&quot;&gt;Merge order and apply order disagree&lt;a class=&quot;heading-anchor&quot; href=&quot;#merge-order-and-apply-order-disagree&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Branch A is cut first and adds a migration timestamped Monday. Branch B is cut later, migration timestamped Friday. B merges first.&lt;/p&gt;
&lt;p&gt;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 &lt;em&gt;set&lt;/em&gt;, nothing notices. The repo carries one timeline, each database carries its own, and the format has no way to state which is authoritative.&lt;/p&gt;
&lt;p&gt;(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.)&lt;/p&gt;
&lt;h2 id=&quot;revert-doesnt-revert&quot;&gt;Revert doesn’t revert&lt;a class=&quot;heading-anchor&quot; href=&quot;#revert-doesnt-revert&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;git revert&lt;/code&gt; on a code commit restores the previous behavior. &lt;code&gt;git revert&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;The verb worked. It did what it does to files. It’s just that what you needed reverted was never in the files.&lt;/p&gt;
&lt;h2 id=&quot;checkout-doesnt-check-out&quot;&gt;Checkout doesn’t check out&lt;a class=&quot;heading-anchor&quot; href=&quot;#checkout-doesnt-check-out&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The everyday one. You run migrations on a feature branch, then switch back to &lt;code&gt;main&lt;/code&gt;. The code time-travels back. Your local database stays in the future.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;the-snapshot-patch&quot;&gt;The snapshot patch&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-snapshot-patch&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The ecosystem noticed. That’s why Rails maintains &lt;code&gt;schema.rb&lt;/code&gt; / &lt;code&gt;structure.sql&lt;/code&gt;, and why Prisma treats the declarative definition as the source that migrations are &lt;em&gt;generated from&lt;/em&gt;. The fix is telling: put a &lt;strong&gt;declarative file&lt;/strong&gt; back in the repo — reshape the schema into the kind of file Git actually manages — and diffs become readable again, conflicts fire again.&lt;/p&gt;
&lt;p&gt;It’s a real improvement and worth using. Two limits keep it a patch rather than a fix:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The snapshot is &lt;strong&gt;the output of replaying migrations&lt;/strong&gt; — it describes what the files claim, not what any actual database is. Drift is invisible to it by construction.&lt;/li&gt;
&lt;li&gt;It’s one file everyone’s changes flow through, which makes it a conflict magnet of its own.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The snapshot repairs the repo’s relationship with &lt;em&gt;intended&lt;/em&gt; 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.&lt;/p&gt;
&lt;h2 id=&quot;what-to-take-from-this&quot;&gt;What to take from this&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-to-take-from-this&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The point is narrower and more actionable: &lt;strong&gt;a workflow doesn’t earn Git’s protections by being made of files.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;So the safety has to come from somewhere Git can’t see: the databases themselves.&lt;/strong&gt; The repo states what the schema should be; only the live databases know what it is. Compare the two — the &lt;a href=&quot;https://diffy-pick.com/blog/how-to-compare-two-postgresql-schemas/&quot;&gt;drift check from earlier in this series&lt;/a&gt; is one concrete way — and treat the repo’s version as a claim to be tested, not a fact.&lt;/p&gt;
&lt;p&gt;Your migrations are in Git. That is storage, not management — and the difference is where the incidents come from.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I build &lt;a href=&quot;https://diffy-pick.com/&quot;&gt;DiffyPick&lt;/a&gt;, a desktop tool for comparing what your databases actually are — the part no file in your repo can tell you.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>database-migration</category><category>git</category></item></channel></rss>