Engineering Notes
Home/Databases/Modeling/Relationships
Databases · Modeling

Relationships

One-to-many, one-to-one, many-to-many and self-references, implemented precisely, plus what happens to children when a parent is deleted. Compare RESTRICT, CASCADE and SET NULL on real deletes.

🗄️ Databases● Beginner⏱ 10 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • One-to-many as the default shape
  • One-to-one implementations
  • Junction tables and self-referential relationships
  • Optional vs required relationships and referential actions

📚 Before you start

1The Three Shapes, Restated Precisely

02-schema_design_and_data_modeling/ introduced cardinality at the modeling level. This topic is about how each shape is actually implemented, enforced, and queried — including the shapes that are easy to get subtly wrong: self-referential relationships, optional vs. required relationships, and what happens on delete.

text
One-to-Many (1:N):    department 1 ────────< N   employee
One-to-One (1:1):     employee   1 ────────1     employee_profile
Many-to-Many (N:M):   student    N >──────< M     course   (via enrolment)

2One-to-Many: the Default Shape

The foreign key always lives on the "many" side, pointing at the "one" side's primary key.

sql
CREATE TABLE department (
  id   SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE employee (
  id            SERIAL PRIMARY KEY,
  name          TEXT NOT NULL,
  department_id INT REFERENCES department(id)   -- the "many" side holds the FK
);

A department row has no column pointing at its employees — "one department has many employees" is answered by a query (SELECT * FROM employee WHERE department_id = ?), not by a stored list. This is the fundamental asymmetry of the relational model versus a document store's embedded-array instinct (see 05-json_in_sql_and_nosql/ for when embedding is actually the right call).

3One-to-One: Two Legitimate Implementations

Split table (two tables, FK carries a UNIQUE constraint) — used when the second table represents optional, rarely-accessed, or logically separate data:

sql
CREATE TABLE employee (
  id   SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE employee_profile (
  employee_id INT PRIMARY KEY REFERENCES employee(id),  -- PK doubling as the FK
  bio         TEXT,
  avatar_url  TEXT
);

Making employee_id both the primary key and the foreign key of employee_profile is what makes this genuinely 1:1 rather than 1:N — a normal FK column would allow multiple profile rows per employee; making it the PK forbids that by construction.

Merged table — when the "second entity" is never queried independently and always needed alongside the first, just add the columns to the same table. Splitting employee and employee_profile only pays off when profile data is large, optional, or updated at a very different rate than the core row (recall the TOAST discussion in 02-schema_design_and_data_modeling/, section 8) — otherwise the split is pure overhead, an extra join for data you always need together anyway.

4Many-to-Many: the Junction Table

A direct N:M relationship cannot be stored as a single foreign key on either side — an employee row can't hold "multiple department IDs" without violating 1NF. The fix is a third table holding one row per pairing:

sql
CREATE TABLE student (id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE course  (code TEXT PRIMARY KEY, title TEXT NOT NULL);

CREATE TABLE enrolment (
  student_id  INT  REFERENCES student(id),
  course_code TEXT REFERENCES course(code),
  enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  grade       TEXT,
  PRIMARY KEY (student_id, course_code)   -- composite PK = the pairing IS the identity
);

Any attribute that describes the pairing itself (enrolled_at, grade) belongs on the junction table — it cannot correctly live on student (which student's grade in which course?) or on course (same problem in reverse). If the junction table needs to be referenced by yet another table (e.g. a certificate issued for a specific enrolment), give it its own surrogate key instead of relying purely on the composite:

sql
CREATE TABLE enrolment (
  id          SERIAL PRIMARY KEY,       -- now referenceable on its own
  student_id  INT  REFERENCES student(id),
  course_code TEXT REFERENCES course(code),
  UNIQUE (student_id, course_code)       -- the pairing is still enforced unique
);

5Self-Referential Relationships

An entity relates to another row of the same entity — an employee's manager, a category's parent category, a comment's parent comment.

sql
CREATE TABLE employee (
  id         SERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  manager_id INT REFERENCES employee(id)   -- points back into the same table
);

This is a 1:N relationship exactly like any other (one manager, many direct reports) — the only difference is both ends live in the same table, which means recursive queries are needed to walk more than one level:

sql
-- everyone in Ana's reporting chain, any depth, via a recursive CTE
WITH RECURSIVE chain AS (
  SELECT id, name, manager_id FROM employee WHERE name = 'Ana'
  UNION ALL
  SELECT e.id, e.name, e.manager_id
  FROM employee e JOIN chain c ON e.id = c.manager_id
)
SELECT * FROM chain;

An adjacency list (the manager_id column above) is simple to write to but expensive to query for "all descendants" at arbitrary depth — each level costs another recursive step. A closure table (a separate table pre-storing every ancestor-descendant pair, not just direct ones) trades write cost (every insert/move must update potentially many closure rows) for O(1)-join descendant/ancestor queries at any depth. Choose adjacency list when reads are shallow (one level: "who reports to X") or rare; choose a closure table when deep-hierarchy queries ("everyone under X, any depth") are frequent and the hierarchy doesn't restructure often.

6Optional vs. Required Relationships

A foreign key column that's nullable models an optional relationship; NOT NULL models a required one. This is a real modeling decision, not a formality:

sql
-- Required: every order MUST have a customer — cannot exist orphaned
customer_id INT NOT NULL REFERENCES customer(id)

-- Optional: a support ticket MAY be assigned to an agent, or sit unassigned
assigned_agent_id INT REFERENCES agent(id)   -- nullable

Getting this wrong in either direction causes real bugs: making a required relationship nullable lets orphaned rows silently accumulate (a payment with no order); making an optional relationship required forces inserting a placeholder or sentinel row just to satisfy the constraint, which is worse than the null it was avoiding.

7Referential Actions: What Happens on Update/Delete

A foreign key isn't just a validation rule for inserts — it also has to say what happens when the referenced row changes or disappears.

sql
CREATE TABLE order_item (
  order_id   INT REFERENCES "order"(id) ON DELETE CASCADE,
  product_id INT REFERENCES product(id) ON DELETE RESTRICT,
  quantity   INT NOT NULL
);
ActionBehavior when the referenced row is deleted
RESTRICT (default in most engines, or NO ACTION)Delete is rejected outright if any child row references it
CASCADEEvery child row referencing it is deleted too, automatically
SET NULLThe child's FK column is set to NULL (requires the FK column to be nullable)
SET DEFAULTThe child's FK column resets to its declared default value

Choosing between them is a real design decision: order_item ON DELETE CASCADE from order makes sense — an order line item has no meaning once its order is gone. order_item ON DELETE RESTRICT from product is deliberately the opposite — deleting a product that has ever been ordered should fail loudly, not silently erase historical order records; the correct operation there is usually "deactivate the product," not delete it.

Under the HoodHow it works internally▾
Adjacency list vs closure table for a self-referential category hierarchy: write cost vs descendant-query cost
Adjacency list vs closure table for a self-referential category hierarchy: write cost vs descendant-query cost

CASCADE is a graph walk, not a single operation

ON DELETE CASCADE is not a bulk DELETE ... WHERE fk = x dressed up — the engine performs a recursive graph traversal at delete time. Deleting a department row with ON DELETE CASCADE on employee.department_id triggers a search for every employee row referencing it; if employee.id is itself referenced by, say, timesheet.employee_id with its own ON DELETE CASCADE, the engine must cascade again, transitively, until no more dependent rows exist anywhere in the FK graph. Two consequences follow directly from this being a graph walk rather than a flat operation:

  1. Lock scope grows with the fan-out of the graph, not with the size of your original DELETE statement. Deleting one department row can escalate into row locks across three or four tables, several levels deep, all held for the duration of the same transaction.
  2. Deadlock risk rises with cascade depth, because two concurrent transactions deleting different top-level rows can end up needing locks on overlapping descendant rows in a different order — the same fundamental deadlock shape as any multi-table lock ordering problem, just made likely by a single DELETE statement fanning out into many implicit ones.

This is precisely why production schemas often prefer RESTRICT plus an explicit, application-level, ordered deletion (or a soft-delete flag from 02-schema_design_and_data_modeling/, section 6) over deep CASCADE chains: predictable lock scope beats convenience once the FK graph gets more than one or two levels deep.

Self-referential recursive queries cost real work per level

A recursive CTE walking an adjacency list (section 5) is not free per level — each iteration is a genuine join against the base table, and the engine cannot generally use a single index range scan to answer "all descendants of X, any depth," because depth isn't encoded in the data at all. This is the mechanical reason a closure table trades write amplification (every insert or re-parenting of a node touches every affected ancestor-descendant pair) for read simplicity (a descendant query becomes one indexed lookup, not N recursive joins) — it's the exact same normalize-for-writes-vs-denormalize-for-reads trade-off from 01-normalization_and_denormalization/, applied specifically to hierarchical data.

Why a required (NOT NULL) FK is checked differently from an optional one

Both required and optional foreign keys are validated the same way on insert/update of the child row (the engine looks up the referenced value in the parent's primary key index — the full mechanics are in 04-primary_foreign_keys_constraints/). The difference only shows up on the parent delete/update path: an optional (NULLABLE) FK with ON DELETE SET NULL still requires the engine to find and rewrite every referencing child row (an UPDATE, not a no-op) — nullable does not mean "the engine can skip checking it," it only changes what the referencing rows become afterward.

⚠️ Common Pitfalls

  1. Modeling N:M as two separate 1:N foreign keys on one of the entities — impossible without violating 1NF (a column can't hold multiple values); the junction table is not optional scaffolding, it's the only correct representation.
  2. Putting a relationship's own attribute on the wrong table — grade cannot live on student or course alone; it describes the pairing, so it belongs on the junction table (or is lost the moment a student takes two courses).
  3. Choosing CASCADE reflexively for convenience — a deep cascade chain silently deletes historical data (e.g. cascading order deletion into payment records) and, per the "Under the Hood" section, expands lock scope and deadlock surface in ways that aren't visible from reading the single DELETE statement that triggers it.
  4. Using an adjacency list and then writing "get all descendants, any depth" queries constantly — each such query is a multi-step recursive join; if this access pattern is frequent, a closure table (or a materialized path column) is the appropriate trade, not a "smarter" recursive query.
  5. Making a relationship nullable "just in case" — an optional FK that should be required lets orphaned data accumulate silently, and the bug surfaces far from where the bad row was created.
  6. Forgetting that SET NULL requires the FK column to already be nullable — declaring ON DELETE SET NULL on a NOT NULL column is a contradiction the engine will reject at DDL time (or, in engines that don't check eagerly, will fail at delete time), not a bug in the code that triggers the delete.

📋 Quick Reference

text
1:N   FK lives on the "many" side, referencing the "one" side's PK
1:1   FK on the child, marked UNIQUE (or the FK IS the child's own PK)
N:M   junction table, composite PK (or surrogate PK + UNIQUE), FK to each side
       -> pairing's own attributes (grade, enrolled_at) live HERE

Self-referential: FK column referencing the same table
  adjacency list   -> cheap writes, recursive query needed per depth level
  closure table    -> write cost scales with hierarchy size, O(1)-join reads at any depth

Optional relationship -> nullable FK      Required relationship -> NOT NULL FK

ON DELETE RESTRICT   reject delete if children exist         (default; safest)
ON DELETE CASCADE    delete children too, recursively         (real data loss risk + lock fan-out)
ON DELETE SET NULL   children's FK -> NULL                    (FK column must be nullable)
ON DELETE SET DEFAULT children's FK -> declared default value

CASCADE = a graph walk: lock scope and deadlock risk scale with FK graph depth, not statement size

🧠 Key takeaways

  • 1:N = FK on the child; 1:1 = unique FK; M:N = junction table with a composite key.
  • Self-references model trees/graphs (manager_id); use recursive CTEs to traverse.
  • NOT NULL on the FK makes a relationship required.
  • Pick referential actions deliberately: RESTRICT (safe default), CASCADE (parts of a whole), SET NULL (optional).

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How do you model one-to-many and many-to-many relationships?core▾

One-to-many: a foreign key on the 'many' side (orders.customer_id). Many-to-many: a junction table with two foreign keys (student_courses(student_id, course_id)) and a composite primary key; extra attributes (grade, enrolled_at) go on the junction table.

Follow-ups interviewers ask
  • One-to-one? A foreign key with a unique constraint, or a shared primary key.
Q2How do you model hierarchical data (trees) in SQL?core▾

Adjacency list (parent_id): simple; query subtrees with recursive CTEs. Materialized path (/1/4/9/): fast subtree queries with LIKE, costly moves. Nested sets: fast reads, expensive writes. Closure table: all ancestor-descendant pairs; flexible, more storage.

Follow-ups interviewers ask
  • Default choice? Adjacency list with recursive CTEs, unless profiling says otherwise.
Q3What is a polymorphic association and why is it problematic?senior▾

One column referencing different tables depending on a type column (commentable_type, commentable_id). The database can't enforce a foreign key, so integrity relies on the app. Alternatives: separate nullable FKs with a check constraint, or separate junction tables per type.

Follow-ups interviewers ask
  • Why do ORMs use it? Convenience; it trades integrity for flexibility.
Q4How do relationships map in document databases?core▾

Embed child data when it's read together and bounded in size (order with its line items); reference by ID when it's shared, large or unbounded (users and their comments). Embedding gives single reads; referencing avoids duplication and huge documents.

Follow-ups interviewers ask
  • MongoDB document size limit? 16 MB, another reason not to embed unbounded arrays.
Q5What should happen to children when a parent is deleted?core▾

Choose deliberately: ON DELETE CASCADE (delete children), RESTRICT (block deletion while children exist), SET NULL (orphan with null reference). Cascades are convenient but can delete large amounts of data unexpectedly.

Follow-ups interviewers ask
  • Which is safest by default? RESTRICT.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Employees have a manager who is also an employee. Model with…
Self-referential relationship.
2. Which referential action deletes children automatically?
Use carefully.
3. How do you make 'every pet must have an owner' enforced?
Required relationship.
4. Primary key of a junction table pet_vets?
Prevents duplicates.