Adding a PostgreSQL foreign key to an existing table is both a schema change and a statement about old data. New writes can be checked immediately while historical rows still contain broken references. Separate the constraint’s enforcement, its validation state, and its checking time when planning a migration or diagnosing an unexpected integrity failure.

Enforcement is not historical validation

An ordinary enforced foreign key requires a matching referenced key for each applicable nonnull reference. The referenced columns need eligible uniqueness, such as a suitable nondeferrable primary or unique constraint or a nonpartial unique index. The child column remains nullable unless separately declared NOT NULL; a foreign key does not make every child relationship mandatory.

ADD CONSTRAINT … NOT VALID skips the initial scan of existing rows. It still checks new or changed references, so it can stop additional orphans from accumulating while old ones are investigated. NOT VALID does not mean that the rule is disabled. The database also does not yet assume that every historical row satisfies it.

VALIDATE CONSTRAINT scans the existing data and marks the constraint validated if the check succeeds. If a historical orphan remains, validation fails and the constraint stays unvalidated. Fix the data using the authoritative business relationship, then validate again. Inventing a parent or deleting a child merely to make the check green can corrupt the business record even when the resulting database is structurally consistent.

Separate the deployment phases

Adding a foreign key takes locks on both the referencing and referenced tables, even with NOT VALID. Avoid describing that option as zero-lock deployment. Its benefit is avoiding a potentially long initial validation scan while the add operation holds its locks. VALIDATE uses a weaker lock that permits ordinary data modifications, but still consumes resources and coordinates with other schema operations.

For a migration intended to shorten the first lock window, commit the short add phase before performing validation as a later operation. Wrapping both phases in one long transaction keeps earlier locks until the transaction ends and can defeat that purpose. The example below deliberately uses one transaction only because its entire test schema must be removed on rollback.

PostgreSQL does not automatically index the referencing columns. The parent’s uniqueness already has an index, but parent updates and deletions may need to find child references. Inspect existing child indexes and add a suitable access path when the workload needs it. A declared foreign key is not a guarantee of join elimination or fast cascades.

When to defer a check

A DEFERRABLE foreign key can postpone checking until transaction end. INITIALLY DEFERRED makes that the starting mode; SET CONSTRAINTS can switch eligible constraints back to immediate checking and retroactively check outstanding work. This supports inserting a child before its parent when both are established by the same transaction.

Deferral does not permit committing unresolved references. It changes the checking boundary, not the final invariant. NO ACTION can participate in deferred checking, whereas the RESTRICT delete action cannot be deferred. Cascading actions also are not postponed merely because the constraint is deferrable. Choose the action and checking mode together instead of treating DEFERRABLE as permission to ignore integrity temporarily without a completion condition.

When it breaks

A migration can appear successful because the add statement succeeded while convalidated remains false. Track successful validation as a separate completion condition. Conversely, a catalog flag alone is not proof of data integrity after privileged operations bypass enforcement. Historical imports and replication procedures must be evaluated under the checks they actually performed, not the constraint name they left behind.

Constraint errors can also reveal a valid policy working as designed. Diagnose the missing referenced key and the transaction’s ordering before weakening the rule. Parent and child operations can wait on concurrent transactions; bypassing the check to avoid waiting trades a coordination issue for possible data corruption.

Check it quickly

Save this as foreign-key-validation.sql and run psql -X -v ON_ERROR_STOP=1 -f foreign-key-validation.sql in a disposable PostgreSQL 18 database. It uses temporary tables and rolls back all changes.

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE customers (id integer PRIMARY KEY);
CREATE TEMP TABLE invoices (id integer PRIMARY KEY,customer_id integer);
INSERT INTO customers VALUES (1);
INSERT INTO invoices VALUES (10,1),(11,99);
ALTER TABLE invoices ADD CONSTRAINT invoices_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers NOT VALID;
SELECT convalidated FROM pg_constraint
WHERE conrelid='invoices'::regclass AND conname='invoices_customer_fk';
DO $$ BEGIN
  INSERT INTO invoices VALUES (12,99);
  RAISE EXCEPTION 'new orphan accepted';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'NOT VALID still checks new references';
END $$;
DO $$ BEGIN
  ALTER TABLE invoices VALIDATE CONSTRAINT invoices_customer_fk;
  RAISE EXCEPTION 'old orphan was not detected';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'validation found the old orphan';
END $$;
INSERT INTO customers VALUES (99);
ALTER TABLE invoices VALIDATE CONSTRAINT invoices_customer_fk;
SELECT convalidated FROM pg_constraint
WHERE conrelid='invoices'::regclass AND conname='invoices_customer_fk';
SELECT indexrelid::regclass AS child_indexes FROM pg_index WHERE indrelid='invoices'::regclass;
CREATE INDEX invoices_customer_lookup ON invoices (customer_id);
CREATE TEMP TABLE deferred_child (
  id integer PRIMARY KEY,
  customer_id integer REFERENCES customers DEFERRABLE INITIALLY DEFERRED
);
INSERT INTO deferred_child VALUES (20,7);
INSERT INTO customers VALUES (7);
SET CONSTRAINTS ALL IMMEDIATE;
DO $$ BEGIN
  IF (SELECT convalidated FROM pg_constraint
      WHERE conrelid='invoices'::regclass AND conname='invoices_customer_fk') IS DISTINCT FROM true THEN
    RAISE EXCEPTION 'constraint not validated';
  END IF;
  IF EXISTS (SELECT FROM invoices i LEFT JOIN customers c ON c.id=i.customer_id
             WHERE i.customer_id IS NOT NULL AND c.id IS NULL) THEN
    RAISE EXCEPTION 'orphan remains';
  END IF;
  IF (SELECT customer_id FROM deferred_child WHERE id=20) IS DISTINCT FROM 7 THEN
    RAISE EXCEPTION 'deferred insertion failed';
  END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 reported convalidated false after the add. A new reference to missing customer 99 failed, and validation independently detected the existing orphan. Restoring the fixture’s known missing parent allowed validation to report true. The index listing initially showed only invoices_pkey; the reference index was added explicitly.

The deferred example inserted child 20 before customer seven, then supplied that parent and successfully switched to immediate checking. Final assertions verify validation, absence of remaining invoice orphans, and the deferred relationship. These checks demonstrate a complete data repair and validation path, not just successful execution of a constraint declaration.

Sources