Adding Constraints Without Downtime

Constraints are the part of a schema that turns application assumptions into guarantees: every order has a customer, no two accounts share an email, a quantity is never negative. They are also the part of a schema change most likely to stall a production table, because adding a constraint to an existing table normally means proving it holds for every row that is already there — a full scan, performed while holding a lock that blocks writes. On a table with a hundred million rows that proof takes minutes, and for those minutes every insert and update queues. This part of Zero-Downtime Schema Evolution Patterns covers how to separate enforcing a constraint for new writes, which is instant, from validating it against existing rows, which can run online. It serves engineers tightening data integrity on live systems and DBAs who approve those changes.

Every technique here follows the same two-phase shape, and it is the constraint-level version of Expand and Contract Methodology: first make the database enforce the rule on new data without checking old data; then check old data under a lock that does not block normal traffic; then, where needed, promote the validated rule into its final form.

Enforce Now, Validate Later Four steps. Clean existing data that would violate the rule; add the constraint as NOT VALID, which is instant and enforces it for new writes; VALIDATE CONSTRAINT scans existing rows under SHARE UPDATE EXCLUSIVE while reads and writes continue; promote if needed, for example SET NOT NULL using the validated check. Enforce Now, Validate Later PHASE 1 Clean data fix violating rows first PHASE 2 Add NOT VALID instant; new writes checked PHASE 3 VALIDATE scan, reads + writes continue PHASE 4 Promote SET NOT NULL / attach index
Splitting enforcement from validation is what turns a blocking scan into an online one.

Concept & Mechanism

In PostgreSQL, ALTER TABLE ... ADD CONSTRAINT takes a lock on the table (for most constraint types ACCESS EXCLUSIVE; for foreign keys SHARE ROW EXCLUSIVE on both tables) and, by default, scans every existing row to prove the constraint holds, all before committing. The lock is held for the entire scan. The NOT VALID option, available for CHECK and FOREIGN KEY constraints, skips the scan: the constraint is recorded and enforced for all subsequent inserts and updates, but existing rows are not checked, so the lock is held only for a moment. A later ALTER TABLE ... VALIDATE CONSTRAINT performs the scan while holding only SHARE UPDATE EXCLUSIVE on the table (and ROW SHARE on a referenced table), which does not block reads or writes.

Two other constraint types have their own online paths. NOT NULL has no NOT VALID form, but since PostgreSQL 12, ALTER COLUMN ... SET NOT NULL skips its scan if a validated CHECK (col IS NOT NULL) constraint already exists — so you add the check NOT VALID, validate it, then set NOT NULL. UNIQUE and PRIMARY KEY constraints are backed by an index; you build that index with CREATE UNIQUE INDEX CONCURRENTLY and then attach it with ADD CONSTRAINT ... UNIQUE USING INDEX, which is a brief metadata operation.

MySQL 8.0 has a different toolset. CHECK constraints (enforced since 8.0.16) are validated when added and require a table rebuild via ALGORITHM=COPY, blocking writes; foreign keys can be added with ALGORITHM=INPLACE only when foreign_key_checks=0, which skips validation of existing rows entirely rather than deferring it. Unique indexes can be built with ALGORITHM=INPLACE, LOCK=NONE. For heavy changes on large MySQL tables, online schema change tools rebuild the table in the background, as described in Online Schema Change Tools.

Online Path per Constraint Type Matrix of constraint types against the blocking default and the online path in PostgreSQL and MySQL 8.0. Online Path per Constraint Type Constraint Blocking default PostgreSQL online path MySQL 8.0 online path FOREIGN KEY scan under lock on both tables NOT VALID, then VALIDATE INPLACE with foreign_key_checks=0 (no validation) CHECK scan under ACCESS EXCLUSIVE NOT VALID, then VALIDATE COPY rebuild; use gh-ost / pt-osc NOT NULL scan under ACCESS EXCLUSIVE validated CHECK, then SET NOT NULL (PG 12+) rebuild; use gh-ost / pt-osc UNIQUE / PRIMARY KEY index build blocks writes UNIQUE INDEX CONCURRENTLY, then USING INDEX ADD UNIQUE INDEX, INPLACE, LOCK=NONE
PostgreSQL has a clean two-phase path for every constraint type; MySQL relies on online DDL for unique indexes and on rebuild tools for the rest.

Two properties of NOT VALID constraints are worth internalising. First, they are real constraints: inserts and updates that violate them fail immediately, so the application must already produce valid data before you add one. Second, an unvalidated constraint gives the planner and other features fewer guarantees — PostgreSQL will not use an unvalidated CHECK for constraint exclusion or for skipping the SET NOT NULL scan — so validation is a real step, not a formality.

Constraints also interact with replication in ways that matter on busy systems. On PostgreSQL, VALIDATE CONSTRAINT only reads, so it generates almost no WAL and replicas are unaffected apart from the usual lock replay. A plain ADD CONSTRAINT on a large table is similarly light on WAL, but its long exclusive lock is replayed on physical replicas too, where it can cancel conflicting read queries under max_standby_streaming_delay. On MySQL, a COPY rebuild is replayed on each replica as one long statement, lagging them by the full rebuild time — one more reason large MySQL changes go through online schema change tools that replicate as ordinary row changes.

Finally, remember what each constraint costs after it exists. Foreign keys add a lookup on the parent for every child insert or key update and a lookup on the child for every parent delete — cheap with the right indexes, expensive without. Check constraints are evaluated on every insert and update of the row, so keep their expressions simple and immutable. Unique constraints add an index that every write maintains. None of these costs is a reason to avoid constraints; they are reasons to add the supporting indexes and to review expressions as carefully as the migration that introduces them.

Prerequisites & Decision Criteria

Decide the approach from table size, write rate and engine.

Situation Approach
New table, or table with a few thousand rows plain ADD CONSTRAINT; the scan is negligible
Large PostgreSQL table, FK or CHECK NOT VALID then VALIDATE in a separate migration
Large PostgreSQL table, NOT NULL CHECK (col IS NOT NULL) NOT VALID, VALIDATE, SET NOT NULL, drop the check
Large PostgreSQL table, UNIQUE CREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX
Large MySQL table, UNIQUE ADD UNIQUE INDEX ..., ALGORITHM=INPLACE, LOCK=NONE
Large MySQL table, CHECK or NOT NULL online schema change tool

Before adding any constraint to a live table:

Step-by-Step Procedure

The procedure below adds a foreign key and a NOT NULL to a large PostgreSQL table; the other constraint types follow the same rhythm.

1. Find violating rows before touching the schema. A NOT VALID constraint does not care about old rows, but VALIDATE will fail on them — after scanning the whole table. Verify the count is zero before proceeding.

-- PostgreSQL · read-only · run on a replica if the table is large
SELECT count(*) AS orphans
FROM orders o LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.customer_id IS NOT NULL AND c.id IS NULL;
SELECT count(*) AS null_regions FROM orders WHERE region IS NULL;

2. Add the constraints as NOT VALID. Each takes its lock for milliseconds; new writes are checked from this moment.

-- PostgreSQL 12+ · migration role · brief locks only
-- WARNING: new writes that violate these constraints fail immediately after COMMIT.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD CONSTRAINT orders_customer_fk
  FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders ADD CONSTRAINT orders_region_nn CHECK (region IS NOT NULL) NOT VALID;
COMMIT;
-- ROLLBACK PATH: ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_customer_fk, DROP CONSTRAINT IF EXISTS orders_region_nn;

3. Validate each constraint in its own statement. Validation scans under SHARE UPDATE EXCLUSIVE; reads and writes continue. Verify that each validation completes and that pg_constraint.convalidated is true before the next step.

-- PostgreSQL · migration role · run as separate statements (or separate migrations)
SET lock_timeout = '3s';
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;
ALTER TABLE orders VALIDATE CONSTRAINT orders_region_nn;

4. Promote the validated check to NOT NULL. PostgreSQL 12+ sees the validated check and skips the scan, so this is instant; the helper check can then be dropped.

-- PostgreSQL 12+ · migration role · instant because orders_region_nn is validated
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ALTER COLUMN region SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_region_nn;
COMMIT;

5. Add supporting indexes for foreign keys. PostgreSQL does not index the referencing column automatically; without an index, deletes on customers scan orders. Build it concurrently, per building indexes with CREATE INDEX CONCURRENTLY.

6. Record and monitor. Confirm the final state in the catalog and watch for constraint-violation errors from the application, which would mean some code path still writes invalid data.

Lock Profile of the Two-Phase Approach Timeline over 10 minutes. The plain ADD CONSTRAINT holds ACCESS EXCLUSIVE for 8 minutes while scanning, blocking all writes. The two-phase approach holds a lock for under a second to add NOT VALID, then validates for 8 minutes under SHARE UPDATE EXCLUSIVE while writes continue. Lock Profile of the Two-Phase Approach One-phase ADD ACCESS EXCLUSIVE + full scan Writes (one-phase) blocked ok Two-phase VALIDATE (SHARE UPDATE EXCLUSIVE) Writes (two-phase) continue throughout 0 2 min 4 min 6 min 8 min 10 min exclusive lock writes blocked online work unaffected
Same scan, same duration — but only the one-phase version makes users wait for it.

Verification & Observability

The catalog records whether each constraint is validated:

-- PostgreSQL · read-only
SELECT conname, contype, convalidated
FROM pg_constraint
WHERE conrelid = 'orders'::regclass
ORDER BY conname;

During validation, watch progress and blocking. VALIDATE CONSTRAINT does not report progress in a dedicated view, but its session is visible in pg_stat_activity with its runtime, and pg_stat_user_tables.seq_scan increments when it starts. Confirm it is not blocking anyone with the lock-tree query in finding the blocking session with pg_blocking_pids. On MySQL, confirm constraints in information_schema.TABLE_CONSTRAINTS and information_schema.CHECK_CONSTRAINTS.

After the change, the most important signal is the application’s error rate for constraint violations — SQLSTATE 23503 (foreign key), 23514 (check), 23502 (not null), 23505 (unique) on PostgreSQL. A rise means a code path writes data the new rule rejects; find it before it becomes a customer-visible failure. The alerting side is covered in Migration Observability.

Constraint Rollout Gates Pipeline. A gate checks for violating rows and fails if any exist; the NOT VALID constraint is added; a gate watches application constraint-violation errors for a soak period; VALIDATE runs; a gate confirms convalidated is true; promotion runs. Constraint Rollout Gates data violations = 0? Add NOT VALID instant, lock_timeout errors no 23xxx spike? VALIDATE online scan Promote SET NOT NULL / attach clean data first fix writer, drop constraint fail
The soak between adding and validating is where you learn whether the application really writes valid data.

Rollback Path

Every phase is reversible with a drop, and drops are cheap: ALTER TABLE ... DROP CONSTRAINT takes a brief ACCESS EXCLUSIVE lock and does no scan. That makes constraints one of the safest changes to roll back — provided the drop also uses a lock timeout.

-- PostgreSQL · migration role · reverses any phase of the procedure
-- WARNING: DROP CONSTRAINT still needs a brief exclusive lock; keep the timeout.
SET lock_timeout = '3s';
ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_customer_fk;
ALTER TABLE orders ALTER COLUMN region DROP NOT NULL;

Roll back when the application turns out to write data that violates the rule and you cannot fix the writer quickly. After validation and promotion, removing a constraint is still safe for the database, but review whether any code now relies on the guarantee — for example, queries that assume every order has a customer. Removing constraints as part of a contract phase has its own sequencing, covered in dropping constraints safely during the contract phase.

Common Errors & Fixes

ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_fk" right after adding NOT VALID. Root cause: an application path writes orphaned rows. Fix: this is the constraint working; find the writer. If it cannot be fixed immediately, drop the constraint and retry after the fix.

ERROR: check constraint "orders_region_nn" of relation "orders" is violated by some row during VALIDATE. Root cause: violating rows existed or were written before the constraint was added. Fix: fix the rows (the constraint already prevents new ones), then rerun VALIDATE.

SET NOT NULL still takes minutes. Root cause: the check constraint is not validated, is written differently (for example CHECK (region <> '')), or the server is older than PostgreSQL 12. Fix: confirm convalidated = true and the exact form col IS NOT NULL.

ERROR: could not create unique index ... Key (email)=(a@example.com) is duplicated. Root cause: duplicates exist. Fix: deduplicate first; after a failed concurrent build, drop the invalid index before retrying — see adding unique constraints using an existing index.

Child Page Index

Each constraint type has its own guide. Adding foreign keys with NOT VALID and VALIDATE CONSTRAINT covers orphan cleanup, lock modes on both tables, and indexing the referencing column. Adding NOT NULL via a CHECK constraint in Postgres walks through the PostgreSQL 12+ proof-based SET NOT NULL. Adding unique constraints using an existing index builds the index concurrently and attaches it. Adding check constraints online in MySQL covers MySQL 8.0’s constraint behaviour and rebuild tools. And dropping constraints safely during the contract phase handles the reverse direction.

Framework-specific spellings of the same techniques appear in Django Migrations Without Downtime and Rails Active Record Migrations.

Frequently Asked Questions

Does a NOT VALID constraint protect new data? Yes. It is enforced for every insert and update after it is added. Only rows that existed before are unchecked until VALIDATE CONSTRAINT runs.

What lock does VALIDATE CONSTRAINT take? SHARE UPDATE EXCLUSIVE on the table being validated, which allows reads and writes but conflicts with other schema changes and with VACUUM. For foreign keys it also takes ROW SHARE on the referenced table.

Why can’t I just use NOT VALID for NOT NULL? PostgreSQL has no NOT VALID form of NOT NULL. The equivalent is a CHECK (col IS NOT NULL) NOT VALID constraint, validated online; PostgreSQL 12 and later then use it to set NOT NULL without scanning.

Is adding a foreign key in MySQL with foreign_key_checks=0 safe? It avoids the table copy, but it also skips validation entirely: existing orphaned rows remain and are never checked. Verify there are none with a query before adding the constraint this way.