<?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: Bolvrk.com</title>
    <description>The latest articles on DEV Community by Bolvrk.com (@bolvrk).</description>
    <link>https://dev.to/bolvrk</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4130154%2F9c653250-2d1d-440f-a981-c40c5144a236.png</url>
      <title>DEV Community: Bolvrk.com</title>
      <link>https://dev.to/bolvrk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bolvrk"/>
    <language>en</language>
    <item>
      <title>Deterministic checks for AI-written migrations</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:44 +0000</pubDate>
      <link>https://dev.to/bolvrk/deterministic-checks-for-ai-written-migrations-3m9h</link>
      <guid>https://dev.to/bolvrk/deterministic-checks-for-ai-written-migrations-3m9h</guid>
      <description>&lt;p&gt;A coding agent asked to "add a &lt;code&gt;status&lt;/code&gt; column to &lt;code&gt;orders&lt;/code&gt;" will do it in seconds. It will also, more often than not, write the version that fails on a table with rows in it, or the version that takes an exclusive lock and holds it for the whole rewrite. Not because the model is bad at SQL, because the dangerous form and the safe form look almost identical, and the difference only matters at production scale, which the model never sees.&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;-- what the agent wrote&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;status&lt;/span&gt; &lt;span class="nb"&gt;text&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;-- what survives contact with a table that has rows&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IS&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;-- in batches&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;status&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;That first statement is &lt;a href="https://bolvrk.com/rules/bv002" rel="noopener noreferrer"&gt;BV002&lt;/a&gt;. It is one of the oldest and best-understood migration mistakes there is, and it is exactly the kind of thing an AI reviewer will sometimes catch and sometimes wave through, depending on the prompt, the context window, and the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the reviewer cannot be another model
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to ask a second model to review the first model's migration. It works often enough to feel like it works. It does not work in the way a team can build a process on, for three reasons.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not repeatable.&lt;/strong&gt; The same migration reviewed twice can get two verdicts. A pull-request check that is red on Tuesday and green on Wednesday for the same diff teaches people to click through it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot say what it did not check.&lt;/strong&gt; A model that returns "looks fine" gives you no list of the properties it verified. A rule corpus is a list by construction: these 104 patterns were checked, these fired, these did not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its claims are not verifiable.&lt;/strong&gt; When a rule says "this rewrites the table under an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock", that claim was tested against a real Postgres and the fixture is in the repository. A model's claim is a sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is an argument against using agents to write migrations. It is an argument about where the trust boundary goes. The agent proposes; something deterministic disposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a deterministic check looks like in an agent workflow
&lt;/h2&gt;

&lt;p&gt;The check has to sit where the agent can reach it, and it has to return the same shape every time so the agent can act on it without interpretation. Three seperate placements cover most setups.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inside the agent's loop.&lt;/strong&gt; Bolvrk runs as an &lt;a href="https://bolvrk.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;. An agent that has just drafted a migration calls the &lt;code&gt;check&lt;/code&gt; tool, gets findings back as JSON with a rule id, a severity and the fix, and revises before the human ever sees the draft. The skills library in the public repository tells the agent when to do this unprompted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the terminal.&lt;/strong&gt; &lt;code&gt;npx bolvrk check migration.sql&lt;/code&gt;, no account, no config, exit code 1 on findings. This is the same rule set the MCP server runs, so a person double-checking an agent's work sees exactly what the agent saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On the pull request.&lt;/strong&gt; The &lt;a href="https://bolvrk.com/docs/github-action" rel="noopener noreferrer"&gt;GitHub Action&lt;/a&gt; posts one summary comment and fails the job above the severity the team chose. Whoever, or whatever, opened the PR, the gate is the same.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point of running the same corpus in all three places is that the verdict does not change as the migration moves from the agent's draft to the terminal to CI. The agent cannot talk its way past the Action, because the Action is not listening to arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that matter most for generated SQL
&lt;/h2&gt;

&lt;p&gt;Looking at what agents actually produce, a handful of rules do most of the work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://bolvrk.com/rules/bv002" rel="noopener noreferrer"&gt;BV002&lt;/a&gt;, &lt;code&gt;ADD COLUMN … NOT NULL&lt;/code&gt; without a default.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bolvrk.com/rules/bv003" rel="noopener noreferrer"&gt;BV003&lt;/a&gt;, an index created without &lt;code&gt;CONCURRENTLY&lt;/code&gt;, blocking writes for the whole build.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bolvrk.com/rules/bv034" rel="noopener noreferrer"&gt;BV034&lt;/a&gt;, a lock taken with no &lt;code&gt;lock_timeout&lt;/code&gt;, so one slow transaction turns into a queue behind it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bolvrk.com/rules/bv030" rel="noopener noreferrer"&gt;BV030&lt;/a&gt;, a &lt;code&gt;DROP COLUMN&lt;/code&gt; while code that reads the column is still deployed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these has a page that says what the rule catches, the SQL it fires on, and the safe pattern. That is deliberate: when an agent explains a finding to a person, or a person to an agent, the explanation should come from the same place and say the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;A rule corpus does not know your buisness. It cannot tell you that the backfill will take four hours on your data, or that the column you are dropping is read by a report nobody has looked at since 2024. Live-schema context narrows that gap, a read-only connection lets the rules see the real table sizes and constraints, but judgement stays with the team. The corpus removes the category of mistakes that need no judgement at all, so the judgement can go where it is needed.&lt;/p&gt;

&lt;p&gt;If you are letting agents write migrations, the question is not whether to review them. It is whether the review is something you can rely on the same way every time. Start with &lt;code&gt;npx bolvrk check&lt;/code&gt; on the last migration an agent wrote for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/deterministic-checks-for-ai-written-migrations" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiworkflows</category>
      <category>postgres</category>
      <category>migrations</category>
    </item>
    <item>
      <title>Vibe coding without being reckless</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:43 +0000</pubDate>
      <link>https://dev.to/bolvrk/vibe-coding-without-being-reckless-587</link>
      <guid>https://dev.to/bolvrk/vibe-coding-without-being-reckless-587</guid>
      <description>&lt;p&gt;Vibe coding, describing what you want, accepting what the model writes, running it, and iterating on the result rather than the code, is a genuinely good way to build a lot of software. Most of a product is UI, glue and business logic that is cheap to be wrong about, because being wrong shows up immediately and costs a retry. The reckless part is not the vibe. It is applying the same loop to the small set of changes where being wrong shows up later, at scale, and is not undone by a retry.&lt;/p&gt;

&lt;p&gt;So the question is not "should I vibe code" but "which parts of this change are safe to vibe". The answer turns out to be short.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts you can vibe
&lt;/h2&gt;

&lt;p&gt;Anything with a fast, honest feedback loop. If a mistake makes a test fail, a page render wrong, or an API return the wrong shape, you will see it within seconds and the model will fix it on the next turn. Components, handlers, transformations, most refactors, most tests. Let it drive. Read the diff the way you would read a collegue's, not the way you would audit a contractor's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts you cannot
&lt;/h2&gt;

&lt;p&gt;Three kinds of change have no fast feedback loop, because the failure mode does not exist in a development environment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schema migrations.&lt;/strong&gt; On a dev database with forty rows, every migration is instant and every migration works. &lt;code&gt;ALTER TABLE orders ADD COLUMN status text NOT NULL&lt;/code&gt; (&lt;a href="https://bolvrk.com/rules/bv002" rel="noopener noreferrer"&gt;BV002&lt;/a&gt;) succeeds on an empty table and fails on a full one. An index built without &lt;code&gt;CONCURRENTLY&lt;/code&gt; (&lt;a href="https://bolvrk.com/rules/bv003" rel="noopener noreferrer"&gt;BV003&lt;/a&gt;) takes a second locally and blocks writes for twenty minutes in production. The model has seen millions of migrations and almost none of them ran against a table with a hundred million rows, so it has no reason to prefer the safe form. Neither does your test suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials.&lt;/strong&gt; A model that needs a database URL to make a script work will happily inline the one it found in &lt;code&gt;.env&lt;/code&gt;, and a model that is asked to "make the migration self-contained" will do the same. The commit passes every test. It also passes the connection string into git history, where it stays. Nothing in the vibe loop flags this, because nothing in the vibe loop is looking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive data operations.&lt;/strong&gt; &lt;code&gt;DROP COLUMN&lt;/code&gt; (&lt;a href="https://bolvrk.com/rules/bv030" rel="noopener noreferrer"&gt;BV030&lt;/a&gt;), &lt;code&gt;DELETE&lt;/code&gt; without a &lt;code&gt;WHERE&lt;/code&gt;, a backfill that rewrites every row. These are correct in the sense that they do exactly what was asked. They are only wrong in the sense that the previous deploy is still reading the column, or that the rows were not yours to delete. That context lives in the deploy window and the business, not in the code the model can see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What these have in common is that the person or agent making the change cannot observe the consequence before committing to it. The loop is open. Something has to close it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close the loop with a rule, not with vigilance
&lt;/h2&gt;

&lt;p&gt;The tempting answer is "just review migrations carefully". It does not survive contact with a team that ships forty pull requests a day, half of them opened by agents, at eleven at night. Vigilance is a budget; it runs out exactly when the migration count goes up, and it definately runs out first at night.&lt;/p&gt;

&lt;p&gt;The answer that scales is a gate that does not get tired. For the three categories above, the dangerous forms are known and enumerable, locks, rewrites, missing defaults, missing timeouts, credentials in the diff, which means they can be checked deterministically: the same input, the same verdict, every time, with a rule id and a fix attached. That is what Bolvrk is. It sits at the three places a generated change passes through, and it does not matter which one catches it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before the draft leaves the agent&lt;/strong&gt;, the &lt;a href="https://bolvrk.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; gives the agent a &lt;code&gt;check&lt;/code&gt; tool, and the skills library tells it to call the tool before proposing SQL. The agent revises its own migration; you never see the first draft.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before you commit&lt;/strong&gt;, &lt;code&gt;npx bolvrk check migration.sql&lt;/code&gt; in the terminal, and &lt;code&gt;bolvrk secrets&lt;/code&gt; on the diff or on any output the agent pasted. No account, exit code 1 on findings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before it merges&lt;/strong&gt;, the &lt;a href="https://bolvrk.com/docs/github-action" rel="noopener noreferrer"&gt;GitHub Action&lt;/a&gt; fails the job above the severity your team chose and explains each finding on the pull request. This is the one that holds when the first two were skipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A working rule of thumb
&lt;/h2&gt;

&lt;p&gt;Vibe everything whose mistakes you would see in the next sixty seconds. Gate everything whose mistakes you would see in the next sixty days. The gate should be a program, not a person, and it should give the same answer to the agent that it gives to you.&lt;/p&gt;

&lt;p&gt;That is the whole discipline. It costs one command per migration and one Action in the repo, and it lets you keep the speed for the ninety percent of changes that deserve it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/vibe-coding-without-being-reckless" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiworkflows</category>
      <category>vibecoding</category>
      <category>migrations</category>
      <category>credentials</category>
    </item>
    <item>
      <title>One line stops a migration from taking down production. Almost nobody adds it.</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:42 +0000</pubDate>
      <link>https://dev.to/bolvrk/one-line-stops-a-migration-from-taking-down-production-almost-nobody-adds-it-30nm</link>
      <guid>https://dev.to/bolvrk/one-line-stops-a-migration-from-taking-down-production-almost-nobody-adds-it-30nm</guid>
      <description>&lt;p&gt;Here is how a migration that does no work at all takes a table offline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ALTER TABLE orders ADD COLUMN note text;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nullable column, no default. This is a catalog change; on its own it holds its lock for a few milliseconds. It does not matter how big the table is. What matters is who else is holding a lock on the table when it asks for its own, and that is something the migration author can not see and did not think about, because the statement is so obviously harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE&lt;/code&gt; needs &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, the lock that conflicts with every other lock including the &lt;code&gt;ACCESS SHARE&lt;/code&gt; a plain &lt;code&gt;SELECT&lt;/code&gt; takes. So if any transaction is holding &lt;em&gt;any&lt;/em&gt; lock on the table, the ALTER waits. A long report query. A transaction some worker opened, ran one select in, and then went to make an HTTP call. An interactive psql session someone forgot about at lunch.&lt;/p&gt;

&lt;p&gt;Now the important part. Postgres grants locks in order. Once the ALTER is in the queue, every new request that conflicts with &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; queues behind it, and everything conflicts with &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;. Every read. Every write. The table is not locked by the ALTER, which has not started yet. It is locked by the ALTER's &lt;em&gt;request&lt;/em&gt;, on behalf of a transaction that is doing nothing.&lt;/p&gt;

&lt;p&gt;We reproduced it on Postgres 16 with a 20 million row table. Session one opens a transaction, runs one cheap select, and idles. Session two runs the ALTER. Session three runs an ordinary point read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  pid  | state  | wait_type | wait_event |                    query
-------+--------+-----------+------------+---------------------------------------------
 10542 | active | Timeout   | PgSleep    | BEGIN; SELECT count(*) FROM orders WHERE id &amp;lt; 10
 10549 | active | Lock      | relation   | ALTER TABLE orders ADD COLUMN note text;
 10556 | active | Lock      | relation   | SELECT count(*) FROM orders WHERE id = 5;

  pid  |        mode         | granted |                  query
-------+---------------------+---------+------------------------------------------
 10542 | AccessShareLock     | t       | BEGIN; SELECT count(*) FROM orders WHERE
 10549 | AccessExclusiveLock | f       | ALTER TABLE orders ADD COLUMN note text;
 10556 | AccessShareLock     | f       | SELECT count(*) FROM orders WHERE id = 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point read at the bottom wants the same &lt;code&gt;AccessShareLock&lt;/code&gt; the idle session already holds, and those two do not conflict with each other at all. It is waiting anyway, because the ALTER is in front of it. In our run the idle transaction held on for thirty seconds, the ALTER finished after 28.1 s, and the point read, a query that takes under a millisecond, waited 26.1 s. Multiply by every connection in the pool and that is the outage: not a slow migration, a fast one, stuck behind something irrelevant, with the whole application stuck behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN note text;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same scenario, same idle transaction. The ALTER waited two seconds, gave up with &lt;code&gt;canceling statement due to lock timeout&lt;/code&gt;, and left the queue. The point read that had been queued behind it ran immediatly: 0.1 s including the round trip. Nothing was locked, nothing was changed, and the migration can be retried in a loop until it gets a gap, which on most systems is the very next attempt.&lt;/p&gt;

&lt;p&gt;Two details that matter. &lt;code&gt;lock_timeout&lt;/code&gt; is not &lt;code&gt;statement_timeout&lt;/code&gt;: the latter limits how long the statement may &lt;em&gt;run&lt;/em&gt;, which for an index build or a backfill is the wrong thing to cap, while the former only limits how long it may &lt;em&gt;wait for a lock&lt;/em&gt;, which is exactly the dangerous part. And set it per session or per migration, not globally; the application's own queries should not be cancelled for waiting on a lock, only the schema change should.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- a retry loop the migration runner can own
DO $$
BEGIN
  FOR i IN 1..10 LOOP
    BEGIN
      SET LOCAL lock_timeout = '2s';
      ALTER TABLE orders ADD COLUMN note text;
      RETURN;
    EXCEPTION WHEN lock_not_available THEN
      PERFORM pg_sleep(1);
    END;
  END LOOP;
  RAISE 'could not take the lock after 10 attempts';
END $$;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why almost nobody adds it
&lt;/h2&gt;

&lt;p&gt;Because the statement without it works. i have added this line to more migrations than i can count and it has never once been the wrong call, but every time, in development, in staging, and in production nine times out of ten, the version without it works too. The tenth time is the one with the idle transaction, and by then the migration has been copy-pasted from the previous nine. The guard is not a fix for a bug in the SQL. It is an admission that the SQL will run in an environment the author can not see, which is true of every migration and is the whole reason &lt;a href="https://bolvrk.com/rules/bv034" rel="noopener noreferrer"&gt;BV034&lt;/a&gt; exists: it fires on exclusive-lock DDL that has no &lt;code&gt;lock_timeout&lt;/code&gt; in scope, so the line gets added before the tenth time, not after.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/the-lock-timeout-guard" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>locks</category>
      <category>migrations</category>
      <category>incidents</category>
    </item>
    <item>
      <title>CREATE INDEX CONCURRENTLY is not free, and half the time you do not need it</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:41 +0000</pubDate>
      <link>https://dev.to/bolvrk/create-index-concurrently-is-not-free-and-half-the-time-you-do-not-need-it-45o4</link>
      <guid>https://dev.to/bolvrk/create-index-concurrently-is-not-free-and-half-the-time-you-do-not-need-it-45o4</guid>
      <description>&lt;p&gt;The standard advice is "always CREATE INDEX CONCURRENTLY in production". It is good advice and it is also a little lazy, because it skips over what the concurrent form costs and when you are paying that cost for nothing. So here are numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Postgres 16, one table, twenty million rows, 1.6 GB on disk. While each index builds, a second session inserts one row every fifty milliseconds and records how long the slowest insert had to wait. Laptop hardware, so the absolute times are small; the ratios are what matter.&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&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;customer_id&lt;/span&gt; &lt;span class="nb"&gt;int&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="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20000000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build time&lt;/th&gt;
&lt;th&gt;Worst insert wait&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.4 s&lt;/td&gt;
&lt;td&gt;4216 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7.3 s&lt;/td&gt;
&lt;td&gt;123 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the second column first. The plain build holds a &lt;code&gt;SHARE&lt;/code&gt; lock on the table for its whole duration. Reads go through, every write waits, and the slowest insert waited almost exactly as long as the build took, because it arrived just after the lock was taken and sat there until it was released. On a table where a build takes four seconds that is four seconds of every checkout, signup and webhook handler hanging. On a table where it takes four minutes it is an incident.&lt;/p&gt;

&lt;p&gt;The concurrent build never blocked a write for more than an eighth of a second. It took two thirds longer to finish, and that is the honest price: it scans the table twice, once to build the index and once to catch the rows that changed during the first scan, and it waits at the end for every transaction that was open when it started. On a busy server it can take several times longer than the plain form. It also can not run inside a transaction block, which is why migration frameworks make you opt out of their wrapper for it. &lt;a href="https://bolvrk.com/rules/bv003" rel="noopener noreferrer"&gt;BV003&lt;/a&gt; fires on the plain form for the reason in the first row.&lt;/p&gt;

&lt;h2&gt;
  
  
  What wrong and right look like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- wrong on a table that has writers: blocks every insert, update and delete for the whole build&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;idx_orders_customer&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- right: two scans, no blocked writes; must run outside a transaction block&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_orders_customer&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- and if the migration runner wraps every file in a transaction, tell it not to for this one:&lt;/span&gt;
&lt;span class="c1"&gt;-- Rails: disable_ddl_transaction!   Django: atomic = False   Flyway: -- flyway:executeInTransaction=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The concurrent build finishes, on a busy table, minutes later than the plain one would have. That is time the migration job spends waiting, not time the application spends blocked, and that is the trade you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode nobody mentions
&lt;/h2&gt;

&lt;p&gt;If a concurrent build fails part way, because of a deadlock, a unique violation, or a cancelled statement, it leaves behind an &lt;em&gt;invalid&lt;/em&gt; index. The index exists, it is maintained on every write, it costs the same as a real one, and the planner never uses it. Nothing tells you. The next migration that tries to create the same name fails with "already exists", and the usual response is to drop it and retry, which is right, but the invalid index can sit there for weeks first, slowing every insert on the table for no benefit.&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;indisvalid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- should return nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that query in the runbook next to every concurrent build, and drop with &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt; when it finds something, for the same reason: a plain &lt;code&gt;DROP INDEX&lt;/code&gt; takes the exclusive lock, &lt;a href="https://bolvrk.com/rules/bv016" rel="noopener noreferrer"&gt;BV016&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the plain form is fine
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new table.&lt;/strong&gt; Zero rows, no writers yet, nothing to block. The plain form is faster and it works inside the migration transaction. Creating the index in the same statement as the table is the cleanest version of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tiny table.&lt;/strong&gt; A few thousand rows builds in milliseconds either way; the concurrent form's second scan and end-of-build wait cost more than the lock does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A maintenance window with writes actually stopped.&lt;/strong&gt; If nobody can write, the lock is free, and the plain form is simpler and can not leave an invalid index behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inside a rebuild.&lt;/strong&gt; When you are populating a new table and swapping it in, build the indexes on the new table after the bulk copy and before the swap, plain. Nothing is reading it yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision is not about the statement. It is about whether anything is writing to the table while it runs. A checker can tell you the statement is the plain form on an existing table, and it should, every time, so that the times you choose it are choices rather then accidents.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/create-index-concurrently-is-not-free" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>indexes</category>
      <category>locks</category>
      <category>data</category>
    </item>
    <item>
      <title>Adding a foreign key to a big table without locking both of them</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:05 +0000</pubDate>
      <link>https://dev.to/bolvrk/adding-a-foreign-key-to-a-big-table-without-locking-both-of-them-2pf8</link>
      <guid>https://dev.to/bolvrk/adding-a-foreign-key-to-a-big-table-without-locking-both-of-them-2pf8</guid>
      <description>&lt;p&gt;Foreign keys are the constraint people add late. The table already has a few million rows in it, someone notices that &lt;code&gt;order_items.order_id&lt;/code&gt; can point at nothing, and the fix is one line.&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;order_items&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;order_items_order_id_fkey&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;order_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;orders&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This statement does two things. It records the constraint, which is instant, and it validates it, which means scanning every row of &lt;code&gt;order_items&lt;/code&gt; and looking each &lt;code&gt;order_id&lt;/code&gt; up in &lt;code&gt;orders&lt;/code&gt;. While it does that it holds a &lt;code&gt;SHARE ROW EXCLUSIVE&lt;/code&gt; lock on &lt;em&gt;both&lt;/em&gt; tables. Reads continue. Every write to either table waits. On a busy orders table a validation that takes ninety seconds is ninety seconds where nobody can place an order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split the two things
&lt;/h2&gt;

&lt;p&gt;Postgres lets you separate recording the constraint from validating it.&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;-- 1. record it; new and updated rows are checked from now on, existing rows are not yet&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;order_items&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;order_items_order_id_fkey&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;order_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;orders&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. validate the existing rows, under a lock that lets writes through&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;order_items_order_id_fkey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step one still takes a brief lock on both tables, but it holds it for milliseconds because there is nothing to scan. Step two does the scan under &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;, which blocks other schema changes and not much else: inserts, updates and deletes all proceed. The end state is identical to the one-line version. The only difference is who waited. &lt;a href="https://bolvrk.com/rules/bv008" rel="noopener noreferrer"&gt;BV008&lt;/a&gt; is the rule for the one-line version.&lt;/p&gt;

&lt;p&gt;If validation finds a violating row, it fails, and the constraint stays in place as &lt;code&gt;NOT VALID&lt;/code&gt;. That is a feature: new writes are already protected, and you can fix the bad rows at leisure and validate again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index nobody adds
&lt;/h2&gt;

&lt;p&gt;Postgres creates an index for a primary key and for a unique constraint. It does not create one for the referencing side of a foreign key. So after the statements above, &lt;code&gt;orders.id&lt;/code&gt; is indexed and &lt;code&gt;order_items.order_id&lt;/code&gt; is not, and every &lt;code&gt;DELETE FROM orders&lt;/code&gt; or update to &lt;code&gt;orders.id&lt;/code&gt; has to scan &lt;code&gt;order_items&lt;/code&gt; to check that nothing still points at the row. On a large child table a single parent delete becomes a sequential scan, and a batch of them becomes a lock queue.&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;order_items_order_id_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://bolvrk.com/rules/bv017" rel="noopener noreferrer"&gt;BV017&lt;/a&gt; flags a foreign key whose referencing columns have no index. It is one of the few rules where the fix is a statement you add rather than a statement you change, and it is also one where the checker benefits from a live schema: with a read-only connection it can see whether the index already exists rather than guessing from the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trick, twice more
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;NOT VALID&lt;/code&gt; then &lt;code&gt;VALIDATE&lt;/code&gt; split is not special to foreign keys. A &lt;code&gt;CHECK&lt;/code&gt; constraint takes it in exactly the same form. And a &lt;code&gt;NOT NULL&lt;/code&gt; constraint, which has no NOT VALID form of its own, can borrow it: add &lt;code&gt;CHECK (col IS NOT NULL) NOT VALID&lt;/code&gt;, validate that, and from Postgres 12 onward &lt;code&gt;SET NOT NULL&lt;/code&gt; will see the validated check and skip its own full-table scan under &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;. That scan is &lt;a href="https://bolvrk.com/rules/bv011" rel="noopener noreferrer"&gt;BV011&lt;/a&gt;, and it is the reason the three statements are worth the extra lines.&lt;/p&gt;

&lt;p&gt;The principle underneath all three: any constraint whose validation needs a scan should be recorded first and validated seperately, so the scan runs under the weakest lock that is correct. Postgres gives you the syntax. It just does not make it the default.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/foreign-keys-without-not-valid" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>migrations</category>
      <category>foreignkeys</category>
      <category>locks</category>
    </item>
    <item>
      <title>Why SQLite refuses your ALTER TABLE, and the twelve-step rebuild it wants instead</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:04 +0000</pubDate>
      <link>https://dev.to/bolvrk/why-sqlite-refuses-your-alter-table-and-the-twelve-step-rebuild-it-wants-instead-31eh</link>
      <guid>https://dev.to/bolvrk/why-sqlite-refuses-your-alter-table-and-the-twelve-step-rebuild-it-wants-instead-31eh</guid>
      <description>&lt;p&gt;Coming from Postgres, SQLite's &lt;code&gt;ALTER TABLE&lt;/code&gt; feels broken. It is not. It is small on purpose, and the rules for what it refuses are consistent once you know them. The trouble is that the refusals happen at run time, and a migration that fails at run time fails in production too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ALTER TABLE can do
&lt;/h2&gt;

&lt;p&gt;Rename a table. Rename a column. Add a column, with restrictions. Drop a column, since 3.35, with more restrictions. That is the complete list. There is no &lt;code&gt;ALTER COLUMN&lt;/code&gt;, no changing a type, no adding or dropping a constraint, no adding a primary key to an existing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The add-column restrictions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ADD COLUMN&lt;/code&gt; is fast in SQLite because it does not touch existing rows: the new column is appended to the schema and existing rows read as the default. That is exactly why the restrictions exist. Anything that would require a value per existing row that SQLite can not derive from a constant is refused.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; needs a non-null default. Without one the statement fails with &lt;code&gt;Cannot add a NOT NULL column with default value NULL&lt;/code&gt;. On every table, including an empty one, so unlike the Postgres version this fails in development too. That is &lt;a href="https://bolvrk.com/rules/sl001" rel="noopener noreferrer"&gt;SL001&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PRIMARY KEY&lt;/code&gt; and &lt;code&gt;UNIQUE&lt;/code&gt; are refused outright. A uniqueness constraint would need to be checked across all rows, and existing rows would all have the same default. &lt;a href="https://bolvrk.com/rules/sl002" rel="noopener noreferrer"&gt;SL002&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A default must be a constant. &lt;code&gt;DEFAULT CURRENT_TIMESTAMP&lt;/code&gt; is refused, because the stored-default trick only works for a value that is the same for every row.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;STORED&lt;/code&gt; generated column can not be added; only &lt;code&gt;VIRTUAL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The add-column fixes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- wrong: fails at run time, everywhere&lt;/span&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;tier&lt;/span&gt; &lt;span class="nb"&gt;TEXT&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;-- right: a constant default satisfies every existing row without touching it&lt;/span&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;tier&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- wrong: refused outright&lt;/span&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;public_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- right: add the column, then enforce uniqueness with an index&lt;/span&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;public_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_public_id&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;public_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- wrong: a non-constant default is refused&lt;/span&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;created_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- right: nullable now, populate on insert from the application, or rebuild the table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why DROP COLUMN rewrites the file
&lt;/h2&gt;

&lt;p&gt;SQLite stores each row as a record with the columns in order. Dropping a column means rewriting every record without it, so &lt;code&gt;DROP COLUMN&lt;/code&gt; is a full table rewrite, holding the write lock for the duration. On a phone that is fine. On a server with a multi-gigabyte database file behind a web app it is an outage that looks like a one-line migration. &lt;a href="https://bolvrk.com/rules/sl006" rel="noopener noreferrer"&gt;SL006&lt;/a&gt; flags it for that reason. And DROP COLUMN is refused entirely when the column is indexed, part of a constraint, or referenced by a view or trigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twelve-step rebuild
&lt;/h2&gt;

&lt;p&gt;For everything ALTER TABLE will not do, the SQLite documentation prescribes one procedure: create the table you wanted, copy the rows into it, drop the old one, rename. Written out properly it is twelve steps, and the order matters, because most of the steps exist to keep foreign keys, indexes, triggers and views from breaking half way through.&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="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- 1. outside the transaction&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;-- 2&lt;/span&gt;
&lt;span class="c1"&gt;-- 3. remember indexes, triggers and views on the table&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users_new&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;       &lt;span class="c1"&gt;-- 4. the schema you actually want&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;users_new&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;    &lt;span class="c1"&gt;-- 5. copy the rows&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                   &lt;span class="c1"&gt;-- 6&lt;/span&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_new&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- 7&lt;/span&gt;
&lt;span class="c1"&gt;-- 8. recreate indexes and triggers&lt;/span&gt;
&lt;span class="c1"&gt;-- 9. recreate views&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_key_check&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;-- 10. must return no rows&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                             &lt;span class="c1"&gt;-- 11&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;-- 12. outside the transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The steps people skip are the ones that hurt. Dropping the old table before copying loses the data, &lt;a href="https://bolvrk.com/rules/sl019" rel="noopener noreferrer"&gt;SL019&lt;/a&gt;. Leaving foreign keys enforced during the rebuild makes step six fail or, worse, cascade. Forgetting the &lt;code&gt;foreign_key_check&lt;/code&gt; commits a database with dangling references. And a &lt;code&gt;PRAGMA foreign_keys&lt;/code&gt; inside the transaction is silently a no-op, so steps one and twelve have to be outside it, which is the kind of detail that is easy to get wrong and impossible to see in a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it
&lt;/h2&gt;

&lt;p&gt;The SQLite corpus is thirty-five rules and every "SQLite refuses this" claim in it is proven against a real SQLite in the test suite. It runs locally in the free CLI with &lt;code&gt;--engine=sqlite&lt;/code&gt;, and it is static only for now: no live schema, no hosted checks. For a migration that is going to fail at run time in every enviroment, static is enough to save the deploy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/sqlite-refuses-your-alter-table" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>migrations</category>
    </item>
    <item>
      <title>What 40 agent-written migrations got wrong</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:30:03 +0000</pubDate>
      <link>https://dev.to/bolvrk/what-40-agent-written-migrations-got-wrong-4go5</link>
      <guid>https://dev.to/bolvrk/what-40-agent-written-migrations-got-wrong-4go5</guid>
      <description>&lt;p&gt;A small experiment. We gave a current coding agent the role of "helpful assistant on a Postgres web app" and asked it, in forty separate requests, for the migrations a team writes in a normal quarter: add a column, add an index, add a foreign key, change a type, rename, drop, backfill, truncate, vacuum. No hints about safety, no linter in the loop, first draft only. Then we ran the eighteen rules the free CLI bundles over the forty files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clean&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flagged, warning&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flagged, critical&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flagged, note only&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv003" rel="noopener noreferrer"&gt;BV003&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Index created without CONCURRENTLY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv004" rel="noopener noreferrer"&gt;BV004&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Column type change that rewrites the table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv005" rel="noopener noreferrer"&gt;BV005&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Rename or drop inside the deploy window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv011" rel="noopener noreferrer"&gt;BV011&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;SET NOT NULL scanning the table under an exclusive lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv008" rel="noopener noreferrer"&gt;BV008&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Foreign key added without NOT VALID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv013" rel="noopener noreferrer"&gt;BV013&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;TRUNCATE in a migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv015" rel="noopener noreferrer"&gt;BV015&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;VACUUM FULL under a full lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv014" rel="noopener noreferrer"&gt;BV014&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Unbounded UPDATE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bolvrk.com/rules/bv016" rel="noopener noreferrer"&gt;BV016&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;DROP INDEX without CONCURRENTLY&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What it got right
&lt;/h2&gt;

&lt;p&gt;This is the part worth reading before the part about what it got wrong. Every time the agent was asked for a required column, it added a default: &lt;code&gt;ADD COLUMN status text NOT NULL DEFAULT&lt;br&gt;
  'open'&lt;/code&gt;, &lt;code&gt;ADD COLUMN email_verified boolean NOT NULL DEFAULT false&lt;/code&gt;. That is the correct modern form, it is instant on Postgres 11 and later, and &lt;a href="https://bolvrk.com/rules/bv002" rel="noopener noreferrer"&gt;BV002&lt;/a&gt; never fired once. The single most famous migration mistake did not appear. Models have read a lot of blog posts about that one.&lt;/p&gt;

&lt;p&gt;It also wrote a correct &lt;code&gt;USING&lt;/code&gt; clause on every type change, used &lt;code&gt;string_to_array&lt;/code&gt; sensibly for the tags split, and wrote a clean &lt;code&gt;CREATE TABLE&lt;/code&gt; with a foreign key when asked for a new table. The SQL was never malformed. Not one of the forty files had a syntax error.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it got wrong, and why it is the same mistake ten times
&lt;/h2&gt;

&lt;p&gt;Ten of the forty files create an index, and all ten do it the plain way. Not one &lt;code&gt;CONCURRENTLY&lt;/code&gt;. On a dev database the plain form is faster and simpler, and the agent has no reason to prefer the form that only matters when the table has fifty million rows and writes going to it. This is the shape of most of the flagged files: the agent picks the form that is &lt;em&gt;locally&lt;/em&gt; best, and the form that is locally best is the one that blocks production.&lt;/p&gt;

&lt;p&gt;The four &lt;code&gt;SET NOT NULL&lt;/code&gt; statements are the same story. The agent even did the right thing first, backfilling with an UPDATE before setting the constraint, and then set the constraint the way that scans the whole table under an exclusive lock, because that is the short way to write it. The two foreign keys were added without &lt;code&gt;NOT VALID&lt;/code&gt;, so they validate every existing row while holding locks on both tables. The pattern is consistent enough that you could predict which files would be flagged from the request alone.&lt;/p&gt;

&lt;p&gt;The two criticals were a &lt;code&gt;TRUNCATE&lt;/code&gt; and a &lt;code&gt;VACUUM FULL&lt;/code&gt;, both of which were literally what was asked for. That is a fair point in the agent's defence and it is also the point: the request was reckless, the agent did not push back, and the only thing standing between the request and production was whether anyone was checking.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fixes, side by side
&lt;/h2&gt;

&lt;p&gt;Every flagged file has a one or two line correction. The four that account for twenty of the twenty-seven:&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;-- BV003: the agent wrote            -&amp;gt;  the fix&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;idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&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&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- BV011: backfilled, then&lt;/span&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;email&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;-- fix: prove it under a weak lock first, then the SET NOT NULL is a catalog change (PG12+)&lt;/span&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;users_email_nn&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;IS&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&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="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_email_nn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&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;email&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="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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_email_nn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- BV004: a type change rewrites the table under ACCESS EXCLUSIVE&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;total&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;-- fix: new column, batched copy, swap, drop (expand / migrate / contract)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;total_new&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;total_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;total_new&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- repeat&lt;/span&gt;
&lt;span class="c1"&gt;-- ... switch the application, then rename and drop in a later release&lt;/span&gt;

&lt;span class="c1"&gt;-- BV008: validates every row while locking both tables&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payments&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;payments_order_fk&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;order_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;orders&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;-- fix: record now, validate under a lock that lets writes through&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payments&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;payments_order_fk&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;order_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;orders&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;payments_order_fk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The honest caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One agent, one run, forty prompts i wrote. A different model or a different day would move the numbers. We do not think it would change the shape.&lt;/li&gt;
&lt;li&gt;The free rule set is the outage core, eighteen rules. The full corpus would have flagged more, and some warnings here are advisory by design: a rename is only dangerous during a deploy window, and the rule says so.&lt;/li&gt;
&lt;li&gt;We asked for first drafts. An agent that is told to review its own migration, or that has a checker as a tool, does better. That is the whole argument for giving it one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thirteen of forty clean is not a verdict on the agent. It is a measurment of what "looks right" means when the thing that makes a migration wrong is a table the author will never see. The fix is not a better prompt. It is the same check, run the same way, on every file, before it merges.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/what-40-agent-written-migrations-got-wrong" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiworkflows</category>
      <category>postgres</category>
      <category>migrations</category>
      <category>data</category>
    </item>
    <item>
      <title>How a database password ends up in git history, and why rotating it is not enough</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:29:28 +0000</pubDate>
      <link>https://dev.to/bolvrk/how-a-database-password-ends-up-in-git-history-and-why-rotating-it-is-not-enough-f61</link>
      <guid>https://dev.to/bolvrk/how-a-database-password-ends-up-in-git-history-and-why-rotating-it-is-not-enough-f61</guid>
      <description>&lt;p&gt;Nobody sets out to commit a password. It happens because a migration file is, from the point of view of the person writing it, just SQL, and SQL sometimes needs to know things that are secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways in
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A settings row.&lt;/strong&gt; The application reads its configuration from a table, the migration seeds the table, and one of the values is a connection string to another service. &lt;code&gt;INSERT INTO settings VALUES ('warehouse_dsn', 'postgres://etl:Sup3rSecret@wh.internal/dw')&lt;/code&gt;. This is the one that spreads furthest, because every service that loads settings now has the credential too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A role.&lt;/strong&gt; The migration creates a database user for a new service and gives it a password, in plaintext, in the file. &lt;code&gt;CREATE ROLE reporting LOGIN PASSWORD 'hunter2'&lt;/code&gt;. Postgres would have accepted a SCRAM verifier in the same position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A vendor key.&lt;/strong&gt; An API key seeded into a table, or set as a column default, so the app "just works" after deploy. Stripe, AWS, GitHub and Slack all give their keys a recognisable prefix precisely so that this can be caught.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A helpful agent.&lt;/strong&gt; Ask a coding agent to make a script self-contained, or to make a migration runnable without setup, and it will do the obvious thing: read the value out of &lt;code&gt;.env&lt;/code&gt; and write it into the file. It is not being careless. It is doing exactly what was asked, and the request did not say "except the secrets" because nobody thought it needed to.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What happens next
&lt;/h2&gt;

&lt;p&gt;The commit lands. The pull request diff shows the value to everyone with read access to the repository, and to every integration that reads pull requests. CI prints the file, so the value is in build logs. If &lt;code&gt;log_statement&lt;/code&gt; covers DDL, the database server writes the statement to its own log. And git history is permanent: reverting the commit adds a second commit and leaves the first one exactly where it was, readable by anyone who clones the repository, for as long as the repository exists.&lt;/p&gt;

&lt;p&gt;This is why rotating the credential is necessary but not sufficient. Rotation closes the door for the value that leaked. It does nothing about the fact that the file, the process and the reviewer all let a plaintext secret through, and the same path is open for the next one. Rewriting history to remove the value is possible and painful, and every fork and every developer's local clone still has it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to do each one right
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- wrong: a live credential in a committed file&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="p"&gt;(&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;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'warehouse_dsn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postgres://etl:Sup3rSecret@wh.internal/dw'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- right: seed the key with a placeholder; the value comes from the environment at runtime&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="p"&gt;(&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;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'warehouse_dsn'&lt;/span&gt;&lt;span class="p"&gt;,&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;-- wrong: the password is now in git history and in the server log&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'hunter2'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- right: commit the SCRAM verifier, which is useless without the password, or set the password&lt;/span&gt;
&lt;span class="c1"&gt;-- out of band after the role exists&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'SCRAM-SHA-256$4096:...$...:...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- wrong: a vendor key as a default&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;integrations&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;api_key&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'sk_live_51H...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- right: no default; the application injects the key from its secret store per environment&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;integrations&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;api_key&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is the same in all three cases: a migration may create the &lt;em&gt;place&lt;/em&gt; a secret lives, and it must never contain the secret. The value belongs in the environment, the secret manager, or a one-off command a person runs, none of which get committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the reviewer does not catch it
&lt;/h2&gt;

&lt;p&gt;Because it looks like data. A DSN in an INSERT is a string like any other string in the file, and a reviewer reading a 200-line migration for logic errors is not pattern-matching on &lt;code&gt;sk_live_&lt;/code&gt;. Secret scanning at the platform level helps, but it runs after the push, which is after the leak. The check has to happen on the diff, before the commit, and it has to be a program, because a person's attention is the thing that was already exhausted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rules look for
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bolvrk.com/rules/bc003" rel="noopener noreferrer"&gt;BC003&lt;/a&gt; fires when a string literal parses as a libpq connection string with a real password in it, and stays quiet on placeholders and empty passwords. &lt;a href="https://bolvrk.com/rules/bc004" rel="noopener noreferrer"&gt;BC004&lt;/a&gt; matches the published key formats, not entropy, so a uuid or a bcrypt hash does not trip it. &lt;a href="https://bolvrk.com/rules/bc001" rel="noopener noreferrer"&gt;BC001&lt;/a&gt; catches &lt;code&gt;CREATE ROLE ... PASSWORD&lt;/code&gt; with a plaintext value and is silent on a SCRAM verifier. All three run in &lt;code&gt;npx bolvrk check&lt;/code&gt; on migration files, and the first two also run on any file or on piped output with &lt;code&gt;bolvrk secrets&lt;/code&gt;, which is the one to put in front of an agent's output before it is commited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; | npx bolvrk secrets -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of scanning the diff rather than the repository is that the diff is the moment the secret is still only on one machine. Everything after that is cleanup.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/secrets-in-migration-files" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>credentials</category>
      <category>security</category>
      <category>migrations</category>
      <category>aiworkflows</category>
    </item>
    <item>
      <title>Expand, migrate, contract: dropping a column without breaking the deploy that is still running</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:29:27 +0000</pubDate>
      <link>https://dev.to/bolvrk/expand-migrate-contract-dropping-a-column-without-breaking-the-deploy-that-is-still-running-966</link>
      <guid>https://dev.to/bolvrk/expand-migrate-contract-dropping-a-column-without-breaking-the-deploy-that-is-still-running-966</guid>
      <description>&lt;p&gt;Here is a migration that is completely correct and takes production down anyway.&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;legacy_ref&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column is unused. The code that read it was deleted in the same pull request. The tests pass. And for the thirty to ninety seconds between the migration running and the last old pod being replaced, the previous version of the application is still running, still has &lt;code&gt;legacy_ref&lt;/code&gt; in its model, and every query it makes that mentions the column fails with &lt;code&gt;column "legacy_ref" does not exist&lt;/code&gt;. If the ORM selects every column by name, which most do, that is every query on the table.&lt;/p&gt;

&lt;p&gt;The migration was not wrong. The &lt;em&gt;timing&lt;/em&gt; was. The schema moved before the code did, and there was a window where they disagreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deploy window
&lt;/h2&gt;

&lt;p&gt;Every rolling deploy has one. Migrations usually run first, as a job before the new version starts, and the old version keeps serving until the rollout completes. During that window the database is at version N+1 and some of the application is still at version N. Anything the old code needs that the new schema removed is a live error for as long as the window lasts, and rollbacks make it worse: rolling back the code without rolling back the schema puts the whole fleet on version N against a schema it does not understand.&lt;/p&gt;

&lt;p&gt;Adding things is safe across the window, because old code ignores columns it does not know about. Removing or renaming things is not, because old code can not ignore a column it expects. That asymmetry is the whole pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand, migrate, contract
&lt;/h2&gt;

&lt;p&gt;The fix is to never remove anything in the same release that stops using it. Three phases, at least two releases.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expand.&lt;/strong&gt; Add the new column, table or constraint. Old code ignores it, new code can start writing to it. If it replaces something, write to both for now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate.&lt;/strong&gt; Backfill the new thing from the old, in batches. Switch reads to the new thing once the backfill is complete. Ship this and let it soak: every pod is now on code that no longer needs the old column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract.&lt;/strong&gt; In a later release, drop the old column. Nothing running reads it anymore, so the window is harmless.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A rename is the same problem wearing a different hat. &lt;code&gt;RENAME COLUMN name TO full_name&lt;/code&gt; removes &lt;code&gt;name&lt;/code&gt; from the point of view of old code, instantly. The safe version is add &lt;code&gt;full_name&lt;/code&gt;, copy, switch, drop &lt;code&gt;name&lt;/code&gt; later. It takes two releases instead of one statement, and it is the only version that does not depend on the deploy being fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same change, both ways
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- wrong: one release, and old pods fail on every query that names the column&lt;/span&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;name&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- right, release 1 (expand): add the new column; nothing reads it yet&lt;/span&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;full_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- release 1 (migrate): backfill in batches, then deploy code that writes both and reads full_name&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;full_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;full_name&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- release 2 (contract): only once no running version reads name&lt;/span&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;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drop in the first paragraph of this post follows the same shape: it is a contract step, and the question is only whether the migrate step, the deploy that stopped reading &lt;code&gt;legacy_ref&lt;/code&gt;, has fully rolled out. If it has, the drop is safe. If you are not sure, it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "later" means
&lt;/h2&gt;

&lt;p&gt;Later means after you are sure no version of the code that references the old column can be running or rolled back to. For most teams that is the next release, or the one after. The contract step is also the one that gets forgotten, wich is how tables grow a graveyard of columns named &lt;code&gt;legacy_&lt;/code&gt; something. i put it in the same ticket as the expand step, with a date on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a checker can and cannot know here
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bolvrk.com/rules/bv030" rel="noopener noreferrer"&gt;BV030&lt;/a&gt; fires on a &lt;code&gt;DROP COLUMN&lt;/code&gt; and &lt;a href="https://bolvrk.com/rules/bv005" rel="noopener noreferrer"&gt;BV005&lt;/a&gt; on a rename or drop, and both are warnings rather than criticals for an honest reason: the statement itself is fine. What makes it dangerous is whether code that still reads the column can be running when it executes, and that is a property of your deploy, not of the SQL. A deterministic rule can tell you "this is a contract step, make sure the migrate step already shipped". It can not tell you whether it did. That is the review question the rule is there to force, and it is a much better use of a reviewer's attention than checking syntax.&lt;/p&gt;

&lt;p&gt;Where live-schema context helps is the other direction: with a read-only connection the checker can see that the column you are about to drop is still the target of an index, a view or a foreign key, which is the class of contract-step mistake that shows up as an error rather than a window.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/expand-migrate-contract" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>migrations</category>
      <category>deploys</category>
      <category>zerodowntime</category>
    </item>
    <item>
      <title>Why ADD COLUMN NOT NULL fails on a full table, and the migration that does not</title>
      <dc:creator>Bolvrk.com</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:29:26 +0000</pubDate>
      <link>https://dev.to/bolvrk/why-add-column-not-null-fails-on-a-full-table-and-the-migration-that-does-not-17gp</link>
      <guid>https://dev.to/bolvrk/why-add-column-not-null-fails-on-a-full-table-and-the-migration-that-does-not-17gp</guid>
      <description>&lt;p&gt;This one works on your laptop and fails in production, which is the worst kind of migration there is.&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;orders&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;status&lt;/span&gt; &lt;span class="nb"&gt;text&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;-- ERROR:  column "status" of relation "orders" contains null values&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a dev database the table is empty, so there are no rows to violate the constraint and the statement succeeds. On the real table every existing row would need a value for the new column, none is given, and Postgres refuses. The migration stops half way through the deploy, the framework marks it as failed, and someone gets paged to work out whether the earlier statements in the same file were applied or rolled back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server is actually doing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ADD COLUMN&lt;/code&gt; takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock on the table. That is the strongest lock Postgres has: it blocks every read and every write until the statement finishes. For a plain nullable column that is fine, because adding a nullable column with no default is a catalog change. No row is touched, the lock is held for a few milliseconds, and nobody notices.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NOT NULL&lt;/code&gt; changes that. A NOT NULL column has to hold a value in every row, so Postgres has to know what the value is. With a default it can use the default. Without one there is nothing to use, and the only honest answer is the error above. The check is not a scan in the usual sense, it is Postgres declining to invent data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in Postgres 11
&lt;/h2&gt;

&lt;p&gt;Before Postgres 11, adding a column &lt;em&gt;with&lt;/em&gt; a default rewrote the whole table to write the default into every row, under that same exclusive lock. On a large table that was minutes of downtime, so the folk wisdom became "never add a column with a default". Postgres 11 fixed it: a constant default is now stored in the catalog and returned on read for rows that predate the column, so &lt;code&gt;ADD COLUMN status text NOT NULL DEFAULT 'open'&lt;/code&gt; is instant on any table size.&lt;/p&gt;

&lt;p&gt;The word that matters is &lt;em&gt;constant&lt;/em&gt;. &lt;code&gt;DEFAULT now()&lt;/code&gt; or &lt;code&gt;DEFAULT gen_random_uuid()&lt;/code&gt; is volatile, every row needs its own value, and Postgres still rewrites the table. Same syntax, completely different cost, and the only way to tell them apart is to know the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that works
&lt;/h2&gt;

&lt;p&gt;When there is a sensible constant default, use it and you are done, on any modern Postgres. When there is not, which is the normal case for a column that must be backfilled from other data, do it in three steps.&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;-- 1. add the column nullable; a catalog change, milliseconds&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. backfill in batches, outside the migration transaction&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ... repeat by id range until no rows remain&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. add the constraint without a full-table scan under lock&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;orders_status_not_null&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IS&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_status_not_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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;status&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_status_not_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step three looks like ceremony, and it is there for a reason. A bare &lt;code&gt;SET NOT NULL&lt;/code&gt; scans the whole table to prove no null exists, and it does that scan while holding &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;. On a big table that is the outage everyone was trying to avoid in step one. A &lt;code&gt;CHECK ... NOT VALID&lt;/code&gt; constraint is added instantly, &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; does its scan under a much weaker lock that lets reads and writes continue, and from Postgres 12 onward &lt;code&gt;SET NOT NULL&lt;/code&gt; notices the validated check constraint and skips its own scan. Then the helper constraint can go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two rules that catch it
&lt;/h2&gt;

&lt;p&gt;The first statement in this post is &lt;a href="https://bolvrk.com/rules/bv002" rel="noopener noreferrer"&gt;BV002&lt;/a&gt;, and the bare &lt;code&gt;SET NOT NULL&lt;/code&gt; is &lt;a href="https://bolvrk.com/rules/bv011" rel="noopener noreferrer"&gt;BV011&lt;/a&gt;. Both are in the free CLI, which is worth knowing because both are exactly the kind of mistake that looks fine in review. The SQL is syntactically perfect. It is the table it will run against that makes it wrong, and a reviewer can not see the table from the diff.&lt;/p&gt;

&lt;p&gt;If you use SQLite instead, the rule is stricter and the failure is earlier: SQLite refuses a NOT NULL column without a non-null default outright, in every enviroment including the empty one. That is &lt;a href="https://bolvrk.com/rules/sl001" rel="noopener noreferrer"&gt;SL001&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bolvrk.com/blog/add-column-not-null-without-default" rel="noopener noreferrer"&gt;bolvrk.com&lt;/a&gt;. Bolvrk is a deterministic checker for database migrations and committed credentials: &lt;a href="https://bolvrk.com/?utm_source=crosspost&amp;amp;utm_medium=blog" rel="noopener noreferrer"&gt;free CLI, GitHub App and Action&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>migrations</category>
      <category>notnull</category>
    </item>
  </channel>
</rss>
