Missing Foreign Keys
Orphan rows and application-enforced integrity failures happen when *_id columns lack REFERENCES - the database cannot reject impossible relationships.
Recipe
Quick-reference recipe card - copy-paste ready.
-- Smell: _id column without FK
CREATE TABLE comments (
comment_id bigint PRIMARY KEY,
post_id bigint NOT NULL
);
-- Fix: add FK (after cleaning orphans)
DELETE FROM comments c
WHERE NOT EXISTS (SELECT 1 FROM posts p WHERE p.post_id = c.post_id);
ALTER TABLE comments
ADD CONSTRAINT comments_post_id_fkey
FOREIGN KEY (post_id) REFERENCES posts (post_id) ON DELETE CASCADE
NOT VALID;
ALTER TABLE comments VALIDATE CONSTRAINT comments_post_id_fkey;When to reach for this:
- New tables with
*_idsuffix columns. - Post-incident cleanup after application-only integrity failed.
- Data imports from CSV without referential checks.
Working Example
BEGIN;
CREATE TABLE authors (author_id bigint PRIMARY KEY, name text NOT NULL);
CREATE TABLE posts (
post_id bigint PRIMARY KEY,
author_id bigint NOT NULL REFERENCES authors (author_id)
);
-- Application-only child table (defect)
CREATE TABLE comments_bad (
comment_id bigint PRIMARY KEY,
post_id bigint NOT NULL
);
INSERT INTO posts VALUES (1, 1);
INSERT INTO comments_bad VALUES (100, 1);
INSERT INTO comments_bad VALUES (101, 999); -- orphan - no post 999
-- Detection
SELECT comment_id, post_id FROM comments_bad c
WHERE NOT EXISTS (SELECT 1 FROM posts p WHERE p.post_id = c.post_id);
-- Remediation path
DELETE FROM comments_bad WHERE post_id = 999;
ALTER TABLE comments_bad
ADD CONSTRAINT comments_bad_post_id_fkey
FOREIGN KEY (post_id) REFERENCES posts (post_id) ON DELETE CASCADE;
-- Prove enforcement
INSERT INTO comments_bad VALUES (102, 888); -- ERROR: violates FK
COMMIT;What this demonstrates:
- Orphan
post_id = 999inserted without error before FK. - Orphan query finds integrity violations in bulk.
- FK blocks new orphans at database boundary.
Deep Dive
How It Works
- FK constraints check parent existence on
INSERT/UPDATEof child. ON DELETEdefines parent removal behavior (CASCADE,RESTRICT,SET NULL).NOT VALIDadds FK without scanning entire table immediately - thenVALIDATE CONSTRAINTscans with weaker lock in PG 18 workflows.- Application validation misses admin SQL, race conditions, and alternate code paths.
ON DELETE Guide
| Action | Use when |
|---|---|
CASCADE | Child meaningless without parent (comments on post) |
RESTRICT | Prevent parent delete if children exist (invoice lines) |
SET NULL | Optional relationship, preserve child history |
NO ACTION | Default - defer check to transaction end |
SQL Notes
-- Find all columns named %_id without FK (heuristic audit)
SELECT
c.table_schema, c.table_name, c.column_name
FROM information_schema.columns c
LEFT JOIN information_schema.key_column_usage k
ON k.table_schema = c.table_schema
AND k.table_name = c.table_name
AND k.column_name = c.column_name
WHERE c.column_name LIKE '%\_id' ESCAPE '\'
AND c.table_schema = 'public';
-- Manual review required - not all _id columns are FKsGotchas
- FK "for performance" removed - orphans return, joins return garbage rows. Fix: index FK columns; keep constraint.
- Soft-delete parents without FK policy - children point at
deletedposts. Fix:RESTRICTor sync soft-delete in app with FK to active view. - Bulk import disables triggers/FKs -
COPYloads orphans. Fix: load staging, validate, insert with FKs enabled. - Cross-database references - FK impossible across databases. Fix: async validation job or same database.
- Polymorphic associations -
commentable_id+commentable_typecannot be single FK. Fix: separate tables or check constraints per type.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| FK constraints | Default OLTP | Sharded cross-shard references |
EXCLUDE / CHECK | Complex rules | Simple parent-child existence |
| Application-only | Throwaway prototype | Production revenue data |
| Periodic orphan job | Legacy debt remediation | Greenfield - add FK immediately |
FAQs
Do foreign keys slow down writes?
Small overhead per insert - usually dominated by index maintenance you need anyway. Missing FKs cost more in bad data and incident time.
Should I index every FK column?
Yes for join and cascade performance. PostgreSQL does not auto-index FK columns.
How do I add FK to billion-row table?
ADD CONSTRAINT ... NOT VALID then VALIDATE CONSTRAINT in maintenance window; clean orphans first.
Can FKs reference non-primary keys?
Yes - must reference UNIQUE or PRIMARY KEY columns. UNIQUE (tenant_id, project_id) supports composite FKs.
What about circular FKs?
Use deferred constraints or insert parents in same transaction with placeholder ordering - rare, design carefully.
Do FKs work on partitioned tables?
Yes in PostgreSQL 18 with partitioned parents/children - plan key design including partition columns.
Should I use ON DELETE CASCADE everywhere?
No - cascades can surprise operators deleting one parent row and wiping thousands of children. Choose explicitly.
How do multi-tenant FKs work?
Include tenant_id in child and parent keys: FOREIGN KEY (tenant_id, document_id) REFERENCES documents (tenant_id, document_id).
Can I trust ORM associations without FK?
No - ORM cannot protect against SQL outside the app. Database FK is the contract of record.
How do I lint missing FKs in CI?
Parse migrations for new *_id columns; require matching REFERENCES or explicit waiver comment in PR template.
Related
- Entity-Relationship Modeling - relationship design
- Defect Scenarios Basics - orphan detection
- Primary and Foreign Keys - FK mechanics
- Defect Scenarios Best Practices - lint rules
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.