Missing Foreign Keys
Orphan rows and application-enforced integrity failures happen when *_id columns lack REFERENCES - the database cannot reject impossible relationships.
Search across all documentation pages
Orphan rows and application-enforced integrity failures happen when *_id columns lack REFERENCES - the database cannot reject impossible relationships.
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:
*_id suffix columns.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:
post_id = 999 inserted without error before FK.INSERT/UPDATE of child.ON DELETE defines parent removal behavior (CASCADE, RESTRICT, SET NULL).NOT VALID adds FK without scanning entire table immediately - then VALIDATE CONSTRAINT scans with weaker lock in PG 18 workflows.| 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 |
-- 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 FKsdeleted posts. Fix: RESTRICT or sync soft-delete in app with FK to active view.COPY loads orphans. Fix: load staging, validate, insert with FKs enabled.commentable_id + commentable_type cannot be single FK. Fix: separate tables or check constraints per type.| 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 |
Small overhead per insert - usually dominated by index maintenance you need anyway. Missing FKs cost more in bad data and incident time.
Yes for join and cascade performance. PostgreSQL does not auto-index FK columns.
ADD CONSTRAINT ... NOT VALID then VALIDATE CONSTRAINT in maintenance window; clean orphans first.
Yes - must reference UNIQUE or PRIMARY KEY columns. UNIQUE (tenant_id, project_id) supports composite FKs.
Use deferred constraints or insert parents in same transaction with placeholder ordering - rare, design carefully.
Yes in PostgreSQL 18 with partitioned parents/children - plan key design including partition columns.
No - cascades can surprise operators deleting one parent row and wiping thousands of children. Choose explicitly.
Include tenant_id in child and parent keys: FOREIGN KEY (tenant_id, document_id) REFERENCES documents (tenant_id, document_id).
No - ORM cannot protect against SQL outside the app. Database FK is the contract of record.
Parse migrations for new *_id columns; require matching REFERENCES or explicit waiver comment in PR template.
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+.
Reviewed by Chris St. John·Last updated Jul 18, 2026