Adding NOT NULL via a CHECK Constraint in Postgres
The backfill finished: every one of the 250 million rows in events now has a tenant_id, and the last step of the expand-and-contract plan is to make the column NOT NULL. ALTER TABLE events ALTER COLUMN tenant_id SET NOT NULL looks like a metadata change, but it is not. PostgreSQL must prove there are no NULLs, so it scans the entire table while holding ACCESS EXCLUSIVE — no reads, no writes — for as long as the scan takes, which on this table is several minutes. Since PostgreSQL 12 there is a way around it: if a validated CHECK (tenant_id IS NOT NULL) constraint already exists, SET NOT NULL uses it as proof and skips the scan. And a check constraint can be added NOT VALID and validated online. This guide walks through the three statements that turn a multi-minute outage into three brief locks and one non-blocking scan. It belongs to Adding Constraints Without Downtime.
SET NOT NULL, where it blocks everything, to VALIDATE, where it blocks nothing.Symptom / Error Signatures
You need this technique when:
ALTER TABLE ... SET NOT NULLon a large table appears in a migration, and inpg_stat_activityit runs for minutes while other sessions queue withwait_event_type = 'Lock'.- A migration tool generated
SET NOT NULLas the final step of adding a required column — for example Rails’change_column_null, Django’sAlterFieldtonull=False, or Prisma making a field required. strong_migrationsreports Setting NOT NULL on an existing column blocks reads and writes while every row is checked.
If the scan runs and finds a NULL, it fails after doing all the work:
ERROR: column "tenant_id" of relation "events" contains null values
Root Cause Analysis
NOT NULL is a column attribute, not a named constraint, and PostgreSQL has no NOT VALID form for it. To set it, the server must be sure no existing row violates it, and until PostgreSQL 12 the only way was to scan the table under ACCESS EXCLUSIVE. PostgreSQL 12 added an optimisation: when setting NOT NULL, the planner checks whether an existing, validated CHECK constraint already implies it. A constraint of the form CHECK (col IS NOT NULL) does, so the scan is skipped and SET NOT NULL becomes a catalog update.
CHECK constraints can be added NOT VALID (brief lock, no scan, enforced for new writes) and validated with VALIDATE CONSTRAINT, which scans under SHARE UPDATE EXCLUSIVE and allows concurrent reads and writes. Combining the two features gives an online path.
| Statement | Lock | Scan | Duration on 250M rows |
|---|---|---|---|
SET NOT NULL (no proof) |
ACCESS EXCLUSIVE | yes | minutes, all traffic blocked |
ADD CONSTRAINT ... CHECK (...) NOT VALID |
ACCESS EXCLUSIVE, brief | no | milliseconds |
VALIDATE CONSTRAINT |
SHARE UPDATE EXCLUSIVE | yes | minutes, traffic continues |
SET NOT NULL (validated check exists) |
ACCESS EXCLUSIVE, brief | no | milliseconds |
DROP CONSTRAINT (the check) |
ACCESS EXCLUSIVE, brief | no | milliseconds |
The check must be exactly col IS NOT NULL (additional conjuncts are fine, but a different expression such as col <> '' does not prove non-nullness). On PostgreSQL 11 and earlier the optimisation does not exist; there, the validated check itself is the guarantee and you can leave it in place instead of converting to NOT NULL.
Immediate Mitigation
1. Cancel a blocking SET NOT NULL if one is running. It is transactional; cancelling it rolls back cleanly and releases the table.
-- PostgreSQL · requires pg_signal_backend
SELECT pid, now() - query_start AS running FROM pg_stat_activity
WHERE query ILIKE '%SET NOT NULL%' AND state = 'active';
SELECT pg_cancel_backend(<pid>);
2. Confirm there are no NULLs left. Validation will fail on the first one after scanning; check first, ideally on a replica.
-- PostgreSQL · read-only · uses an index on tenant_id if one exists
SELECT count(*) FROM events WHERE tenant_id IS NULL;
3. Add the check as NOT VALID. It is enforced for new writes from this moment, so the application must already always set the column.
-- PostgreSQL 12+ · migration role · brief ACCESS EXCLUSIVE
-- WARNING: inserts or updates that leave tenant_id NULL fail from now on.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE events ADD CONSTRAINT events_tenant_id_nn CHECK (tenant_id IS NOT NULL) NOT VALID;
COMMIT;
-- ROLLBACK PATH: ALTER TABLE events DROP CONSTRAINT IF EXISTS events_tenant_id_nn;
4. Validate, then promote and tidy up.
-- PostgreSQL 12+ · migration role · run VALIDATE on its own, then the short transaction
SET lock_timeout = '3s';
ALTER TABLE events VALIDATE CONSTRAINT events_tenant_id_nn; -- online scan
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE events ALTER COLUMN tenant_id SET NOT NULL; -- instant: uses the validated check
ALTER TABLE events DROP CONSTRAINT events_tenant_id_nn; -- redundant after SET NOT NULL
COMMIT;
-- ROLLBACK PATH: ALTER TABLE events ALTER COLUMN tenant_id DROP NOT NULL;
Permanent Fix / Long-Term Pattern
Adopt the four-statement sequence as the only way to add NOT NULL to an existing column on any table larger than a few hundred thousand rows. Place it at the end of the expand-and-contract lifecycle: add the column nullable, deploy code that always writes it, backfill old rows, then tighten — the order described in Backfill Optimization and used throughout Expand and Contract Methodology. Put the NOT VALID addition in one migration and the validation plus promotion in the next, so a problem discovered after enforcement begins can be fixed before the scan runs.
Teach your tools the pattern. ORMs generate the one-statement form; override it with hand-written SQL or the framework’s constraint operations, as covered for Django in adding a non-null field in Django without locking and for Prisma in customizing Prisma migrations for zero downtime. A lint rule that flags SET NOT NULL not preceded by a validated check on the same column catches regressions.
NOT NULL.Verification Checklist
Frequently Asked Questions
Why doesn’t PostgreSQL have SET NOT NULL NOT VALID?
NOT NULL is stored as a column attribute rather than as a separately named constraint with a validity flag, so it cannot exist in an unvalidated state. The CHECK (col IS NOT NULL) constraint fills that role, and PostgreSQL 12+ recognises it as proof.
Is the helper check constraint needed after SET NOT NULL?
No. Once the column is NOT NULL, the check is redundant and adds a small cost to every write, so drop it. On PostgreSQL 11 and earlier, where SET NOT NULL would still scan, keep the check as the enforcement mechanism instead.
Does VALIDATE CONSTRAINT block writes?
No. It takes SHARE UPDATE EXCLUSIVE, which allows inserts, updates and deletes. It does conflict with other schema changes, VACUUM and concurrent index builds on the same table, so avoid running them at the same time.
What if new NULLs appear during validation?
They cannot: the NOT VALID constraint already rejects inserts and updates that would set the column to NULL. Validation only has to check rows that existed before it was added.