skip to content

PostgreSQL lets you add a constraint with the NOT VALID clause and run VALIDATE CONSTRAINT later. Walk through the two steps — what each one locks and checks, and exactly what guarantee you have in the window between them.

level: seniorimportance: must knowfreq 38%

answer

  1. Unbundles catalog change from verification scan
  2. NOT VALID: brief ACCESS EXCLUSIVE, no scan, new rows enforced
  3. VALIDATE: SHARE UPDATE EXCLUSIVE, reads/writes continue
  4. Failed validation leaves it NOT VALID — retry after cleanup
  5. Not for UNIQUE/PK — use CREATE INDEX CONCURRENTLY + USING INDEX

basics

~20 s

ADD CONSTRAINT ... NOT VALID takes a brief strong lock and no scan; from then on all new and modified rows are enforced, but existing rows are unchecked. VALIDATE CONSTRAINT then scans the table under a weak lock that allows concurrent reads and writes.

solid answer

~60 s

**Step 1 — `ADD CONSTRAINT ... NOT VALID`.** Catalog-only. It takes the strong lock (`ACCESS EXCLUSIVE` on the table; for a foreign key also a lock on the referenced table) but holds it for milliseconds because it skips the verification scan. The constraint is immediately live for all subsequent INSERTs and UPDATEs. **Step 2 — `VALIDATE CONSTRAINT`.** Scans existing rows and, if all pass, flips `convalidated`. It takes only `SHARE UPDATE EXCLUSIVE` on the table (plus `ROW SHARE` on the referenced table for a foreign key), so normal reads and writes continue throughout. It cannot run concurrently with another such maintenance operation on the same table. **The in-between guarantee:** new data is fully enforced, historical data is unproven. The constraint is a real constraint, not a comment. What you lose is that the planner will not *reason* from an unvalidated constraint — it will not use an unvalidated CHECK for partition pruning or constraint exclusion. Works for CHECK and foreign-key constraints, and for `NOT NULL` indirectly via a validated CHECK. It does not apply to UNIQUE/PRIMARY KEY, which need an index built concurrently instead.

code

sql · 7 lines
sql
SET lock_timeout = '2s';
ALTER TABLE orders
  ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id)
  REFERENCES customers (id) NOT VALID;

-- later, off-peak, after historical rows are repaired
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;

go deeper

for a junior

Know that the two-step form adds the rule quickly and checks the old rows later, and that new rows are enforced from step one.

for a middle

Name the lock each step takes and state precisely what is and is not guaranteed between them.

for a senior

Give the full staged rollout — stop the source, add NOT VALID, repair in batches, validate off-peak — plus the planner limitation and the failure-retry behaviour.

for a principal

Treat it as the template for online schema change: decouple the brief strong-lock catalog step from the long weak-lock verification step, and name the constraint types that need a different route (unique via a concurrently-built index).

## The problem it solves A plain `ADD CONSTRAINT` bundles two things: recording the rule in the catalog, and proving every existing row obeys it. The first is instantaneous, the second is a full scan — and because they happen in one statement, the strong lock needed for the catalog change is held for the entire scan. `NOT VALID` unbundles them. ## Step 1: adding the constraint unvalidated ```sql ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID; ``` This records the constraint and attaches its enforcement machinery, then returns. No rows are read. It still needs `ACCESS EXCLUSIVE` on `orders` and a lock on `customers`, but only for the moment the catalog is written, so with a short `lock_timeout` and a retry loop it is safe to run at any time of day. From the instant it commits: - Every `INSERT` is checked. - Every `UPDATE` that touches the constrained columns is checked. (Subtlety worth knowing: an update to an unrelated column of a pre-existing violating row is *not* forced to fix it for a CHECK constraint — the check applies to rows being inserted or updated in the constrained columns.) - Existing untouched rows are not examined and may violate the rule. The constraint is marked `convalidated = false` in `pg_constraint`. ## Step 2: validating ```sql ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk; ``` This is the scan. It reads the table, verifies each row, and on success flips the constraint to validated. Crucially it takes only `SHARE UPDATE EXCLUSIVE` on the table — the same class of lock as `VACUUM` and `CREATE INDEX CONCURRENTLY` — which does **not** conflict with `SELECT`, `INSERT`, `UPDATE` or `DELETE`. For a foreign key it takes `ROW SHARE` on the referenced table. So the long part runs while the application keeps working. What it does conflict with is other `SHARE UPDATE EXCLUSIVE` holders — another validation, a concurrent index build, an autovacuum on that table — so schedule accordingly. If validation finds a violating row it raises an error and the constraint simply stays `NOT VALID`. Nothing is lost; you clean the data and try again. That is why the practical order is: add NOT VALID first (to stop new bad data arriving), *then* fix the historical rows, *then* validate. Fixing first and adding later leaves a race in which new violations appear between the cleanup and the constraint. ## What you actually give up in the window The temptation is to think an unvalidated constraint is decorative. It is not — it is enforced for all new writes. Two real limitations: 1. **The planner will not trust it.** PostgreSQL will not use an unvalidated CHECK constraint for constraint exclusion or partition pruning, because the guarantee does not hold over existing rows. If you added the CHECK specifically to enable pruning, you get nothing until validation completes. 2. **Your invariant is not actually true yet.** Application code that assumes "every order has a customer" can still meet an orphan from before the migration. Until validation succeeds, treat the invariant as aspirational in code paths that read old data. Some operations also refuse to proceed against unvalidated constraints — attaching a partition, for instance, benefits from a validated CHECK to skip its own scan. ## The end-to-end staged rollout 1. **Stop the source.** Fix the application code producing violations, and deploy it. 2. **Add `NOT VALID`.** Short lock, immediate enforcement of new writes. 3. **Repair history in batches.** Fix or delete violating rows in small transactions, so you never hold long locks. Query for them with an anti-join. 4. **Validate.** Off-peak, under the weak lock. Expect it to take as long as a full scan of the table. 5. **Verify.** Confirm `convalidated` is true, and re-check anything that depends on planner behaviour. Each step is independently revertible, and the risky step (the brief strong lock) is decoupled from the slow step (the scan). ## Where it does not apply `NOT VALID` is supported for CHECK and foreign-key constraints. It is not available for `UNIQUE` or `PRIMARY KEY`, because there the constraint *is* an index and there is no way to have the index without building it. The equivalent staged approach for uniqueness is `CREATE UNIQUE INDEX CONCURRENTLY`, which builds without blocking writes, followed by `ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX`, which adopts the finished index with only a brief lock and no rescan. For `NOT NULL`, the modern route is to add `CHECK (col IS NOT NULL) NOT VALID`, validate it, then `SET NOT NULL` — from PostgreSQL 12 onward the planner uses the validated CHECK to skip the scan that `SET NOT NULL` would otherwise perform.

  • Should you clean the historical data before or after adding the constraint NOT VALID?
    After. Adding it NOT VALID first stops any new violations from arriving, so your cleanup converges instead of racing against live traffic. If you clean first and add later, rows written in between can reintroduce violations and the eventual validation fails.
  • Why can't you use NOT VALID for a UNIQUE constraint?
    A unique constraint is implemented by a unique index, so there is no version of it that exists without the index having been built — the build is the verification. The staged equivalent is CREATE UNIQUE INDEX CONCURRENTLY, which builds without blocking writes, then ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX to adopt it with only a brief lock and no second scan.
  • What happens if VALIDATE CONSTRAINT hits a violating row?
    It raises an error and rolls back, leaving the constraint in its NOT VALID state — enforced for new writes, unproven for old ones. Nothing is damaged; you locate the offending rows with an anti-join, repair or delete them in batches, and run the validation again.

saying these in an interview costs you the question

  • Believing a NOT VALID constraint is not enforced for new writes
  • Thinking VALIDATE CONSTRAINT takes the same lock as ADD CONSTRAINT
  • Cleaning historical data before adding the constraint, leaving a race window
  • Expecting the planner to use an unvalidated CHECK for partition pruning
  • Trying to add a UNIQUE constraint NOT VALID

context