<?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: Sayonil Mitra</title>
    <description>The latest articles on DEV Community by Sayonil Mitra (@sayonilmitra).</description>
    <link>https://dev.to/sayonilmitra</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%2F3960044%2Fb5cd9573-e7bf-4b3f-9208-0ee11220b88a.png</url>
      <title>DEV Community: Sayonil Mitra</title>
      <link>https://dev.to/sayonilmitra</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sayonilmitra"/>
    <language>en</language>
    <item>
      <title>The 4 kinds of ALTER TABLE that cause production incidents (and a 5-minute review for each)</title>
      <dc:creator>Sayonil Mitra</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:55:45 +0000</pubDate>
      <link>https://dev.to/sayonilmitra/the-4-kinds-of-alter-table-that-cause-production-incidents-and-a-5-minute-review-for-each-5gog</link>
      <guid>https://dev.to/sayonilmitra/the-4-kinds-of-alter-table-that-cause-production-incidents-and-a-5-minute-review-for-each-5gog</guid>
      <description>&lt;p&gt;A migration that runs fine on your laptop's empty database can still fail, or do real damage, in production. Production has data, traffic, and application code that depends on the old schema.&lt;/p&gt;

&lt;p&gt;You don't need a heavyweight process to catch most of it. You need to look at each statement and ask which of four categories it falls into.&lt;/p&gt;

&lt;p&gt;Here's a migration that looks routine:&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;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;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;RENAME&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;cust_id&lt;/span&gt; &lt;span class="k"&gt;TO&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;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;region&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those four lines belongs to a different category.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Destructive and irreversible
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DROP COLUMN&lt;/code&gt; permanently deletes the data in that column. &lt;code&gt;DROP TABLE&lt;/code&gt; deletes the whole table. There's no undo inside the migration; recovery means restoring from a backup.&lt;/p&gt;

&lt;p&gt;Before running one: confirm the data is genuinely unused, and confirm a recent backup exists. Dropping a primary key belongs here too, because ORMs, replication and cascading deletes often rely on one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Statements that fail on existing data
&lt;/h2&gt;

&lt;p&gt;These pass on an empty table and fail in production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ADD COLUMN ... NOT NULL&lt;/code&gt; with no default&lt;/strong&gt; fails if the table already has rows, because existing rows would have no value for it. &lt;code&gt;region TEXT NOT NULL&lt;/code&gt; above is exactly this case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SET NOT NULL&lt;/code&gt;&lt;/strong&gt; on an existing column fails if any row holds &lt;code&gt;NULL&lt;/code&gt;. The docs say it's checked by scanning the entire table, unless a valid &lt;code&gt;CHECK&lt;/code&gt; constraint already proves no &lt;code&gt;NULL&lt;/code&gt; can exist, in which case the scan is skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding a foreign key&lt;/strong&gt; fails if orphaned child rows exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing a column type&lt;/strong&gt; can fail, or lose precision, if existing values don't fit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The good news on defaults: since PostgreSQL 11, adding a column with a non-volatile &lt;code&gt;DEFAULT&lt;/code&gt; doesn't rewrite the table. The default is stored in metadata. So this is safe and fast:&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;region&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;'unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;em&gt;volatile&lt;/em&gt; default (the docs use &lt;code&gt;clock_timestamp()&lt;/code&gt; as the example) still forces a full rewrite.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;SET NOT NULL&lt;/code&gt; or a new constraint on a big table, the two-step pattern avoids doing the long scan under the heavy lock:&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;region_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;region&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="c1"&gt;-- no table scan&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;region_not_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- scans, but lighter lock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NOT VALID&lt;/code&gt; means the &lt;code&gt;ADD CONSTRAINT&lt;/code&gt; doesn't scan the table and commits right away. &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; takes only a &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; lock, which doesn't block normal reads and writes. Once the constraint is valid, a later &lt;code&gt;SET NOT NULL&lt;/code&gt; can skip its own scan (PostgreSQL 12 and later). The &lt;code&gt;CHECK&lt;/code&gt; constraint has to still exist when &lt;code&gt;SET NOT NULL&lt;/code&gt; runs, so drop it in a separate statement afterwards, not in the same command.&lt;/p&gt;

&lt;p&gt;On PostgreSQL 18 and later you can skip that workaround. The 18.0 release notes list "Allow &lt;code&gt;ALTER TABLE&lt;/code&gt; to set the &lt;code&gt;NOT VALID&lt;/code&gt; attribute of &lt;code&gt;NOT NULL&lt;/code&gt; constraints", so you can add the &lt;code&gt;NOT NULL&lt;/code&gt; constraint itself as &lt;code&gt;NOT VALID&lt;/code&gt; and validate it later. Check the exact syntax in the PostgreSQL 18 docs for your minor version before relying on it.&lt;/p&gt;

&lt;p&gt;Everything above is available in every currently supported release. The oldest, PostgreSQL 14, reaches end of life on 12 November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Statements that silently break application code
&lt;/h2&gt;

&lt;p&gt;These succeed in the database and fail later, in code the migration never touched.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Renaming a column or table.&lt;/strong&gt; &lt;code&gt;cust_id&lt;/code&gt; → &lt;code&gt;customer_id&lt;/code&gt; breaks every query, ORM model, view and report still using the old name, including the &lt;em&gt;previous&lt;/em&gt; version of your app that's still running during a rolling deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropping an index.&lt;/strong&gt; Nothing errors. Queries that relied on it just get slower, sometimes dramatically on a large table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A safer rename: add the new column, write to both for a while, migrate the readers, and only then drop the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Statements that are safe but take a lock
&lt;/h2&gt;

&lt;p&gt;Adding a nullable column or an index won't fail or lose data. But a plain &lt;code&gt;CREATE INDEX&lt;/code&gt; locks the table against writes while it builds. Per the docs, other transactions can still read, but inserts, updates and deletes block until the build finishes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; avoids blocking writes. The trade-offs, also from the docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it does more total work and takes significantly longer;&lt;/li&gt;
&lt;li&gt;it can't run inside a transaction block;&lt;/li&gt;
&lt;li&gt;if it fails, it leaves behind an &lt;code&gt;INVALID&lt;/code&gt; index that still adds update overhead, so you need to drop it and retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more habit worth having for anything that needs a heavy lock: set a &lt;code&gt;lock_timeout&lt;/code&gt; first.&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;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default a statement waits indefinitely for a lock. With a timeout, the migration gives up instead of waiting, and you can retry at a quieter moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-flight routine
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Tag every statement: destructive, data-dependent, code-breaking, or lock-taking.&lt;/li&gt;
&lt;li&gt;For destructive ones, confirm the backup and that the data is unused.&lt;/li&gt;
&lt;li&gt;For data-dependent ones, run a check query first, e.g. &lt;code&gt;SELECT COUNT(*) FROM orders WHERE region IS NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For code-breaking ones, search the codebase for the old name and plan the deploy order.&lt;/li&gt;
&lt;li&gt;Rehearse on production-sized data if the table is large.&lt;/li&gt;
&lt;li&gt;Write the rollback step &lt;em&gt;before&lt;/em&gt; you run it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Automating the first pass
&lt;/h2&gt;

&lt;p&gt;I built a free &lt;a href="https://sqltoolkit.dev/migration-checker" rel="noopener noreferrer"&gt;Migration Checker&lt;/a&gt; that applies this categorisation to pasted SQL. On the example migration above it rates the &lt;code&gt;DROP COLUMN&lt;/code&gt; and the &lt;code&gt;NOT NULL&lt;/code&gt; column as danger and the rename as a warning. It reads the statements only: it can't see your data or your application code, and it doesn't flag a standalone &lt;code&gt;CREATE INDEX&lt;/code&gt;, so the lock question in section 4 is still yours to check. It runs entirely in your browser, so the schema you paste isn't uploaded.&lt;/p&gt;

&lt;p&gt;The longer write-up with more detail is &lt;a href="https://sqltoolkit.dev/guides/database-migration-checklist" rel="noopener noreferrer"&gt;here&lt;/a&gt;. If you're about to add a &lt;code&gt;NOT NULL&lt;/code&gt; column on a big Postgres table specifically, &lt;a href="https://sqltoolkit.dev/guides/add-not-null-column-postgres" rel="noopener noreferrer"&gt;this guide&lt;/a&gt; covers the backfill-and-validate pattern.&lt;/p&gt;

&lt;p&gt;What's the migration mistake that bit you hardest? I'd like to add it to the checklist.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Safe Way to Add a NOT NULL Column to a Large Postgres Table</title>
      <dc:creator>Sayonil Mitra</dc:creator>
      <pubDate>Fri, 11 Sep 2026 09:23:41 +0000</pubDate>
      <link>https://dev.to/sayonilmitra/the-safe-way-to-add-a-not-null-column-to-a-large-postgres-table-4lin</link>
      <guid>https://dev.to/sayonilmitra/the-safe-way-to-add-a-not-null-column-to-a-large-postgres-table-4lin</guid>
      <description>&lt;p&gt;If you're adding a brand-new column and can give it a constant default, this is already fast and safe in modern Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;plan&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since Postgres 11, this is a single statement — no table rewrite, and no scan to verify existing rows either. Postgres knows every existing row will read back as the default value, and a non-null default can't violate NOT NULL, so there's nothing to check. This works at any table size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual risk: NOT NULL on a column that already has data
&lt;/h2&gt;

&lt;p&gt;The dangerous case is different: a column that already exists in a populated table, where you now want to enforce NOT NULL retroactively — a field that's been optional since launch and now needs to be required, for example.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;plan&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;Run this directly and Postgres has to scan every existing row to prove none of them are null, holding an ACCESS EXCLUSIVE lock for the entire scan. On a small table that's instant. On a table with tens of millions of rows, that scan can run for minutes — and every read and write against the table queues up behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: separate "add the constraint" from "prove it holds"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Backfill any remaining NULLs, in batches:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;plan&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 for the next id range&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single unbatched UPDATE over millions of rows is its own problem — one long transaction holding row locks and generating a pile of WAL/vacuum work all at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add the constraint as NOT VALID first:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_plan_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;plan&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With NOT VALID, Postgres adds the constraint without scanning existing rows — it's the usual ACCESS EXCLUSIVE lock, but only briefly, since there's no data to check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Validate it separately:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_plan_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;This is the step that actually scans the table — but per Postgres's own documentation it takes only a SHARE UPDATE EXCLUSIVE lock, which doesn't block normal reads or writes. Traffic keeps flowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Set NOT NULL for real:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;plan&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_plan_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;Since Postgres 12, if a validated CHECK constraint already guarantees the column can't be null, this step is metadata-only — Postgres trusts the existing constraint instead of re-scanning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same distinction shows up with DROP COLUMN
&lt;/h2&gt;

&lt;p&gt;The database-level operation is nearly free in Postgres — metadata-only, the column is marked dropped but not physically removed, and disk space isn't reclaimed by routine autovacuum on its own, only by later row rewrites or a forced VACUUM FULL/CLUSTER. MySQL/InnoDB used to require a full table rebuild for DROP COLUMN, same as any other lock-holding ALTER — but MySQL 8.0.29 extended instant DDL to cover it too, under similar conditions to instant ADD COLUMN.&lt;/p&gt;

&lt;p&gt;The real risk with DROP COLUMN usually isn't the database — it's the application layer: old instances mid-rollout, SELECT * queries, ORM models that still list the field, and queued background jobs can all still be reaching for that column the instant it disappears. The fix there is sequencing — remove every application reference first, confirm via logs/APM that nothing's touching it, and only then run the drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction
&lt;/h2&gt;

&lt;p&gt;I got two things wrong in the original version of this post and I'd rather flag it than quietly edit it away: I'd claimed ADD COLUMN ... NOT NULL DEFAULT in one statement still needed a table scan under lock — it doesn't, since Postgres 11's fast-default optimization covers the NOT NULL check too, not just the table rewrite. I also overstated MySQL's DROP COLUMN cost as still requiring a rebuild — 8.0.29 fixed that. Thanks to a rigorous fact-check pass for catching both. The reasoning above reflects the correction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm writing this
&lt;/h2&gt;

&lt;p&gt;I got tired of re-deriving this pattern from memory (or from old Slack threads) every time a migration like this came up, so I built &lt;a href="https://sqltoolkit.dev/migration-checker" rel="noopener noreferrer"&gt;Migration Checker&lt;/a&gt; — paste an ALTER TABLE statement and it flags dangerous patterns like NOT NULL without a default, DROP COLUMN, DROP PRIMARY KEY, and a handful of others, before you run anything for real. It correctly tells apart ADD COLUMN ... NOT NULL DEFAULT (safe) from SET NOT NULL on an existing column (dangerous). It's part of &lt;a href="https://sqltoolkit.dev" rel="noopener noreferrer"&gt;SQL Toolkit&lt;/a&gt;, a set of free, browser-only SQL tools (formatter, JSON/CSV/Excel → SQL, schema docs) — nothing leaves your browser, no signup.&lt;/p&gt;

&lt;p&gt;If you want the deeper walkthroughs, I wrote them up as standalone guides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sqltoolkit.dev/guides/add-not-null-column-postgres" rel="noopener noreferrer"&gt;Add a NOT NULL column without locking your table&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sqltoolkit.dev/guides/drop-column-production-risks" rel="noopener noreferrer"&gt;What DROP COLUMN actually breaks in production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious what migration foot-guns you've hit — always looking for more patterns to add.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Simple SQL Tool</title>
      <dc:creator>Sayonil Mitra</dc:creator>
      <pubDate>Sat, 30 May 2026 14:05:41 +0000</pubDate>
      <link>https://dev.to/sayonilmitra/simple-sql-tool-f9b</link>
      <guid>https://dev.to/sayonilmitra/simple-sql-tool-f9b</guid>
      <description>&lt;p&gt;I am a full stack dev but randomly got interested in SQL. Something about combining queries felt like an interesting challenge, like piecing together a puzzle. Then I thought, why not make a tool which uses SQL somehow. At that time I did not know what tool to make or what my tool should do. Later I discovered, a converter can be handy. It is not life changing, ground breaking tool that will blow someone's mind. I am a fan of little utility tools which make small mundane tasks easy on a daily basis. So I built one as such. Is it useful for a lot of people in a large scale? Only time will tell I guess...&lt;br&gt;
(I will keep adding more complex features, just to see how far this project can go)&lt;/p&gt;

&lt;p&gt;If you have any feedback on these tools or want to check them out:&lt;br&gt;
&lt;a href="https://sqltoolkit.dev/json-to-sql" rel="noopener noreferrer"&gt;https://sqltoolkit.dev/json-to-sql&lt;/a&gt;&lt;br&gt;
May sound very simple but looked really cool to me that json can be converted to an sql insertion query.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sqltoolkit.dev/csv-to-sql" rel="noopener noreferrer"&gt;https://sqltoolkit.dev/csv-to-sql&lt;/a&gt;&lt;br&gt;
This idea I got from my seeing my project managers. They have a massive CSV file and want to import that into some database (I know every major platform has easy import for CSVs, but still, wanted to build it)&lt;/p&gt;

&lt;p&gt;Update: this little side project grew way more than I expected when I wrote this. It's now a full SQL Toolkit (&lt;a href="https://sqltoolkit.dev" rel="noopener noreferrer"&gt;https://sqltoolkit.dev&lt;/a&gt;) with a bunch more free tools - the original SQL formatter/beautifier, plus SQL to JSON, SQL to CSV, Excel to SQL, a schema doc generator, and a migration risk analyzer. Still no signup, still runs entirely in your browser. Would love feedback if you try it out.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
