<?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 — database-migration</title><description>Posts tagged &quot;database-migration&quot; from the DiffyPick Blog.</description><link>https://diffy-pick.com/</link><language>en</language><item><title>Why schema drift goes undetected</title><link>https://diffy-pick.com/blog/why-schema-drift-goes-undetected/</link><guid isPermaLink="true">https://diffy-pick.com/blog/why-schema-drift-goes-undetected/</guid><description>Migration history records intent, not the current state of your database. Why environments diverge and nothing catches it.</description><pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here is a scenario from a project in its third year of operation. Migrations are managed properly. CI is green. &lt;code&gt;validate&lt;/code&gt; passes.&lt;/p&gt;
&lt;p&gt;Then an error shows up in production only. The production table has a column that doesn’t exist anywhere else. Nobody remembers who added it, when, or why. There is no migration file for it.&lt;/p&gt;
&lt;p&gt;The important part is not the mystery column. It’s that &lt;strong&gt;this divergence passed every check the team had&lt;/strong&gt;. Nothing was broken. The tooling worked exactly as designed — and, as designed, it does not detect this class of problem.&lt;/p&gt;
&lt;p&gt;This post is about why.&lt;/p&gt;
&lt;h2 id=&quot;code-and-databases-are-not-symmetric&quot;&gt;Code and databases are not symmetric&lt;a class=&quot;heading-anchor&quot; href=&quot;#code-and-databases-are-not-symmetric&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Git made deployment reproducible. Check out any commit and you get exactly the same tree, every time. If CI is green, the contents of that commit are settled.&lt;/p&gt;
&lt;p&gt;Migrations brought the same idea to databases: record every change as a timestamped file, apply them in order, and every environment converges on the same schema.&lt;/p&gt;
&lt;p&gt;There is an asymmetry hiding in that analogy, and it’s rarely stated outright.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Replaying history is deterministic for code.&lt;/strong&gt; The repository’s state is fully determined by its history. If someone hand-edits a file on a server, the next deploy overwrites it. The working tree is disposable; the truth lives in the history.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Replaying history is not deterministic for databases.&lt;/strong&gt; A database is a persistent entity you cannot throw away and rebuild from scratch. And it changes through paths that never touch your migration files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An emergency &lt;code&gt;ALTER TABLE&lt;/code&gt; run directly in a production console during an incident&lt;/li&gt;
&lt;li&gt;Changes made by another team or another tool with write access — GUI clients, BI tools, monitoring agents, batch jobs&lt;/li&gt;
&lt;li&gt;Implicit behavioral differences between engine versions: default collations, implicit type coercions, index length limits&lt;/li&gt;
&lt;li&gt;An index added by hand in production because the data volume demanded it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these leave a trace in migration history.&lt;/p&gt;
&lt;p&gt;A migration directory is an &lt;strong&gt;append-only log of intent&lt;/strong&gt;. It records what someone meant to change. It is not a representation of what the database currently is.&lt;/p&gt;
&lt;p&gt;For code, history is the truth. For a database, the truth is only in the database.&lt;/p&gt;
&lt;h2 id=&quot;two-questions-migrations-cannot-answer&quot;&gt;Two questions migrations cannot answer&lt;a class=&quot;heading-anchor&quot; href=&quot;#two-questions-migrations-cannot-answer&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This asymmetry has practical consequences. There are two questions a migration-based workflow structurally cannot answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. What does the schema look like right now?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Migration files are a sequence of imperative steps, not a declaration. If you want to know the current definition of a table, you’d have to mentally fold hundreds of files in order. Nobody does that. By year three, everyone runs &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; or checks &lt;code&gt;\d&lt;/code&gt; in psql instead — because the live database is more trustworthy than the files.&lt;/p&gt;
&lt;p&gt;And that’s the problem. The moment “the database is more reliable than the migration files” becomes shared team knowledge, the files stop functioning as documentation. Files nobody reads decay. Decayed files get read even less.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. When two environments differ, which one is correct?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is no arbiter. “Whatever you get by replaying all migrations against an empty database” sounds like an answer, but it’s circular — the whole problem is that this result and production disagree.&lt;/p&gt;
&lt;h2 id=&quot;validate-is-not-looking-at-your-database&quot;&gt;&lt;code&gt;validate&lt;/code&gt; is not looking at your database&lt;a class=&quot;heading-anchor&quot; href=&quot;#validate-is-not-looking-at-your-database&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A common misconception is worth clearing up precisely.&lt;/p&gt;
&lt;p&gt;Flyway’s &lt;code&gt;validate&lt;/code&gt; checks that &lt;strong&gt;the checksums of applied migrations still match the files on disk&lt;/strong&gt;. It answers the question “has anyone edited a migration file after it was applied?” It does not answer “does the actual schema match what the migrations describe?”&lt;/p&gt;
&lt;p&gt;Run a manual &lt;code&gt;ALTER&lt;/code&gt; in production and &lt;code&gt;validate&lt;/code&gt; stays green. The files didn’t change, so from its point of view, nothing happened. This is correct behavior — for what the tool is scoped to do.&lt;/p&gt;
&lt;p&gt;To be fair: tooling for the other question does exist. Liquibase ships &lt;code&gt;diff&lt;/code&gt; and &lt;code&gt;diffChangeLog&lt;/code&gt; in its open-source tier. Flyway offers drift detection in its paid editions. But the former assumes a changelog-centric CLI workflow with real setup cost, and the latter sits behind a license. The practical result across most teams is the same: &lt;strong&gt;the capability exists somewhere, and nobody runs it routinely.&lt;/strong&gt; “Technically possible” and “actually happening every day” are very different states.&lt;/p&gt;
&lt;p&gt;While we’re being honest about tooling: most codebases carry down migrations that have never been executed even once. Untested code doesn’t work. “We can roll back if we need to” is, in most shops, a comforting fiction that costs writing effort on every change.&lt;/p&gt;
&lt;h2 id=&quot;the-discipline-problem&quot;&gt;The discipline problem&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-discipline-problem&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Everything so far was mechanical. The deeper problem is organizational.&lt;/p&gt;
&lt;p&gt;A migration workflow looks like a technical mechanism, but what actually holds it together is a &lt;strong&gt;rule&lt;/strong&gt;: every schema change goes through a migration file. No exceptions, no direct changes, everyone, always.&lt;/p&gt;
&lt;p&gt;Rules like this have three properties that determine their fate:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;They can be broken.&lt;/li&gt;
&lt;li&gt;Things keep working when they’re broken.&lt;/li&gt;
&lt;li&gt;Breaking them is not detected.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Any process with all three properties degrades. It’s only a question of when.&lt;/p&gt;
&lt;p&gt;And this particular rule has an extra cost: it must be &lt;strong&gt;transmitted to every future participant for the lifetime of the project&lt;/strong&gt;. It doesn’t matter that the current team understands it perfectly. The engineer who joins in six months, the team that inherits the system in two years, the contractor called in for a 2 a.m. incident five years from now — the rule binds all of them, and any one of them can silently break it.&lt;/p&gt;
&lt;p&gt;The higher your staff turnover, the faster the history rots.&lt;/p&gt;
&lt;p&gt;This is the trade migrations quietly make: &lt;strong&gt;less friction today, paid for with a discipline debt assigned to people who weren’t in the room.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-assumption-that-all-environments-are-identical&quot;&gt;The assumption that all environments are identical&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-assumption-that-all-environments-are-identical&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;There’s one more assumption baked into migration workflows, and it’s worth dragging into the light: &lt;em&gt;every environment should have the same schema.&lt;/em&gt; Apply the same files in the same order, get the same result. Any divergence is an accident.&lt;/p&gt;
&lt;p&gt;Except some divergence is deliberate.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An index that exists only in production, because production has 400× the data. Locally it would just slow the tests down.&lt;/li&gt;
&lt;li&gt;Partitioning applied only in production.&lt;/li&gt;
&lt;li&gt;Read-optimized indexes that exist only on replicas.&lt;/li&gt;
&lt;li&gt;Collation or encoding differences mid-migration.&lt;/li&gt;
&lt;li&gt;Debug views and seed tables that exist only in staging.&lt;/li&gt;
&lt;li&gt;Per-customer schema variations in on-premise or packaged deployments.&lt;/li&gt;
&lt;li&gt;A foreign key constraint deliberately dropped in production for operational reasons.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Try to express these in migrations and you get conditional branches keyed on environment variables — which makes the files even harder to read, which means they get read even less.&lt;/p&gt;
&lt;p&gt;So there is a category of real, legitimate schema state that &lt;strong&gt;migrations cannot express even in principle&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;To be clear, none of this is an argument against migrations. Versioned migration history is the correct practice. It just has a smaller jurisdiction than we usually assume.&lt;/p&gt;
&lt;h2 id=&quot;only-a-human-can-classify-a-difference--and-weve-given-humans-nothing-to-work-with&quot;&gt;Only a human can classify a difference — and we’ve given humans nothing to work with&lt;a class=&quot;heading-anchor&quot; href=&quot;#only-a-human-can-classify-a-difference--and-weve-given-humans-nothing-to-work-with&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Once you accept that intentional drift exists, a task becomes unavoidable: when a difference between environments surfaces, someone has to decide — &lt;strong&gt;is this intentional, or is it an accident?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That decision cannot be automated. Intent lives in people’s heads. No tool can know whether an extra index is a performance fix someone deliberately applied to production, or something an engineer added during an investigation and forgot to remove.&lt;/p&gt;
&lt;p&gt;I think it’s important to say this plainly, because it marks the hard boundary of every “fully automated schema management” pitch: classification requires human judgment, full stop.&lt;/p&gt;
&lt;p&gt;The real problem is not that humans have to look. It’s that &lt;strong&gt;we’ve made looking nearly impossible.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Today your options are roughly:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Open two database clients side by side.&lt;/strong&gt; Works for three tables. Does not work for eighty. This is a limitation of human attention, not effort.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dump both schemas and diff the text.&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;pg_dump&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; --schema-only&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -h&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; host-a&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; mydb&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; a.sql&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;pg_dump&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; --schema-only&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -h&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; host-b&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; mydb&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; b.sql&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;diff&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; a.sql&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; b.sql&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reasonable in theory. In practice, most of what comes out is noise: object ordering differences, &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; counters, formatting variations in defaults, type spelling differences (&lt;code&gt;serial&lt;/code&gt; vs. identity columns), ownership lines, version-dependent &lt;code&gt;SET&lt;/code&gt; statements. You end up hunting for the one meaningful line buried in dozens of irrelevant ones — and humans who scan noisy output for long enough start missing the meaningful lines too.&lt;/p&gt;
&lt;p&gt;That’s the structural gap: &lt;strong&gt;the judgment can only be made by a human, and the output was never designed for human judgment.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here’s the part that makes it tractable. Intentional drift is &lt;em&gt;stable&lt;/em&gt;. That production-only index shows up as the same difference every single time you compare. If the comparison output is structured and noise-free, stable differences become recognizable patterns — after the third look, it’s “ah, that one again,” and the real anomalies stand out against a familiar background.&lt;/p&gt;
&lt;p&gt;People don’t miss differences because there are too many. They miss them because &lt;strong&gt;the same difference looks different every time.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;where-this-leaves-us&quot;&gt;Where this leaves us&lt;a class=&quot;heading-anchor&quot; href=&quot;#where-this-leaves-us&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Migration history records intent. It does not describe the present. The present exists only in the database itself.&lt;/li&gt;
&lt;li&gt;The verification built into migration tools checks files, not databases. Nothing in the default workflow detects drift.&lt;/li&gt;
&lt;li&gt;The workflow’s integrity rests on a convention that is breakable, silently breakable, and must be re-taught forever.&lt;/li&gt;
&lt;li&gt;Some drift is intentional, which means classification is unavoidable, which means human eyes are unavoidable.&lt;/li&gt;
&lt;li&gt;So the leverage point is not more automation of intent — it’s better material for judging results: a clean, structured, repeatable comparison of what your databases actually are.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Manage history with migrations. Verify the present with diffs. These are different jobs, and conflating them is how a column ends up in production that nobody can explain.&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 and syncing database schemas — it exists because of everything above.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>schema-drift</category><category>database-migration</category></item></channel></rss>