How to Add a NOT NULL Column With No Downtime in PostgreSQL
A single ALTER TABLE ADD COLUMN NOT NULL DEFAULT ... rewrites every row in one transaction - seconds of table lock on a large table, writes blocked the whole time. The fix is a three-step pattern: add nullable first, backfill in batches, then enforce NOT NULL. Here's how to implement it.
Why the naive migration causes downtime
On PostgreSQL 10 and earlier, adding a column with a DEFAULT rewrites every row to store
the new value, holding an AccessExclusiveLock for the entire duration. A 100,000-row
table may lock for several seconds; a 100M-row table can lock for minutes - every write
blocks until it finishes.
PostgreSQL 11+ made ADD COLUMN ... DEFAULT ... metadata-only for non-volatile defaults,
so the default is recorded in the catalog rather than written to each row. But if you
immediately add NOT NULL, Postgres must still scan every row to validate the constraint,
which brings the lock back.
The safe pattern splits the work into three short, independently committable steps.
Step A - add the column as NULLABLE with a default
ALTER TABLE orders ADD COLUMN customer_segment TEXT DEFAULT 'standard';
On Postgres 11+ this is a pure catalog update - no row rewrite, no long lock. New rows get the default automatically. Existing rows are still NULL.
Step B - backfill existing rows in batches
Loop until no NULLs remain, committing each batch independently so no single transaction holds a long lock:
import psycopg2
conn = psycopg2.connect("postgres://postgres@localhost:5432/app")
conn.autocommit = True # each batch is its own transaction
BATCH_SIZE = 5000
while True:
with conn.cursor() as cur:
cur.execute(
"""
WITH batch AS (
SELECT id FROM orders
WHERE customer_segment IS NULL
LIMIT %s
FOR UPDATE SKIP LOCKED
)
UPDATE orders SET customer_segment = 'standard'
FROM batch WHERE orders.id = batch.id
""",
(BATCH_SIZE,),
)
if cur.rowcount == 0:
break
print(f"backfilled {BATCH_SIZE} rows...")
Postgres does not support LIMIT directly in UPDATE, so the CTE pattern is the
standard approach. FOR UPDATE SKIP LOCKED avoids blocking concurrent writers.
Step C - enforce NOT NULL and drop the default
Once every row has a value, the constraint check is a fast metadata scan, not a full rewrite:
ALTER TABLE orders ALTER COLUMN customer_segment SET NOT NULL;
ALTER TABLE orders ALTER COLUMN customer_segment DROP DEFAULT;
This does require a brief AccessExclusiveLock, but it is a constraint validation
plus metadata change - not a row rewrite - so it completes in milliseconds.
Idempotency
Make each step safe to re-run by checking state before acting:
- Step A: query
information_schema.columnsfor the column name before adding it. - Step B: the loop terminates naturally when no NULLs remain - re-running is a no-op.
- Step C: check
is_nullable = 'NO'before issuing theSET NOT NULL.
Idempotency means the script can be interrupted and restarted at any point without corrupting the table.
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 with a DEFAULT as a metadata-only Postgres operation
- Backfilling rows in batches using a CTE UPDATE with FOR UPDATE SKIP LOCKED
- Enforcing NOT NULL after the backfill with idempotent state checks
FAQ
Why does ALTER TABLE ADD COLUMN NOT NULL lock my table?
Adding a NOT NULL constraint forces PostgreSQL to scan every existing row to confirm none are NULL. On large tables that full-table scan holds an AccessExclusiveLock for its entire duration, blocking all writes.
How do I add a NOT NULL column in PostgreSQL without locking the table?
Use the three-step pattern: first ADD COLUMN as nullable with a DEFAULT (metadata-only on Postgres 11+), then backfill existing NULL rows in small batches each committed separately, then SET NOT NULL once no NULLs remain.
How do I UPDATE in batches in PostgreSQL?
Use a CTE to SELECT a limited set of rows and JOIN the UPDATE to it - for example: WITH batch AS (SELECT id FROM orders WHERE col IS NULL LIMIT 5000) UPDATE orders SET col = val FROM batch WHERE orders.id = batch.id. Postgres does not support LIMIT directly in UPDATE.
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 →