How to Run a Zero-Downtime Postgres Migration
Adding a NOT NULL column to a table that already has rows fails unless you tell Postgres what to put in those rows. The three-step pattern - add nullable, backfill, then promote to NOT NULL - lets you ship the column without blocking writes or causing downtime.
Why the naive migration errors out
If you run:
ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL;
against a table with existing rows, Postgres rejects it immediately:
ERROR: column "email_verified" of relation "users" contains null values
Every existing row would have NULL in the new column, which violates NOT NULL.
Postgres refuses the statement rather than silently breaking your constraint.
Two safe approaches
Option 1: DEFAULT in one statement (Postgres 11+)
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT false;
On Postgres 11 and later this is a metadata-only change - Postgres records the
default in the catalog and never rewrites existing rows. It is instant, even on a
100-million-row table. New rows get false; existing rows read false at query time.
Use this when the default value is meaningful for the existing data.
Option 2: Three-step zero-downtime migration
When you cannot set a single permanent default (or need a different value per row), use the three-step pattern:
-- Step 1: add the column as nullable (no constraint, no table rewrite)
ALTER TABLE users ADD COLUMN email_verified BOOLEAN;
-- Step 2: backfill all existing rows
UPDATE users SET email_verified = false WHERE email_verified IS NULL;
-- Step 3: promote to NOT NULL now that no row is NULL
ALTER TABLE users ALTER COLUMN email_verified SET NOT NULL;
Each step acquires only a brief lock. Steps 1 and 3 are metadata operations.
Step 2 (the UPDATE) takes a row-level lock per row, not a table lock, so reads
keep working throughout.
What to watch on large tables
On tables with millions of rows, the UPDATE in step 2 can run for seconds or
minutes. To avoid holding locks that long:
- Batch the backfill - chunk by primary key and sleep briefly between batches so the table stays available to writes:
DO $$
DECLARE
batch_size INT := 10000;
min_id BIGINT;
max_id BIGINT;
BEGIN
SELECT MIN(id), MAX(id) INTO min_id, max_id FROM users WHERE email_verified IS NULL;
WHILE min_id <= max_id LOOP
UPDATE users SET email_verified = false
WHERE id >= min_id AND id < min_id + batch_size AND email_verified IS NULL;
min_id := min_id + batch_size;
PERFORM pg_sleep(0.05);
END LOOP;
END $$;
- Set a
lock_timeout- so the migration fails fast rather than queuing behind a long-running transaction:
SET lock_timeout = '2s';
ALTER TABLE users ALTER COLUMN email_verified SET NOT NULL;
- Use a tool for multi-TB tables -
pg_repack,gh-ost(MySQL), or Postgres-nativepg_oscautomate the batching and lock management. The three-step pattern is the foundation those tools formalize.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Adding a nullable column and backfilling rows before setting NOT NULL
- Using ADD COLUMN NOT NULL DEFAULT for an instant metadata-only migration
- Batching large UPDATE backfills to avoid long table locks
FAQ
Why does adding a NOT NULL column fail in Postgres?
Postgres requires that every existing row satisfies the NOT NULL constraint. If you add the column without a DEFAULT, existing rows would have NULL, which violates the constraint - so Postgres rejects the statement.
What is the zero-downtime pattern for adding a NOT NULL column?
Three steps: (1) add the column as nullable, (2) UPDATE all existing rows to a value, (3) ALTER COLUMN SET NOT NULL. Each step holds only a brief lock, so the table stays writable throughout.
Is ADD COLUMN NOT NULL DEFAULT safe in Postgres 11+?
Yes - on Postgres 11 and later, adding a column with a constant NOT NULL DEFAULT is a metadata-only change. Postgres never rewrites the table, so it completes instantly even on very large tables.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →