<?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 — postgresql</title><description>Posts tagged &quot;postgresql&quot; from the DiffyPick Blog.</description><link>https://diffy-pick.com/</link><language>en</language><item><title>How to compare two PostgreSQL schemas</title><link>https://diffy-pick.com/blog/how-to-compare-two-postgresql-schemas/</link><guid isPermaLink="true">https://diffy-pick.com/blog/how-to-compare-two-postgresql-schemas/</guid><description>Comparing Postgres schemas with pg_dump and diff, a normalization script that removes most of the noise, and where the DIY approach stops.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You have two Postgres databases that are supposed to have the same schema — local and staging, staging and production, or two customer deployments — and you need to know whether they actually do.&lt;/p&gt;
&lt;p&gt;This post walks through doing it with nothing but &lt;code&gt;pg_dump&lt;/code&gt; and standard Unix tools, gets the approach to a genuinely usable state, and is honest about where it stops being enough.&lt;/p&gt;
&lt;h2 id=&quot;the-naive-version&quot;&gt;The naive version&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-naive-version&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&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:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$DB_A&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&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:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$DB_B&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&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:#79B8FF&quot;&gt; -u&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;This works, in the sense that real differences will appear in the output. The problem is what appears alongside them. On any two databases with separate histories, you’ll see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ownership and privilege lines&lt;/strong&gt; (&lt;code&gt;ALTER TABLE ... OWNER TO ...&lt;/code&gt;) differing because the role names differ&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version-dependent preamble&lt;/strong&gt; — &lt;code&gt;SET&lt;/code&gt; statements that vary with the server and pg_dump version&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Object ordering differences.&lt;/strong&gt; pg_dump orders output by dependency and OID, and OIDs reflect each database’s creation history. The same table can appear at a different position in each dump&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comment lines&lt;/strong&gt; (&lt;code&gt;-- Name: users; Type: TABLE; ...&lt;/code&gt;) that shift with the ordering&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Representation differences&lt;/strong&gt; — a column created as &lt;code&gt;serial&lt;/code&gt; versus one created as an identity column dumps as structurally different SQL even when the runtime behavior is equivalent&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The signal is in there, but you’re reading dozens of hunks to find the three that matter. And attention degrades: after a screen of noise, humans start scrolling past real differences too.&lt;/p&gt;
&lt;h2 id=&quot;step-1-cut-the-flags&quot;&gt;Step 1: cut the flags&lt;a class=&quot;heading-anchor&quot; href=&quot;#step-1-cut-the-flags&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Two options remove entire noise categories at the source:&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; --no-owner&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; --no-privileges&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$DB_A&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&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; --no-owner&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; --no-privileges&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$DB_B&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&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;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you don’t intend to compare grants and ownership — and across environments you usually don’t, since roles legitimately differ — remove them from the dump rather than filtering them later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Be clear about what this trades away, though.&lt;/strong&gt; With these flags, privilege drift is invisible by construction: a &lt;code&gt;GRANT&lt;/code&gt; quietly missing in production — or an extra one quietly granted — will never appear in this comparison, and a permissions gap can matter more than a mystery column. If privileges are part of what you need to verify, keep &lt;code&gt;--no-owner&lt;/code&gt;, drop &lt;code&gt;--no-privileges&lt;/code&gt;, and absorb the legitimate role-name differences with the allow-list from Step 3 instead. (Credit to a commenter on the previous post for calling out that the original version of this article took the noise reduction without stating its cost.)&lt;/p&gt;
&lt;h2 id=&quot;step-2-normalize&quot;&gt;Step 2: normalize&lt;a class=&quot;heading-anchor&quot; href=&quot;#step-2-normalize&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The remaining big two are comments/preamble and ordering. Both can be handled with a short script:&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:#6A737D&quot;&gt;#!/usr/bin/env bash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# normalize-schema.sh — make pg_dump output diff-friendly&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -euo&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; pipefail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;normalize&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;  grep&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -vE&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;^(--|SET |SELECT pg_catalog|\\(un)?restrict)&apos;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;    sed&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -E&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;s/[[:space:]]+$//&apos;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;    awk&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;BEGIN{blank=0} /^$/{blank++; if(blank&amp;gt;1) next} /./{blank=0} {print}&apos;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;    awk&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;BEGIN{RS=&quot;&quot;; ORS=&quot;\0&quot;} {print}&apos;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;    sort&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -z&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;    tr&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;\0&apos;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;\n&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;normalize&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;${1&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;sql&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;.normalized.sql&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Line by line: drop comment lines, &lt;code&gt;SET&lt;/code&gt; statements, catalog selects, and &lt;code&gt;\restrict&lt;/code&gt;/&lt;code&gt;\unrestrict&lt;/code&gt; markers (recent pg_dump versions emit these with a random token that differs on every run — a guaranteed false positive if left in); strip trailing whitespace; collapse blank-line runs; then treat each blank-line-separated block as a record and sort the blocks.&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;./normalize-schema.sh&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;./normalize-schema.sh&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:#79B8FF&quot;&gt; -u&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; a.normalized.sql&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; b.normalized.sql&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interesting step is the last one. Treating each blank-line-separated block as a record and sorting the blocks means every &lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;CREATE INDEX&lt;/code&gt;, and &lt;code&gt;ALTER TABLE ... ADD CONSTRAINT&lt;/code&gt; statement lands at a deterministic position regardless of the order pg_dump emitted it. Ordering noise disappears entirely; what’s left in the diff is actual structural difference.&lt;/p&gt;
&lt;p&gt;To put numbers on it: I tested this against two databases with three planted differences (a &lt;code&gt;NOT NULL&lt;/code&gt; mismatch, an extra column, a production-only index) plus deliberately different object creation order. The naive diff was 141 lines. Normalized: 71 lines, all three differences clearly visible, and — verified separately with two identically-schemed databases built in different order — &lt;strong&gt;zero false positives from ordering.&lt;/strong&gt; The output is also deterministic: same input, byte-identical output, every run.&lt;/p&gt;
&lt;p&gt;This gets you surprisingly far. For a periodic “are these two databases still the same” check, it’s most of the way there.&lt;/p&gt;
&lt;h2 id=&quot;what-it-still-cant-see-through&quot;&gt;What it still can’t see through&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-still-cant-see-through&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Some noise is semantic, and text processing can’t normalize it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;serial&lt;/code&gt; vs. identity.&lt;/strong&gt; One database was built with &lt;code&gt;serial&lt;/code&gt;, the other migrated to &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;. The dumps differ substantially; the behavior barely does. Whether this counts as drift is a judgment call — it may be a migration in progress.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default expression spelling.&lt;/strong&gt; &lt;code&gt;now()&lt;/code&gt; vs. &lt;code&gt;CURRENT_TIMESTAMP&lt;/code&gt;, &lt;code&gt;&apos;0&apos;::integer&lt;/code&gt; vs. &lt;code&gt;0&lt;/code&gt;. Same effect, different text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Type aliases.&lt;/strong&gt; &lt;code&gt;timestamp without time zone&lt;/code&gt; is stable in dumps, but defaults and check constraints echo whatever spelling they were created with.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Constraint names.&lt;/strong&gt; Auto-generated names (&lt;code&gt;users_email_key&lt;/code&gt;, &lt;code&gt;fk_orders_user_id&lt;/code&gt;) can differ between databases even when the constraints are identical, and every dependent line differs with them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these produces a diff hunk that a human has to look at and classify. Which is fine at low volume — the point of normalization is to get the volume low enough that classification is feasible.&lt;/p&gt;
&lt;h2 id=&quot;step-3-make-it-a-drift-check&quot;&gt;Step 3: make it a drift check&lt;a class=&quot;heading-anchor&quot; href=&quot;#step-3-make-it-a-drift-check&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Once the comparison is cheap, run it on a schedule. The most useful variant compares &lt;strong&gt;what migrations claim&lt;/strong&gt; against &lt;strong&gt;what production is&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:#6A737D&quot;&gt;# 1. Build the expected schema: replay all migrations on a scratch database&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;createdb&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; scratch_expected&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;migrate&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -database&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$SCRATCH_URL&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; up&lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;   # your migration tool here&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# 2. Dump both sides and normalize&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; --no-owner&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; --no-privileges&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$SCRATCH_URL&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; expected.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; --no-owner&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; --no-privileges&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$PROD_URL&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; actual.sql&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;./normalize-schema.sh&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; expected.sql&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;./normalize-schema.sh&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; actual.sql&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# 3. Fail loudly on any difference&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:#79B8FF&quot;&gt; -u&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; expected.normalized.sql&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; actual.normalized.sql&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wire that into CI as a nightly job and you’ve closed the biggest gap in a migration-based workflow: drift now surfaces in days, not on the next incident.&lt;/p&gt;
&lt;p&gt;One thing you will immediately need: an &lt;strong&gt;allow-list&lt;/strong&gt;. Some differences are intentional — the production-only index, the replica-only tuning — and they’ll show up every night. Grep them out with a maintained pattern file, and accept the trade-off you’ve just made: the allow-list is itself a small new convention someone has to maintain. In exchange, your intentional drift is now written down for the first time, which is more than most teams have.&lt;/p&gt;
&lt;h2 id=&quot;where-diy-stops&quot;&gt;Where DIY stops&lt;a class=&quot;heading-anchor&quot; href=&quot;#where-diy-stops&quot; aria-label=&quot;Link to this section&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Everything above answers &lt;em&gt;detection&lt;/em&gt;. If detection is all you need — you review the nightly diff and fix things through your normal migration flow — the scripts on this page are honestly enough, and you should use them.&lt;/p&gt;
&lt;p&gt;What they don’t give you:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Applying the difference.&lt;/strong&gt; The diff shows &lt;code&gt;name character varying(255)&lt;/code&gt; on one side and &lt;code&gt;NOT NULL&lt;/code&gt; on the other. Producing the &lt;code&gt;ALTER TABLE&lt;/code&gt; statements — in a form that accounts for what’s actually there — is still on you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ordering.&lt;/strong&gt; Creating a table with a foreign key requires the referenced table first. Dropping a column requires dropping dependent indexes and constraints first. With dozens of selected changes, dependency ordering becomes a real graph problem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safety.&lt;/strong&gt; Adding &lt;code&gt;NOT NULL&lt;/code&gt; to a column that contains NULLs fails — or worse, a type change succeeds and mangles data. Text diffs know nothing about the data the schema holds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semantic equivalence.&lt;/strong&gt; The &lt;code&gt;serial&lt;/code&gt;-vs-identity problem above never fully goes away in text.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In the open-source ecosystem, &lt;a href=&quot;https://github.com/djrobstep/migra&quot;&gt;migra&lt;/a&gt; deserves a mention: it compares two live Postgres databases at the catalog level rather than the text level, and emits the &lt;code&gt;ALTER&lt;/code&gt; statements for the difference. If you live entirely in Postgres and entirely in the terminal, it solves a good chunk of items 1 and 4.&lt;/p&gt;
&lt;p&gt;Items 2 and 3 — and doing any of this across MySQL, SQL Server, or SQLite, or with someone on the team who won’t run a CLI — are the point where I stopped scripting and built &lt;a href=&quot;https://diffy-pick.com/&quot;&gt;DiffyPick&lt;/a&gt;: a desktop tool (macOS / Windows / Linux) that does the extraction, classification, dependency ordering, and pre-flight data checks, with the diff presented one line per difference. The free tier covers SQLite with no limits if you want to see the approach.&lt;/p&gt;
&lt;p&gt;But start with the script. If the nightly diff stays quiet, you may never need anything else — and you’ll know within a week either way.&lt;/p&gt;
</content:encoded><category>postgresql</category><category>schema-drift</category></item></channel></rss>