Foreign Key Violation Adding Constraint With Orphans
A schema migration or ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY statement fails with SQLSTATE 23503 and the message "violates foreign key...
In 10 seconds
A migration is supposed to add a foreign key that has been missing for months: orders.customer_id should reference customers(id). The ALTER runs fine in dev and staging, then fails the instant it hits production with ERROR: 23503: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" and DETAIL: Key (customer_id)=(1000001) is not present in table "customers". The production table accumulated orphan rows -- orders whose customer_id points at a customer that no longer exists (or never did) -- back when no foreign key was enforcing the relationship. ADD CONSTRAINT validates EVERY existing row against the parent before it will trust the constraint, so the first orphan it meets aborts the whole ALTER. This is the write/migration side of foreign keys: the companion problem to an unindexed foreign key, which bites on reads and deletes instead.
Why This Happens
A foreign key is a promise that every referencing value already exists in the parent. When you run ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY in its normal (validating) form, PostgreSQL will not <a class="sev1-termlink" href="https://thesev1database.com/glossary/tuple/">record that promise until it has proven it is true for the current contents of the table, so it scans the whole table and checks each row against the parent.
The moment it finds a row that violates the promise, it raises foreign_key_violation (SQLSTATE 23503) and rolls the ALTER back -- the constraint is never added. The DETAIL line reports only the FIRST offending key it happened to reach, which is why fixing that one value and re-running just surfaces the next orphan. The validating scan also takes an ACCESS EXCLUSIVE-style share-row-exclusive lock for its entire duration, so on a large table the failed migration both blocks writers and wastes the whole scan.
The orphans exist in the first place precisely because there was no foreign key enforcing the relationship while those rows were written. The orphans exist in the first place precisely because there was no foreign key enforcing the relationship while those rows were written.
Diagnose
Free Tier -- Basic Approach
The error names only one bad value. A single anti-join -- orders LEFT JOIN customers where the parent IS NULL -- counts every row that will block the constraint, so you know how dirty the data is before you touch it.
SELECT count(*) AS orphaned_orders FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL;
orphaned_orders
-----------------
5
(1 row)
Run the anti-join count against the table the migration failed on. orphaned_orders = 5 here; on a real table the number tells you whether this is a five-row cleanup or a thousands-row data problem.
Fix
The safe online pattern, in order: add the constraint NOT VALID (it enforces new writes at once and skips the up-front validating scan), confirm a fresh orphan write is already rejected, clean the existing orphans, then VALIDATE the constraint against the remaining rows.
ALTER TABLE orders ADD CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers(id) NOT VALID;
ALTER TABLE
INSERT INTO orders(customer_id, amount) VALUES (2000000, 1.00);
ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(2000000) is not present in table "customers".
DELETE FROM orders WHERE customer_id NOT IN (SELECT id FROM customers); ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_fkey;
DELETE 5 ALTER TABLE
ALTER TABLE orders ADD CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES customers(id) NOT VALID returns ALTER TABLE immediately. A new orphan INSERT (customer_id=2000000) is rejected with 23503 -- proof the constraint is enforced for new writes.
DELETE FROM orders WHERE customer_id NOT IN (SELECT id FROM customers) removes the 5 existing orphans (DELETE 5). ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_fkey then checks the surviving rows under a weaker lock and returns ALTER TABLE.
Verify
Healthy state is three facts together: zero orphans remain, the constraint reports convalidated = t (fully trusted, not just NOT VALID), and a brand-new orphan write is rejected -- the data is clean and the foreign key is enforced both ways.
SELECT count(*) AS orphaned_orders FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL;
orphaned_orders
-----------------
0
(1 row)
SELECT conname, convalidated FROM pg_constraint WHERE conrelid = 'orders'::regclass AND contype = 'f';
conname | convalidated -------------------------+-------------- orders_customer_id_fkey | t (1 row)
INSERT INTO orders(customer_id, amount) VALUES (3000000, 1.00);
ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(3000000) is not present in table "customers".
The anti-join now returns orphaned_orders = 0. pg_constraint shows orders_customer_id_fkey with convalidated = t. A fresh orphan INSERT (customer_id=3000000) is rejected with the foreign-key-violation error, confirming enforcement.
⏪ Rollback
Nothing to roll back from the failed ADD CONSTRAINT itself -- because the validating ALTER aborts, the constraint is never created and the table is left exactly as it was. The remediation does change data: the DELETE removes the orphan rows. If those orphan rows are business data you cannot lose, do not delete them blind -- instead repoint them at a real parent (UPDATE orders SET customer_id = <valid id> WHERE ...), or insert the missing customers, then VALIDATE. Run the anti-join audit first so you know exactly which rows and how much value are involved before choosing delete vs. repair. Once ADD CONSTRAINT ... NOT VALID has been applied, you can drop it again with ALTER TABLE orders DROP CONSTRAINT orders_customer_id_fkey if you need to back out.
📦 Version Matrix (PG 14--18)
Same mechanism on PostgreSQL 14, 15, 16, 17 and 18: with orphans present the validating ADD CONSTRAINT fails with "violates foreign key constraint"; after the orphans are deleted the identical ADD CONSTRAINT succeeds (ALTER TABLE).
| Version | Behavior | Version Notes |
|---|---|---|
PG 14.23 |
broken=violates foreign key constraint | ✗ Not available -- use stats_reset age |
PG 15.18 |
broken=violates foreign key constraint | ✗ Not available -- use stats_reset age |
PG 16.14 |
broken=violates foreign key constraint | ✗ Not available -- use stats_reset age |
PG 17.10 |
broken=violates foreign key constraint | ✗ Not available -- use stats_reset age |
PG 18.4 |
broken=violates foreign key constraint | ✗ Not available -- use stats_reset age |
⚠ Common Mistakes
Stop this incident before it pages you
The free runbook clears today's failure. Pro adds the standing guardrails -- the alert query, the dashboard, and the managed-service knobs -- so it never becomes an incident again.
- ✓ A ready-to-paste alert query with the right thresholds.
- ✓ The topology change that prevents recurrence.
- ✓ The exact knobs on RDS, Azure Flexible Server & Cloud SQL.
Frequently Asked Questions
Why does ADD CONSTRAINT scan the whole table at all -- can't it just enforce the rule going forward?
A foreign key is a guarantee about the table's entire current contents, not just future writes. The planner and other features rely on that guarantee, so PostgreSQL refuses to record a validated constraint it has not proven. The validating scan is how it proves it. If you only want forward enforcement for now, that is exactly what ADD CONSTRAINT ... NOT VALID gives you: new writes are checked immediately, the existing rows are checked later by VALIDATE CONSTRAINT.
The error only named customer_id=1000001. How do I find all of the bad rows?
Use the anti-join audit: SELECT count(*) FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL. The DETAIL line stops at the first violation it reaches, but the anti-join returns every orphan. In the lab there are five (1000001..1000005); on a real table list them all before deciding to delete or repair.
Is ADD CONSTRAINT ... NOT VALID safe to run on a busy table?
Much safer than the validating form. It records and enforces the constraint for new writes without scanning the existing rows, so it takes only a brief lock instead of a long one. You then run ALTER TABLE ... VALIDATE CONSTRAINT separately; VALIDATE scans the existing rows under a weaker (SHARE UPDATE EXCLUSIVE) lock that does not block normal reads and writes. Split into two steps, the migration stops being a long blocking operation.
Can I keep the orphan rows instead of deleting them?
Yes -- delete is just one remediation. If the orphans are real data, either insert the missing parent rows (so the references become valid) or repoint the orphans at an existing parent with an UPDATE. The constraint will only VALIDATE once no row points at a missing parent. Choose based on the premium blast-radius report, not on the convenience of a DELETE.
After I fix it, how do I make sure no foreign key is silently left half-done?
Query pg_constraint for foreign keys that are still NOT VALID: SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND NOT convalidated. A clean system returns 0. Wire that into CI or a periodic check so a NOT VALID constraint can never be forgotten in the un-validated state.