<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Isabelle Hue</title>
    <description>The latest articles on DEV Community by Isabelle Hue (@isabelle_hue).</description>
    <link>https://dev.to/isabelle_hue</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3964087%2F47693cba-51dc-43c2-8e86-7d0151ed37b9.png</url>
      <title>DEV Community: Isabelle Hue</title>
      <link>https://dev.to/isabelle_hue</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/isabelle_hue"/>
    <language>en</language>
    <item>
      <title>Your fr.json is three keys behind en.json and nobody noticed until a French user did</title>
      <dc:creator>Isabelle Hue</dc:creator>
      <pubDate>Tue, 02 Jun 2026 10:55:08 +0000</pubDate>
      <link>https://dev.to/isabelle_hue/your-app-is-silently-shipping-english-to-french-users-automating-i18n-translations-in-the-pr-1o53</link>
      <guid>https://dev.to/isabelle_hue/your-app-is-silently-shipping-english-to-french-users-automating-i18n-translations-in-the-pr-1o53</guid>
      <description>&lt;p&gt;Every i18n setup I've shipped has the same slow rot: you add a feature, you add the English string, and you fully intend to backfill the other languages "later." Later never comes, the non-English files drift behind, and your French users see raw key strings or fall back to English. So I built a thing that just fills the missing keys in on the PR that introduced them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You add a confirmation dialog. You touch &lt;code&gt;en.json&lt;/code&gt; because that's the language you're thinking in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// locales/en.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"checkout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Checkout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"confirm_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirm your order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"confirm_body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"We'll charge {{count}} item(s) to {cardLast4}. Continue?"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// locales/fr.json  — still the old version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"checkout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Commander"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c1"&gt;// confirm_title and confirm_body don't exist here&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two new keys, zero new French. Depending on your i18n library, the French user now sees the literal &lt;code&gt;cart.confirm_title&lt;/code&gt;, or an empty string, or a silent English fallback. None of those are what you wanted, and your test suite is perfectly happy because the keys all "exist somewhere."&lt;/p&gt;

&lt;p&gt;The annoying part is it's &lt;em&gt;invisible&lt;/em&gt;. The PR looks complete. CI is green. The gap only surfaces in production, in a language you don't read, reported by a user weeks later — if at all.&lt;/p&gt;

&lt;p&gt;And the manual fix is its own trap: hand-translating &lt;code&gt;{{count}}&lt;/code&gt; and &lt;code&gt;{cardLast4}&lt;/code&gt; is exactly where placeholders get mangled, pluralized, or dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;i18n Autopilot is a GitHub Action that runs on every pull request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It diffs your locale files and finds keys present in your base/source locale but &lt;strong&gt;missing&lt;/strong&gt; in the others.&lt;/li&gt;
&lt;li&gt;It translates only those missing keys with AI — it doesn't touch existing translations.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;preserves placeholders&lt;/strong&gt; like &lt;code&gt;{name}&lt;/code&gt; and &lt;code&gt;{{count}}&lt;/code&gt; exactly, so interpolation and pluralization don't break.&lt;/li&gt;
&lt;li&gt;It commits the filled-in translations back to the same PR, so the branch that added the English string ships with every other language already done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After it runs on the PR above, &lt;code&gt;fr.json&lt;/code&gt; comes back complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// locales/fr.json — after i18n Autopilot&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"checkout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Commander"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"confirm_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirmez votre commande"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"confirm_body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Nous débiterons {{count}} article(s) sur {cardLast4}. Continuer ?"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;{{count}}&lt;/code&gt; and &lt;code&gt;{cardLast4}&lt;/code&gt; survived untouched — that's the part hand-translation and naive find-and-replace get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;It's a free GitHub Action on the Marketplace: &lt;a href="https://github.com/marketplace/actions/i18n-autopilot" rel="noopener noreferrer"&gt;https://github.com/marketplace/actions/i18n-autopilot&lt;/a&gt; — add it to a workflow and your next PR comes back with the missing locale keys filled in. There's also a hosted Pro app if you don't want to manage the Action and API key yourself: &lt;a href="https://i18n.useautopilot.dev" rel="noopener noreferrer"&gt;https://i18n.useautopilot.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you maintain anything multilingual, I'd love to hear how far behind your non-English files have quietly drifted. That number is always bigger than people expect.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your AI code reviewer treats .svelte files like plain JS. That's how secrets leak to the browser.</title>
      <dc:creator>Isabelle Hue</dc:creator>
      <pubDate>Tue, 02 Jun 2026 10:44:05 +0000</pubDate>
      <link>https://dev.to/isabelle_hue/the-10-svelte-5-sveltekit-footguns-your-ai-review-bot-waves-through-and-how-to-catch-them-in-pr-16g7</link>
      <guid>https://dev.to/isabelle_hue/the-10-svelte-5-sveltekit-footguns-your-ai-review-bot-waves-through-and-how-to-catch-them-in-pr-16g7</guid>
      <description>&lt;p&gt;I build SvelteKit apps, and I lean on AI PR review like everyone else. But I kept noticing the generic reviewers green-lighting things that would absolutely break in production — because they parse a &lt;code&gt;.svelte&lt;/code&gt; file as if it were a &lt;code&gt;.js&lt;/code&gt; file and miss the framework rules entirely. So I built a reviewer that actually knows Svelte 5. Here's the footgun that pushed me over the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;SvelteKit has a hard rule: anything from &lt;code&gt;$env/static/private&lt;/code&gt; (or &lt;code&gt;$env/dynamic/private&lt;/code&gt;) must never reach client code. The compiler enforces it in a lot of cases, but not all — and the moment a private value flows through a regular module or a shared util, it can end up bundled into the browser.&lt;/p&gt;

&lt;p&gt;Here's a snippet that looks completely innocent in a PR diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- src/routes/contact/+page.svelte --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SENDGRID_API_KEY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$env/static/private&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// "just calling the API directly from the component, ship it"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.sendgrid.com/v3/mail/send&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SENDGRID_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;on:submit=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A generic AI reviewer reads this and sees: an import, a fetch, a form handler. Looks fine. It might even compliment your error handling.&lt;/p&gt;

&lt;p&gt;What it misses: this is a &lt;code&gt;.svelte&lt;/code&gt; component — it runs in the &lt;strong&gt;browser&lt;/strong&gt;. &lt;code&gt;SENDGRID_API_KEY&lt;/code&gt; gets shipped to every visitor's devtools. That's not a style nit, that's your provider key in plaintext on the public internet.&lt;/p&gt;

&lt;p&gt;Same category of misses I kept seeing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;// crashes SSR — window doesn't exist on the server&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- XSS: user-controlled string rendered as raw HTML --&amp;gt;&lt;/span&gt;
&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;@html&lt;/span&gt; &lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;// reactivity silently lost — destructuring breaks the $state proxy&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// mutating `count` now updates nothing&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these are exotic. They're the everyday mistakes you make at 1am, and they all pass a JS-shaped review.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Svelte Autopilot is a GitHub Action that runs on every pull request. It's specialized for Svelte 5 / SvelteKit, so it reviews &lt;code&gt;.svelte&lt;/code&gt; files with the framework's actual rules in mind, not generic JS lint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private env in client code&lt;/strong&gt; — flags &lt;code&gt;$env/static/private&lt;/code&gt; / &lt;code&gt;$env/dynamic/private&lt;/code&gt; reaching anything that ships to the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSR-unsafe top-level access&lt;/strong&gt; — &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt; at module/component top level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{@html}&lt;/code&gt; on user input&lt;/strong&gt; — XSS surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rune reactivity bugs&lt;/strong&gt; — destructured &lt;code&gt;$state&lt;/code&gt; losing reactivity, &lt;code&gt;$effect&lt;/code&gt; used where &lt;code&gt;$derived&lt;/code&gt; is correct, and similar Svelte 5 rune mistakes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It posts findings as a normal PR review with the line and the why. I tested it deliberately: &lt;code&gt;gpt-4o&lt;/code&gt; catches all four categories above; generic reviewers and &lt;code&gt;gpt-4o-mini&lt;/code&gt; miss them. That gap is the whole reason the product exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Free GitHub Action on the Marketplace — drop it in your workflow and it reviews your next PR. There's also a hosted Pro app if you'd rather not manage the workflow and keys yourself: &lt;a href="https://svelte.useautopilot.dev" rel="noopener noreferrer"&gt;https://svelte.useautopilot.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Repo + source: &lt;a href="https://github.com/isabellehuecloser-ctrl/svelte-autopilot" rel="noopener noreferrer"&gt;https://github.com/isabellehuecloser-ctrl/svelte-autopilot&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you ship SvelteKit, I'd genuinely like to know whether it catches something real in your repo. Build-in-public, so feedback is the point.&lt;/p&gt;

</description>
      <category>svelte</category>
      <category>sveltekit</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>The 15 Postgres migration footguns that lock production — and how to catch them in PR review</title>
      <dc:creator>Isabelle Hue</dc:creator>
      <pubDate>Tue, 02 Jun 2026 08:34:29 +0000</pubDate>
      <link>https://dev.to/isabelle_hue/the-15-postgres-migration-footguns-that-lock-production-and-how-to-catch-them-in-pr-review-4dap</link>
      <guid>https://dev.to/isabelle_hue/the-15-postgres-migration-footguns-that-lock-production-and-how-to-catch-them-in-pr-review-4dap</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Most prod migration incidents come from a small, well-known catalogue of SQL patterns. Your CI doesn't catch them. Your staging database doesn't either, because it has 30 000 rows and production has 1.2 billion. This post catalogues the 15 footguns, with safe rewrites, and ships a free GitHub Action (&lt;a href="https://github.com/marketplace/actions/migration-autopilot" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt;) that blocks them in the PR review.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A migration that ran in 0.4s locked production for 20 minutes
&lt;/h2&gt;

&lt;p&gt;The exact pattern, &lt;a href="https://blog.thnkandgrow.com/how-a-simple-migration-took-down-our-system-for-20-minutes-and-how-to-never-let-that-happen-again/" rel="noopener noreferrer"&gt;from a real postmortem&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tested on staging (30 000 rows) — green in 0.4 seconds. Shipped to production (a &lt;code&gt;posts&lt;/code&gt; table with over 1 billion rows). Postgres took an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, scanned every row to validate the constraint, and held the lock for &lt;strong&gt;20 minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For 20 minutes, no read, no write, nothing. Reader traffic queues, writers time out, the app degrades into a 503 page.&lt;/p&gt;

&lt;p&gt;The mechanism is well documented. The fix takes three lines of safer SQL. And yet teams keep doing it, because nothing in the standard PR review flow surfaces &lt;em&gt;"this statement will lock writers for the duration of a full-table scan"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is one of fifteen footguns I want to put on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why staging never catches it
&lt;/h2&gt;

&lt;p&gt;Staging databases lie to you in three different ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Volume&lt;/strong&gt;. A &lt;code&gt;SET NOT NULL&lt;/code&gt; that scans 30 000 rows runs in milliseconds. The same statement against 1.2 billion rows can run for the duration of an episode of a sitcom. Lock duration is roughly linear in row count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contention&lt;/strong&gt;. Your staging instance has one developer poking at it; production has thousands of concurrent connections waiting on every row they need. The lock that's free on staging is a queue depth bomb on prod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions and extensions&lt;/strong&gt;. Lock-upgrade behavior changed between Postgres 11, 12, and 13. Subtle index-build semantics depend on patch versions. Staging often lags. Production often leads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The team that wrote the postmortem above &lt;a href="https://blog.thnkandgrow.com/how-a-simple-migration-took-down-our-system-for-20-minutes-and-how-to-never-let-that-happen-again/" rel="noopener noreferrer"&gt;had&lt;/a&gt; a staging environment. The migration was reviewed. CI passed. None of that helped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catalogue — 15 patterns to never merge
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Data loss
&lt;/h3&gt;

&lt;p&gt;These permanently delete data, or break the running app version mid-deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;DROP COLUMN&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Permanent. And during the deploy window, the previous app version still reads &lt;code&gt;email&lt;/code&gt; and crashes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: ship a code-side stop-reading-this-column release first. Optionally rename to &lt;code&gt;email_deprecated&lt;/code&gt; for a release. Drop the column in a later migration once nothing references it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;DROP TABLE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;legacy_orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same as above, table-scale. Add a check: is anything still referencing it? Foreign keys? A delete in production at 3am can take a service down at 9am.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: rename, observe for a release, drop later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;TRUNCATE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;TRUNCATE&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌ unless you really mean it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Often used as a quick reset in dev that survives into a migration. Production teams rarely intend it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production locks
&lt;/h3&gt;

&lt;p&gt;These take an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock and scan every row. On a big table, that's minutes of blocked writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;SET NOT NULL&lt;/code&gt; on an existing column&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres validates the constraint by scanning every row, holding &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; for the duration. Even &lt;em&gt;checking&lt;/em&gt; an existing NOT NULL constraint requires a scan.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt; (Postgres 12+): add a &lt;code&gt;CHECK (phone IS NOT NULL) NOT VALID&lt;/code&gt; constraint, &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; in a separate transaction, then &lt;code&gt;SET NOT NULL&lt;/code&gt; (which becomes O(1) because the check is already validated).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;ADD COLUMN ... NOT NULL&lt;/code&gt; without a default&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outright fails on any non-empty table — Postgres can't synthesize a value.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: add nullable, backfill in batches, then add the constraint via the &lt;code&gt;NOT VALID&lt;/code&gt; dance above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. &lt;code&gt;ALTER COLUMN ... TYPE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rewrites the entire table. Locks for the duration. The "free" &lt;code&gt;int → bigint&lt;/code&gt; upgrade has bitten more teams than any other type change.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: add a new nullable column of the target type, backfill, swap the application read path, drop the old column much later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. &lt;code&gt;CREATE INDEX&lt;/code&gt; without &lt;code&gt;CONCURRENTLY&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- ❌ on Postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Holds &lt;code&gt;SHARE&lt;/code&gt; lock — blocks writes until the index is built. On a big table, that's minutes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_users_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;8. &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; inside a Prisma/Drizzle migration&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Looks safe! But this fails at runtime.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_users_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both Prisma and Drizzle wrap migrations in a transaction by default. &lt;code&gt;CONCURRENTLY&lt;/code&gt; cannot run inside a transaction. The migration errors out, your deploy fails halfway through, and now you have a half-applied schema to clean up by hand at 3am.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: split the index into a separate migration with &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; stripped, or use the ORM's escape hatch (Prisma: a raw SQL migration created with &lt;code&gt;--create-only&lt;/code&gt; and the transaction wrapper removed).&lt;/p&gt;

&lt;h3&gt;
  
  
  Silent breaks
&lt;/h3&gt;

&lt;p&gt;These don't lock — they break consumers in subtle ways the deployed app version can't anticipate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. &lt;code&gt;ADD CONSTRAINT ... FOREIGN KEY&lt;/code&gt; (without &lt;code&gt;NOT VALID&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_user&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validates every existing row under a lock.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: &lt;code&gt;ADD CONSTRAINT ... NOT VALID&lt;/code&gt;, then &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; in a separate, lock-free transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. &lt;code&gt;ADD CONSTRAINT ... CHECK&lt;/code&gt; (without &lt;code&gt;NOT VALID&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
Same shape as the foreign-key case. Same fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. &lt;code&gt;ADD CONSTRAINT ... UNIQUE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;uq_email&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Builds the underlying unique index under a lock.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: &lt;code&gt;CREATE UNIQUE INDEX CONCURRENTLY&lt;/code&gt;, then &lt;code&gt;ADD CONSTRAINT ... UNIQUE USING INDEX&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. &lt;code&gt;RENAME COLUMN&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ❌ during deploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic on the DB side — but the previous app version still selects &lt;code&gt;fname&lt;/code&gt; and crashes the second the rename commits.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: add a new column, dual-write, migrate readers, drop the old column much later. Or, if downtime is acceptable, schedule it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. &lt;code&gt;RENAME TABLE&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
Same pattern as rename column, one level up.&lt;/p&gt;
&lt;h3&gt;
  
  
  Lower severity but worth a heads-up
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;14. &lt;code&gt;DROP INDEX&lt;/code&gt; without &lt;code&gt;CONCURRENTLY&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_old&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- ⚠&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brief &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;. Usually fast, but on a busy table can cause noticeable timeouts. Prefer &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt; (Postgres 9.2+).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15. Volatile defaults on &lt;code&gt;ADD COLUMN&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;-- ⚠&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres 11+ can skip the table rewrite for constant defaults — but &lt;code&gt;gen_random_uuid()&lt;/code&gt; and &lt;code&gt;now()&lt;/code&gt; are volatile and &lt;em&gt;will&lt;/em&gt; trigger a full rewrite.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Safe rewrite&lt;/em&gt;: add the column nullable, backfill with the generator in batches, set the default afterwards.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about &lt;code&gt;strong_migrations&lt;/code&gt; / Squawk / Atlas?
&lt;/h2&gt;

&lt;p&gt;These are excellent tools. None of them quite fits the modern stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ankane/strong_migrations&lt;/code&gt;&lt;/strong&gt; — Rails-only. If you ship Rails, install this gem today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/sbdchd/squawk" rel="noopener noreferrer"&gt;Squawk&lt;/a&gt;&lt;/strong&gt; — Postgres + raw SQL. Excellent linter, no understanding of Prisma/Drizzle migration semantics. You wire up the CI yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://atlasgo.io/" rel="noopener noreferrer"&gt;Atlas&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;atlas migrate lint&lt;/code&gt; is a top-tier tool. They &lt;a href="https://atlasgo.io/blog/2025/10/15/atlas-pro" rel="noopener noreferrer"&gt;paywalled it in October 2025&lt;/a&gt; ($9/dev + $59/CI project + $39/database).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic AI review bots&lt;/strong&gt; (CodeRabbit, Greptile, Qodo) — don't model lock semantics. They'll happily approve a &lt;code&gt;SET NOT NULL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a gap: hosted, zero-config, deterministic, multi-ORM. So I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch them in the PR — &lt;code&gt;migration-autopilot&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/marketplace/actions/migration-autopilot" rel="noopener noreferrer"&gt;Migration Autopilot&lt;/a&gt; is a free, MIT-licensed GitHub Action that runs the 15 rules above on every pull request and blocks the merge if it finds a high-severity issue.&lt;/p&gt;

&lt;p&gt;📺 &lt;strong&gt;&lt;a href="https://www.loom.com/share/ab8d293bd8b94baa89fb6ab090a2c974" rel="noopener noreferrer"&gt;60-second demo →&lt;/a&gt;&lt;/strong&gt; — three real PRs, three rules triggered, three blocked merges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two minutes to set up:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/migration-review.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Migration Autopilot&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;isabellehuecloser-ctrl/migration-autopilot@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt; &lt;span class="c1"&gt;# block merge on dangerous migrations&lt;/span&gt;
          &lt;span class="na"&gt;dialect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The detection is a deterministic rule engine — no OpenAI key required, no LLM hallucinations, no false positives. A merge-gating tool that cries wolf gets disabled within a week. The rules are derived from Squawk, &lt;code&gt;strong_migrations&lt;/code&gt;, the Atlas PG301-311 series, and the postmortems linked above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supported migration sources&lt;/strong&gt; (auto-detected): Prisma &lt;code&gt;prisma/migrations/&lt;/code&gt;, Drizzle &lt;code&gt;drizzle/&lt;/code&gt;, Rails &lt;code&gt;db/migrate/*.rb&lt;/code&gt; (the Ruby DSL is parsed directly — no Ruby runtime needed), and raw SQL in any directory that smells like migrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dialects&lt;/strong&gt;: Postgres and MySQL. Postgres-specific rules (like &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;) don't fire on MySQL files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd love your feedback on
&lt;/h2&gt;

&lt;p&gt;I'm a solo dev. The free Action is feature-complete; a &lt;a href="https://migration.useautopilot.dev" rel="noopener noreferrer"&gt;hosted Pro version&lt;/a&gt; (1-click install, dashboard, multi-repo policy) is in early access.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What rule is missing?&lt;/strong&gt; If you've hit a footgun the catalogue doesn't cover, open an issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's the false-positive rate on your real codebase?&lt;/strong&gt; Install the Action on a recent branch and tell me what fires wrongly. Zero false positives is the hardest commitment to keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the safe-rewrite text useful, or noise?&lt;/strong&gt; I'd rather it be slightly opinionated than vague.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source code, issues, contributions welcome:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/isabellehuecloser-ctrl/migration-autopilot" rel="noopener noreferrer"&gt;github.com/isabellehuecloser-ctrl/migration-autopilot&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the Action catches a real footgun on a PR of yours — drop a ⭐ on the repo. That's how I know it's landing somewhere.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;References &amp;amp; further reading:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.thnkandgrow.com/how-a-simple-migration-took-down-our-system-for-20-minutes-and-how-to-never-let-that-happen-again/" rel="noopener noreferrer"&gt;How a simple migration took down our system for 20 minutes (thnkandgrow.com)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://news.ycombinator.com/item?id=40194973" rel="noopener noreferrer"&gt;Common DB schema change mistakes in Postgres (HN, 2024)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gitlab.com/gitlab-com/gl-infra/production/-/issues/2731" rel="noopener noreferrer"&gt;GitLab production migration incident 2731 (2020)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://develop.sentry.dev/backend/application-domains/database-migrations/" rel="noopener noreferrer"&gt;Sentry: backend database-migrations doc&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ankane/strong_migrations" rel="noopener noreferrer"&gt;ankane/strong_migrations README&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
