Filter concepts by levelShowing all levels.

PostgreSQL · Section 10

Primary Keys, Foreign Keys and Relationship Design

Level
intermediate
Read
34 min
Concepts
6

Referential integrity as a mechanically enforced guarantee rather than a naming convention, where the foreign key belongs for each relationship shape (and the UNIQUE constraint one-to-one actually needs), junction tables as genuine entities once they carry their own attributes, composite foreign keys for referencing a composite key as one unit, and the operational discipline of tracing a CASCADE chain's true blast radius before it deletes more than intended.

PostgreSQL overview

What is true here

  1. A plain integer column only represents a relationship by convention; FOREIGN KEY enforces it on every write.
  2. One-to-many needs a plain foreign key; one-to-one needs that same key plus UNIQUE, or it silently becomes one-to-many.
  3. A junction table carrying its own attributes is a real entity, not just plumbing between two other tables.
  4. A composite foreign key must match the referenced composite key as one unit, never independently per column.
  5. CASCADE composes across every level of a foreign key chain — its true blast radius is invisible from any single table.

What you will be able to do

  • Explain why a foreign key constraint is a mechanically enforced guarantee, not documentation
  • Model one-to-one, one-to-many and many-to-many relationships with the correct constraints
  • Recognize when a junction table has become a real entity warranting its own identity
  • Design a composite foreign key referencing a composite key correctly
  • Audit a schema's CASCADE chains for their true production blast radius before deleting
From a guarantee to an auditable production risk

Referential integrity

a foreign key always points somewhere real

Relationship shapes

1:1, 1:many, many:many — where the key goes

Referential action

CASCADE/RESTRICT/SET NULL — what happens on delete

Production audit

trace the true blast radius before it happens

  • Referential integrity — a foreign key always points somewhere real
    • leads to Relationship shapes
  • Relationship shapes — 1:1, 1:many, many:many — where the key goes
    • leads to Referential action
  • Referential action — CASCADE/RESTRICT/SET NULL — what happens on delete
    • leads to Production audit
  • Production audit — trace the true blast radius before it happens

The core guarantee

What referential integrity actually means, and where the foreign key goes per relationship shape.

Understanding Referential Integrity

corebeginner

Referential integrity is the guarantee that a foreign key value always points to a row that genuinely exists. PostgreSQL enforces it automatically for any column declared REFERENCES another table — an INSERT or UPDATE that would point at a nonexistent row is rejected outright.

Think of it as

Without referential integrity, a column merely "looks like" a reference — nothing stops it from holding a value that used to be valid but no longer corresponds to any real row, an orphaned pointer into nothing. A FOREIGN KEY constraint converts that hopeful convention into an enforced guarantee: every value in the column is checked against the referenced table on every write, and PostgreSQL simply refuses any write that would break the guarantee, rather than allowing a dangling reference to be created in the first place.

sql
CREATE TABLE customers (id SERIAL PRIMARY KEY);
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id));

What we're doing: Compare a plain integer column against a real foreign key by attempting to reference a nonexistent customer in both.

referential_integrity_demo.sqlsql
CREATE TABLE customers (id SERIAL PRIMARY KEY);
CREATE TABLE orders_unsafe (id SERIAL PRIMARY KEY, customer_id INT);
CREATE TABLE orders_safe (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id));

INSERT INTO customers DEFAULT VALUES;

INSERT INTO orders_unsafe (customer_id) VALUES (999);
-- succeeds -- nothing checks that customer 999 exists

INSERT INTO orders_safe (customer_id) VALUES (999);
-- rejected -- customer 999 does not exist
2
orders_unsafe.customer_id is a plain INT — it "means" a customer reference by convention only.
3
orders_safe.customer_id has a real FOREIGN KEY — PostgreSQL enforces the reference.
7–8
Customer 999 does not exist, but orders_unsafe has no way to know or care.
10–11
The same insert against orders_safe is rejected outright — an orphaned reference is structurally prevented.
Output
INSERT 0 1

ERROR:  insert or update on table "orders_safe" violates foreign key constraint
DETAIL:  Key (customer_id)=(999) is not present in table "customers".

Why this works: orders_unsafe.customer_id is just an ordinary integer as far as PostgreSQL is concerned — nothing about its declaration links it to customers, so a value of 999 is accepted exactly like any other integer, regardless of whether a customer with that id exists. orders_safe.customer_id carries an actual FOREIGN KEY constraint, which PostgreSQL checks on every write by looking up the value in customers — this is the mechanical difference between a column that merely represents a relationship by convention and one where the relationship is genuinely enforced.

Using a plain integer column to represent a relationship instead of a real foreign key

Wrong

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT);
-- "customer_id" by naming convention only -- nothing enforces it actually references a real customer

Better

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id));
-- the relationship is now a guarantee, not a convention

What you see: Orphaned rows accumulate over time — orders referencing customers that were deleted through some code path that did not check first — discovered only when a report or join unexpectedly produces missing or NULL customer data.

Why: A column named customer_id with no FOREIGN KEY constraint communicates intent to human readers but enforces nothing — any bug, race condition, or overlooked code path that deletes a customer without also handling their orders produces silent data corruption that no error ever surfaces. A real FOREIGN KEY constraint moves that guarantee from "hopefully true, if every code path remembers" to "mechanically enforced by the database, from any code path," the same principle every earlier constraint concept in this course has established.

A plain integer column vs an enforced foreign key

customer_id INT (no constraint)

  • +nothing verifies the value refers to a real customer
  • +a deleted customer silently orphans every order that "referenced" them
  • +correctness depends entirely on application code remembering to check

customer_id INT REFERENCES customers(id)

  • every INSERT/UPDATE is checked against customers
  • deleting a referenced customer is blocked (or cascaded/nulled, per ON DELETE)
  • correctness is guaranteed by PostgreSQL itself
  • customer_id INT (no constraint)
    • nothing verifies the value refers to a real customer
    • a deleted customer silently orphans every order that "referenced" them
    • correctness depends entirely on application code remembering to check
  • customer_id INT REFERENCES customers(id)
    • every INSERT/UPDATE is checked against customers
    • deleting a referenced customer is blocked (or cascaded/nulled, per ON DELETE)
    • correctness is guaranteed by PostgreSQL itself

With and without an enforced foreign key

With and without an enforced foreign key
Propertyplain INT columnINT REFERENCES parent(id)
Can reference a nonexistent row?yes, silentlyno — rejected at write time
Deleting the referenced roworphans the child silentlyblocked, cascaded, or nulled — per ON DELETE
Who enforces correctness?application code, if it remembers toPostgreSQL itself, on every write

Together

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id));

Remember: Referential integrity is the guarantee that a foreign key value always points to a row that genuinely exists — a plain integer column only represents a relationship by convention; a real FOREIGN KEY constraint enforces it on every write.

See also: one to one one to many many to many · primary foreign unique not null check

Modeling One-to-One, One-to-Many and Many-to-Many

standardintermediate

One-to-one: a foreign key with an added UNIQUE constraint, on either table. One-to-many: a plain foreign key on the "many" side. Many-to-many: neither table can hold it alone — it needs a junction table with a foreign key to each side.

Think of it as

The design question is always "where does the foreign key column go?" One-to-many has an obvious answer: the "many" side, since each of its rows points at exactly one "one" side row. Many-to-many has no valid answer on either original table — a single column can only hold one value, so it cannot represent "this row relates to several rows on the other side." A junction table exists specifically to hold the relationship itself as its own set of rows, one per pair.

sql
-- one-to-one
CREATE TABLE user_profiles (user_id INT PRIMARY KEY REFERENCES users(id), bio TEXT);

-- one-to-many
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id));

-- many-to-many
CREATE TABLE enrollments (
    student_id INT REFERENCES students(id),
    course_id INT REFERENCES courses(id),
    PRIMARY KEY (student_id, course_id)
);

What we're doing: Show the difference UNIQUE makes on a one-to-one foreign key — without it, a second profile per user is silently allowed.

one_to_one_demo.sqlsql
CREATE TABLE users (id SERIAL PRIMARY KEY);
CREATE TABLE profiles_no_unique (user_id INT REFERENCES users(id), bio TEXT);
CREATE TABLE profiles_unique (user_id INT UNIQUE REFERENCES users(id), bio TEXT);

INSERT INTO users DEFAULT VALUES;

INSERT INTO profiles_no_unique (user_id, bio) VALUES (1, 'first bio'), (1, 'second bio');
-- succeeds -- accidentally one-to-many, since nothing enforces one profile per user

INSERT INTO profiles_unique (user_id, bio) VALUES (1, 'first bio');
INSERT INTO profiles_unique (user_id, bio) VALUES (1, 'second bio');
-- rejected -- UNIQUE enforces genuinely one-to-one
2
profiles_no_unique has a plain foreign key — nothing stops a user from getting a second profile row.
7–8
Two profile rows for user 1 are silently accepted — a schema bug, since the intent was one-to-one.
9–11
profiles_unique adds UNIQUE — the second insert correctly fails, enforcing the one-to-one intent.
Output
INSERT 0 2

INSERT 0 1
ERROR:  duplicate key value violates unique constraint "profiles_unique_user_id_key"

Why this works: A plain foreign key only guarantees the referenced row exists — it says nothing about how many times a given value can appear in the column, which is exactly the same fact a one-to-many relationship also relies on. UNIQUE is the piece of the puzzle that actually narrows "many rows can point at this user" down to "at most one row can point at this user," which is what a genuine one-to-one relationship requires — without it, a schema that looks one-to-one on a diagram is actually one-to-many underneath.

Declaring a one-to-one relationship without the UNIQUE constraint that actually makes it one

Wrong

sql
CREATE TABLE profiles (user_id INT REFERENCES users(id), bio TEXT);
-- "one-to-one" only in the schema diagram and the developer's intent -- not enforced

Better

sql
CREATE TABLE profiles (user_id INT UNIQUE REFERENCES users(id), bio TEXT);
-- or, even more directly, make user_id the primary key itself:
CREATE TABLE profiles (user_id INT PRIMARY KEY REFERENCES users(id), bio TEXT);

What you see: A feature relying on "each user has exactly one profile" (like a profile edit form that assumes a single row) breaks unpredictably once a duplicate profile row is created through some code path that was never blocked from doing so.

Why: FOREIGN KEY alone constrains only what a value can reference, never how many rows can share that value — a one-to-one relationship needs an explicit UNIQUE (or making the foreign key column itself the primary key) to enforce the "one" half of "one-to-one." Without it, the relationship is really one-to-many with an application-level assumption bolted on top, and that assumption is exactly the kind of invariant this course has repeatedly shown belongs in the schema instead.

Where the foreign key lives, per relationship shape

Where the foreign key lives, per relationship shape
ShapeForeign key locationExtra constraint needed
One-to-oneeither table (pick one)UNIQUE on the foreign key column
One-to-manythe "many" sidenone — plain foreign key is enough
Many-to-manya new junction tablecomposite PRIMARY KEY on both foreign keys together

Together

sql
CREATE TABLE user_profiles (user_id INT UNIQUE REFERENCES users(id), bio TEXT);
-- UNIQUE makes this genuinely one-to-one, not one-to-many

Remember: One-to-many needs only a plain foreign key on the "many" side. One-to-one needs that same foreign key PLUS a UNIQUE constraint — without it, a "one-to-one" relationship is actually one-to-many. Many-to-many always needs a junction table, since neither original table can hold a multi-valued reference in one column.

See also: referential integrity · junction tables for many to many

Advertisement

Junction tables and composite keys

When a many-to-many link becomes a real entity, and referencing a composite key as one unit.

Using Junction Tables for Many-to-Many Relationships

standardintermediate

A junction table's minimum shape is two foreign keys, one to each related table, usually forming a composite primary key together. Once the relationship itself has its own attributes — an enrollment date, a role, a quantity — the junction table becomes a real entity in its own right, sometimes warranting its own surrogate key.

Think of it as

A junction table is not just plumbing — once it needs to carry data about the relationship itself (when did this happen, in what role, how many), it stops being a pure association and becomes a genuine entity. The signal to add a surrogate key of its own is the same signal that would apply to any table: does this row need to be referenced individually, from somewhere else, or does the composite key of its two foreign keys always suffice as the way to find it.

sql
CREATE TABLE enrollments (
    id SERIAL PRIMARY KEY,
    student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
    course_id INT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
    grade TEXT,
    UNIQUE (student_id, course_id)
);

What we're doing: Build a junction table that carries its own attribute (a grade) and its own surrogate key, since a grade is naturally attached to one specific enrollment.

attributed_junction.sqlsql
CREATE TABLE students (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE courses (id SERIAL PRIMARY KEY, title TEXT);
CREATE TABLE enrollments (
    id SERIAL PRIMARY KEY,
    student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
    course_id INT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
    grade TEXT,
    UNIQUE (student_id, course_id)
);

INSERT INTO students (name) VALUES ('Ada');
INSERT INTO courses (title) VALUES ('Databases');
INSERT INTO enrollments (student_id, course_id) VALUES (1, 1);

UPDATE enrollments SET grade = 'A' WHERE student_id = 1 AND course_id = 1;

SELECT s.name, c.title, e.grade FROM enrollments e
JOIN students s ON s.id = e.student_id
JOIN courses c ON c.id = e.course_id;
7
grade belongs to the enrollment itself, not to the student or the course — it only makes sense in the context of this specific pairing.
8
UNIQUE (student_id, course_id) still enforces "one enrollment per student per course," even though id is now the primary key.
Output
name | title     | grade
-----+-----------+------
Ada  | Databases | A
(1 row)

Why this works: grade genuinely belongs to the (student, course) pair — it is not a fact about Ada in general (she may have different grades in different courses) nor about the Databases course in general (different students earn different grades) — which is exactly the sign that the junction table has become a real entity representing "this specific enrollment," not just a link. Giving it its own id makes sense once other things might need to reference one specific enrollment directly (a grade-change audit log entry, for instance), while UNIQUE (student_id, course_id) is retained specifically to preserve the original many-to-many constraint that only one enrollment exists per pair.

Storing relationship-specific data on one of the original tables instead of the junction table

Wrong

sql
ALTER TABLE students ADD COLUMN current_grade TEXT;
-- which course is this grade for? a student can be enrolled in several -- this column cannot say

Better

sql
ALTER TABLE enrollments ADD COLUMN grade TEXT;
-- correctly scoped to one specific (student, course) pairing

What you see: A student enrolled in multiple courses can only have one current_grade value stored on their row, with no way to represent "an A in Databases and a B in Algorithms" at the same time.

Why: A column added directly to students can only hold one value per student, which is exactly the same structural limitation that made a many-to-many relationship impossible to model with a single foreign key column in the first place — grade is data about the relationship between a specific student and a specific course, so it belongs on the table that represents that specific relationship, which is the junction table.

Pure association vs a junction table with its own attributes

Pure association vs a junction table with its own attributes
ShapePrimary keyWhen it applies
Pure associationcomposite (student_id, course_id)the relationship itself carries no extra data
Attributed relationshipcomposite, or its own surrogate idthe relationship has data like enrolled_at, grade, role

Together

sql
CREATE TABLE enrollments (
    id SERIAL PRIMARY KEY,
    student_id INT REFERENCES students(id) ON DELETE CASCADE,
    course_id INT REFERENCES courses(id) ON DELETE CASCADE,
    enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (student_id, course_id)
);

Remember: A pure association junction table needs only its two foreign keys as a composite primary key; once the relationship itself carries data (a grade, a role, a quantity), the junction table is a real entity and may warrant its own surrogate key, with UNIQUE preserving the original pairing constraint.

See also: one to one one to many many to many · cardinality and relationships

Understanding Composite Foreign Keys

standardadvanced

A composite foreign key references a composite primary or unique key on another table — the referencing table must supply matching values for every column in the combination, not just one. It is needed whenever the table being referenced can only be uniquely identified by more than one column together.

Think of it as

A foreign key must always reference something the target table guarantees is unique — normally a single-column primary key. When the table being referenced only has a composite key (like enrollments' (student_id, course_id)), any table that needs to point at a specific enrollment has no single column to reference; it must supply the same combination of columns, declared together as one composite foreign key, mirroring the shape of the composite key it points at.

sql
CREATE TABLE enrollments (
    student_id INT,
    course_id INT,
    PRIMARY KEY (student_id, course_id)
);

CREATE TABLE enrollment_notes (
    id SERIAL PRIMARY KEY,
    student_id INT,
    course_id INT,
    note TEXT,
    FOREIGN KEY (student_id, course_id) REFERENCES enrollments(student_id, course_id)
);

What we're doing: Reference a junction table's composite primary key from a third table, and confirm PostgreSQL requires both columns to match together, not independently.

composite_fk_demo.sqlsql
CREATE TABLE enrollments (
    student_id INT,
    course_id INT,
    PRIMARY KEY (student_id, course_id)
);
CREATE TABLE enrollment_notes (
    id SERIAL PRIMARY KEY,
    student_id INT,
    course_id INT,
    note TEXT,
    FOREIGN KEY (student_id, course_id) REFERENCES enrollments(student_id, course_id)
);

INSERT INTO enrollments VALUES (1, 100);

INSERT INTO enrollment_notes (student_id, course_id, note) VALUES (1, 100, 'Great progress');
-- succeeds -- (1, 100) exists as a pair in enrollments

INSERT INTO enrollment_notes (student_id, course_id, note) VALUES (1, 999, 'Bad reference');
-- fails -- student 1 exists, but not paired with course 999 in enrollments
1–5
enrollments has no single-column identity — the pair (student_id, course_id) together is the only unique key.
6–12
enrollment_notes references that pair together, not either column independently.
18–19
Student 1 genuinely exists in enrollments (paired with course 100), but not paired with course 999 — the composite check fails on the combination.
Output
INSERT 0 1

ERROR:  insert or update on table "enrollment_notes" violates foreign key constraint
DETAIL:  Key (student_id, course_id)=(1, 999) is not present in table "enrollments".

Why this works: The composite foreign key checks the pair (student_id, course_id) as one unit against enrollments — it does not separately verify "does student_id=1 exist somewhere in enrollments" and "does course_id=999 exist somewhere in enrollments," which would incorrectly pass since both values individually do appear in the table. Requiring the exact combination to match is precisely why a composite foreign key is necessary whenever the referenced table's uniqueness genuinely depends on more than one column together — a single-column foreign key to either column alone could not express this check at all.

Referencing only one column of a composite key, hoping it is unique enough

Wrong

sql
CREATE TABLE enrollment_notes (student_id INT REFERENCES enrollments(student_id), course_id INT);
-- ERROR: there is no unique constraint matching given keys for referenced table "enrollments"

Better

sql
CREATE TABLE enrollment_notes (
    student_id INT, course_id INT,
    FOREIGN KEY (student_id, course_id) REFERENCES enrollments(student_id, course_id)
);

What you see: Attempting to create a foreign key against just one column of a table whose primary key is composite fails immediately at CREATE TABLE / ALTER TABLE time, with an error about no matching unique constraint.

Why: PostgreSQL requires a foreign key to reference a column set that the target table actually guarantees is unique — student_id alone is not unique in enrollments (a student can have many enrollment rows, one per course), so no single-column foreign key to it is possible. The composite foreign key must mirror the composite primary key's exact column set for PostgreSQL to have a genuine uniqueness guarantee to check against.

Single-column vs composite foreign key

Single-column vs composite foreign key
Referenced table's keyForeign key shape
PRIMARY KEY (id)FOREIGN KEY (parent_id) REFERENCES parent(id)
PRIMARY KEY (student_id, course_id)FOREIGN KEY (student_id, course_id) REFERENCES enrollments(student_id, course_id)

Together

sql
CREATE TABLE enrollment_notes (
    student_id INT,
    course_id INT,
    note TEXT,
    FOREIGN KEY (student_id, course_id) REFERENCES enrollments(student_id, course_id)
);

Remember: A composite foreign key references a composite primary/unique key as one unit — the referencing table must supply the exact combination, not match either column independently, and the referenced column set must genuinely be unique on the target table.

See also: junction tables for many to many · composite keys and unique constraints

Advertisement

Referential actions in production

What CASCADE/RESTRICT/SET NULL actually cost operationally, and how to audit the real blast radius.

ON DELETE Options and Their Operational Impact

standardintermediate

The operational question behind ON DELETE is: "if this parent row disappears in production, what should actually happen to the data that depended on it?" CASCADE can silently delete far more than expected if the relationship chain is deep. RESTRICT blocks the delete outright, which is safe but can surprise an operator trying to clean something up. SET NULL and SET DEFAULT preserve the child row but change what it means.

Think of it as

Choosing an ON DELETE action is choosing what story the data tells after a deletion happens — and that choice has real production consequences beyond the schema diagram. CASCADE chained across several tables (orders → order_items → item_refunds) means one DELETE FROM orders can remove far more rows than an operator typing it expects, with no confirmation step. RESTRICT is the conservative default precisely because it forces a human (or application logic) to explicitly decide what to do with dependents before a delete can proceed at all.

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY);
CREATE TABLE order_items (id SERIAL PRIMARY KEY, order_id INT REFERENCES orders(id) ON DELETE CASCADE);
CREATE TABLE item_refunds (id SERIAL PRIMARY KEY, order_item_id INT REFERENCES order_items(id) ON DELETE CASCADE);

What we're doing: Build a two-level CASCADE chain and delete the top-level parent, to make the non-obvious blast radius concrete.

cascade_chain.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY);
CREATE TABLE order_items (id SERIAL PRIMARY KEY, order_id INT REFERENCES orders(id) ON DELETE CASCADE);
CREATE TABLE item_refunds (id SERIAL PRIMARY KEY, order_item_id INT REFERENCES order_items(id) ON DELETE CASCADE);

INSERT INTO orders DEFAULT VALUES;
INSERT INTO order_items (order_id) VALUES (1), (1);
INSERT INTO item_refunds (order_item_id) VALUES (1), (2);

SELECT (SELECT count(*) FROM order_items) AS items_before,
       (SELECT count(*) FROM item_refunds) AS refunds_before;

DELETE FROM orders WHERE id = 1;

SELECT (SELECT count(*) FROM order_items) AS items_after,
       (SELECT count(*) FROM item_refunds) AS refunds_after;
1–3
A 3-level chain: orders → order_items → item_refunds, CASCADE at each level.
12
Deleting exactly one order row — nothing here mentions order_items or item_refunds directly.
Output
items_before | refunds_before
--------------+----------------
            2 |              2

items_after | refunds_after
-------------+---------------
           0 |             0

Why this works: Deleting the one order row cascades to its two order_items rows, and each of those cascades again to its own item_refunds rows — a single DELETE FROM orders WHERE id = 1 statement removed 5 rows total across 3 tables, with no indication of that scope anywhere in the statement itself. This is the concrete operational risk CASCADE carries: the actual blast radius of a delete depends on the full chain of foreign keys referencing (directly or transitively) the table being deleted from, which is not visible by reading any single table's own definition.

Adding ON DELETE CASCADE without tracing the full downstream chain first

Wrong

sql
ALTER TABLE order_items ADD CONSTRAINT fk_order
    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE;
-- added without checking what else references order_items with its own CASCADE

Better

sql
-- before adding CASCADE, trace every table referencing order_items (and anything referencing THOSE):
SELECT conrelid::regclass, confrelid::regclass, confdeltype
FROM pg_constraint WHERE confrelid = 'order_items'::regclass;
-- then decide CASCADE is genuinely appropriate at every level, not just this one

What you see: A routine "cancel and delete this order" operation, tested only against a simple order with no complications, unexpectedly deletes refund records, audit trail rows, or other downstream data in production once a more complex order (with items, refunds, and whatever else references those) hits the same code path.

Why: CASCADE composes across every level of foreign key that also specifies it, so its true effect is a property of the entire schema's reference graph, not of the one constraint being added — a chain that looked safe with 2 levels can silently grow to 4 or 5 as the schema evolves, with no single point where someone reviews the combined blast radius. Querying pg_constraint (or an equivalent schema-introspection approach) to trace the full downstream chain before relying on CASCADE is the practical way to make that otherwise-invisible scope visible before it deletes something unintended in production.

Operational risk profile of each ON DELETE option

Operational risk profile of each ON DELETE option
OptionOperational risk
CASCADEcan silently delete a large, non-obvious blast radius across chained tables
RESTRICT / NO ACTIONblocks the delete outright — safe, but can surprise an operator mid-cleanup
SET NULLchild survives but loses its reference — later queries must handle the NULL
SET DEFAULTchild survives but silently reassigns to a fallback — can misattribute data if not carefully chosen

Together

sql
-- a 3-level CASCADE chain: deleting one order can remove dozens of rows across two other tables
CREATE TABLE order_items (order_id INT REFERENCES orders(id) ON DELETE CASCADE);
CREATE TABLE item_refunds (order_item_id INT REFERENCES order_items(id) ON DELETE CASCADE);

Remember: ON DELETE CASCADE composes across every level of a foreign key chain — its true blast radius is a property of the whole schema's reference graph, not just the one constraint being written, and is not visible from any single table's definition.

See also: on delete and on update · detecting dangerous cascading deletes

Detecting Dangerous Cascading Deletes in Production Schemas

coreadvanced

PostgreSQL's system catalog pg_constraint records every foreign key and its confdeltype (the ON DELETE action). A recursive query over it can trace the full CASCADE chain starting from any table — revealing the true blast radius of deleting from that table before it happens in production, not after.

Think of it as

A single table's CREATE TABLE statement only shows constraints declared ON that table — it cannot show what elsewhere references it. Auditing for dangerous cascades means asking the reverse question for every table: "what points at me, and does IT cascade too, and what points at THAT?" — exactly the shape a recursive query over pg_constraint answers, mirroring the same base-case-plus-recursive-term structure a recursive CTE uses to walk any other hierarchy.

sql
WITH RECURSIVE cascade_chain AS (
    SELECT conrelid::regclass AS child, confrelid::regclass AS parent, confdeltype
    FROM pg_constraint
    WHERE contype = 'f' AND confrelid = 'orders'::regclass AND confdeltype = 'c'
    UNION ALL
    SELECT c.conrelid::regclass, c.confrelid::regclass, c.confdeltype
    FROM pg_constraint c
    JOIN cascade_chain cc ON c.confrelid = cc.child
    WHERE c.contype = 'f' AND c.confdeltype = 'c'
)
SELECT * FROM cascade_chain;

What we're doing: Run a recursive audit query against the 3-level CASCADE chain from the previous concept, revealing the full downstream impact of deleting from orders before actually deleting anything.

cascade_audit.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY);
CREATE TABLE order_items (id SERIAL PRIMARY KEY, order_id INT REFERENCES orders(id) ON DELETE CASCADE);
CREATE TABLE item_refunds (id SERIAL PRIMARY KEY, order_item_id INT REFERENCES order_items(id) ON DELETE CASCADE);

WITH RECURSIVE cascade_chain AS (
    SELECT conrelid::regclass AS child, confrelid::regclass AS parent
    FROM pg_constraint
    WHERE contype = 'f' AND confrelid = 'orders'::regclass AND confdeltype = 'c'
    UNION ALL
    SELECT c.conrelid::regclass, c.confrelid::regclass
    FROM pg_constraint c
    JOIN cascade_chain cc ON c.confrelid = cc.child
    WHERE c.contype = 'f' AND c.confdeltype = 'c'
)
SELECT * FROM cascade_chain;
5–8
Base case: which tables directly CASCADE from orders — order_items.
9–13
Recursive term: which tables CASCADE from anything already found — item_refunds, found via order_items.
14
The full result is the complete downstream impact of a DELETE FROM orders, computed WITHOUT actually deleting anything.
Output
child          | parent
---------------+-------------
order_items    | orders
item_refunds   | order_items
(2 rows)

Why this works: This is structurally the same recursive-CTE pattern used to walk any hierarchy (an org chart, a category tree) — the base case finds direct children of orders with CASCADE, and the recursive term repeatedly finds children of whatever the previous round found, terminating naturally once a round adds nothing new, exactly like the earlier org-chart example. Running this before a production delete turns "we discovered the blast radius by watching what got deleted" into "we knew the blast radius in advance and decided it was correct" — a genuinely different, much safer operational posture.

Trusting a schema diagram or ORM model instead of querying pg_constraint directly

Wrong

sql
-- relying on a schema diagram tool, or an ORM's model definitions, to represent the true CASCADE chain
-- diagrams and ORM models can drift out of sync with the actual database schema over time

Better

sql
WITH RECURSIVE cascade_chain AS (...)
SELECT * FROM cascade_chain;
-- queries the database's own live catalog -- cannot drift, since it IS the actual enforced schema

What you see: A production incident traces back to a CASCADE delete that a schema diagram or ORM model did not show, because a migration added or changed an ON DELETE action directly in SQL without updating the diagram or regenerating the model.

Why: A diagram or ORM model is a representation of the schema, generated or maintained separately from the schema itself, and any manual SQL migration (or a migration tool that does not perfectly round-trip every constraint option) can cause it to drift out of sync with what the database actually enforces. pg_constraint is the database's own live, authoritative record of every constraint's real behavior — querying it directly is the only way to be certain the audit reflects reality rather than a possibly-stale representation of it.

Auditing a CASCADE chain before it deletes something in production

pg_constraint

every FK + its ON DELETE action

Recursive trace

follow confrelid downward, level by level

Full CASCADE chain

every table transitively affected

Decide before deleting

is this blast radius actually intended?

  • pg_constraint — every FK + its ON DELETE action
    • leads to Recursive trace
  • Recursive trace — follow confrelid downward, level by level
    • leads to Full CASCADE chain
  • Full CASCADE chain — every table transitively affected
    • leads to Decide before deleting
  • Decide before deleting — is this blast radius actually intended?

pg_constraint columns relevant to a CASCADE audit

pg_constraint columns relevant to a CASCADE audit
ColumnMeaning
conrelidthe table the constraint is defined ON (the child)
confrelidthe table being referenced (the parent)
confdeltypethe ON DELETE action — 'c' means CASCADE

Together

sql
SELECT conrelid::regclass AS child, confrelid::regclass AS parent, confdeltype
FROM pg_constraint WHERE contype = 'f';

Remember: A single table's definition cannot show what references it — trace the true CASCADE blast radius with a recursive query over pg_constraint (following confrelid, filtering confdeltype = 'c'), the same recursive pattern used to walk any hierarchy, before relying on a possibly-stale schema diagram.

See also: on delete cascade restrict set null set default · recursive ctes

Advertisement