sql 60 lines · 3 tabs

SQL migration safety: add column nullable, backfill, then constrain

Shared by codesnips Jan 2026
3 tabs
-- Step 1: add the column nullable, no default.
-- Catalog-only change in Postgres 11+, returns instantly.
ALTER TABLE orders
  ADD COLUMN currency text;

-- Optional: keep the lock attempt bounded so a long-running
-- query can't make this migration hang indefinitely.
SET lock_timeout = '3s';

COMMENT ON COLUMN orders.currency IS
  'ISO 4217 code; nullable until backfill (03_constrain) completes';
3 files · sql Explain with highlit

Adding a NOT NULL column to a large, busy table in one statement is a classic way to cause an outage. In PostgreSQL, ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ... can rewrite the whole table (older versions) and, more importantly, adding the constraint requires validating every existing row while holding an ACCESS EXCLUSIVE lock that blocks reads and writes. The safe pattern splits the change into three independently deployable steps: add the column nullable, backfill data in small batches, then add the constraint without a long lock.

The first tab, 01_add_column.sql, adds currency as a plain nullable text column. A bare ADD COLUMN with no default and no NOT NULL is a catalog-only change in modern Postgres, so it takes a brief lock and returns almost instantly regardless of table size. New writes from the application start populating the column immediately (the app is deployed to set it), while historical rows stay NULL for now.

The second tab, 02_backfill.sql, fills those NULL rows in bounded chunks. The loop uses a CTE that selects up to batch_size ids WHERE currency IS NULL, FOR UPDATE SKIP LOCKED so concurrent transactions never block each other, and updates only that slice. GET DIAGNOSTICS updated = ROW_COUNT drives the exit condition, and COMMIT inside the DO-style procedure keeps each batch's locks short-lived. Sleeping briefly between batches yields headroom to production traffic and lets autovacuum keep up. This is idempotent: re-running only touches rows still NULL.

The third tab, 03_constrain.sql, adds the guarantee cheaply. Rather than SET NOT NULL directly, it first adds a CHECK (currency IS NOT NULL) NOT VALID constraint, which skips scanning existing rows and only enforces the rule on new writes. A separate VALIDATE CONSTRAINT then scans the table under a weaker SHARE UPDATE EXCLUSIVE lock that permits concurrent reads and writes. On Postgres 12+, an equivalent validated CHECK even lets SET NOT NULL skip its own full scan.

The trade-off is more steps and a window where the column is nullable, so application code must tolerate NULL until step three completes. The payoff is that no single statement takes a table-wide blocking lock, making the whole change safe under load.


Related snips

Share this code

Here's the card — post it anywhere.

SQL migration safety: add column nullable, backfill, then constrain — share card
Link copied