Senior 4 min · March 05, 2026

ON DELETE CASCADE — The $2.3M Revenue Discrepancy

A cleanup script deleted 340 customers and silently removed 14,000 orders via CASCADE.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Database relationships are rules describing how rows in one table connect to rows in another
  • One-to-Many (1:N): one parent, many children — FK lives on the child table
  • Many-to-Many (M:N): both sides connect to many — always requires a junction table
  • One-to-One (1:1): rare — use only for sparse data, security isolation, or proven query performance
  • Always declare ON DELETE behaviour explicitly — RESTRICT is the safe default
  • Missing UNIQUE on a junction table composite key silently corrupts COUNT and aggregation queries
Plain-English First

Think of a library. One library card belongs to exactly one person — but that person can borrow many books, and each book can be borrowed by many different people over time. That's the whole concept of database relationships: it's just a set of rules describing how rows in one table connect to rows in another. Get those rules right and your data stays clean and consistent forever. Get them wrong and you'll be untangling duplicate rows at 2am.

Every real application — a Netflix, an Airbnb, a humble todo list — is powered by tables that talk to each other. The moment you store a user's orders, a product's reviews, or a student's enrolled courses, you're dealing with database relationships. They're not optional theory; they're the skeleton your entire data model is built on.

A wrong relationship can corrupt data silently, make queries nightmarishly slow, or force you to rewrite half your schema six months into production. The problem they solve is data redundancy and integrity — without relationships, you'd copy a customer's name and address into every single order row.

By the end of this article you'll be able to identify which relationship type belongs in a given scenario, write the SQL to implement each one correctly with foreign keys, design a clean junction table for many-to-many links, and avoid the three classic mistakes that trip up even experienced developers.

One-to-Many: The Relationship You'll Use 80% of the Time

A One-to-Many (1:N) relationship means one row in Table A can be associated with many rows in Table B, but each row in Table B points back to exactly one row in Table A. Classic examples: one customer → many orders, one blog post → many comments, one department → many employees.

The pattern is always the same: the 'many' side holds the foreign key. An order row holds a customer_id. A comment row holds a post_id. You never put a list of IDs inside the 'one' side — relational databases don't store arrays in columns, and if you find yourself wanting to, that's a design smell.

Why does this matter so much? Because it's the primary tool for eliminating redundancy. You store the customer's name and email exactly once in the customers table. Every order just references that one row. Update the email in one place and every order instantly reflects it. That's referential integrity — the database guarantees the customer_id in every order actually exists in the customers table, because you declared a FOREIGN KEY constraint.

one_to_many_orders.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
-- ─────────────────────────────────────────────────────────
-- SCENARIO: An e-commerce store where customers place orders.
-- One customer can place many orders.
-- The foreign key lives on the 'many' side (orders table).
-- ─────────────────────────────────────────────────────────

-- Step 1: Create the 'one' side first (parent table)
CREATE TABLE customers (
    customer_id   INT           PRIMARY KEY AUTO_INCREMENT,
    full_name     VARCHAR(100)  NOT NULL,
    email         VARCHAR(150)  NOT NULL UNIQUE
);

-- Step 2: Create the 'many' side (child table)
-- Notice: customer_id here is a FOREIGN KEY pointing to the parent
CREATE TABLE orders (
    order_id      INT           PRIMARY KEY AUTO_INCREMENT,
    customer_id   INT           NOT NULL,                    -- FK column
    order_date    DATE          NOT NULL,
    total_amount  DECIMAL(10,2) NOT NULL,
    CONSTRAINT fk_order_customer
        FOREIGN KEY (customer_id)
        REFERENCES customers(customer_id)
        ON DELETE RESTRICT   -- prevent deleting a customer who has orders
        ON UPDATE CASCADE    -- if customer_id changes, propagate it
);

-- Step 3: Seed some data
INSERT INTO customers (full_name, email) VALUES
    ('Sarah Mitchell', 'sarah@example.com'),
    ('James Okafor',   'james@example.com');

INSERT INTO orders (customer_id, order_date, total_amount) VALUES
    (1, '2024-03-01', 129.99),  -- Sarah's first order
    (1, '2024-04-15', 49.00),   -- Sarah's second order
    (2, '2024-04-20', 310.50);  -- James's only order

-- Step 4: Fetch every customer alongside their order count
-- This is the most common query pattern for 1:N relationships
SELECT
    c.full_name,
    COUNT(o.order_id)        AS total_orders,
    SUM(o.total_amount)      AS lifetime_value
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id  -- LEFT JOIN keeps customers with 0 orders
GROUP BY c.customer_id, c.full_name
ORDER BY lifetime_value DESC;
Output
full_name | total_orders | lifetime_value
-----------------+--------------+---------------
James Okafor | 1 | 310.50
Sarah Mitchell | 2 | 178.99
Watch Out: Always Declare ON DELETE Behaviour
If you omit ON DELETE, the database default is RESTRICT — but don't rely on the default. Be explicit. Choosing ON DELETE CASCADE on orders means deleting a customer silently wipes all their orders. That's almost never what you want in a financial system. Use RESTRICT to block the delete and force the application to handle cleanup deliberately.
Production Insight
FK columns need explicit indexes — foreign key constraints do NOT auto-create indexes in MySQL or PostgreSQL.
Without an index on orders.customer_id, every JOIN and every DELETE on the parent triggers a full table scan on orders.
Rule: always CREATE INDEX on every foreign key column immediately after table creation.
Key Takeaway
The foreign key always lives on the 'many' side — if you're unsure which table gets it, ask which side has many rows per relationship.
Always create an explicit index on FK columns — the constraint alone does not guarantee query performance.
ON DELETE RESTRICT is the safe default; CASCADE is the exception, not the rule.
When to Use One-to-Many
IfOne entity clearly owns many sub-entities (customer → orders)
UseUse 1:N — put FK on the child table
IfEach child belongs to exactly one parent
UseUse 1:N — the asymmetric ownership is the signal
IfYou're tempted to store a comma-separated list of IDs in one column
UseExtract to a proper child table with one row per relationship
IfBoth sides can have multiple connections to the other
UseSkip 1:N — use Many-to-Many with a junction table instead

Many-to-Many: Why You Always Need a Junction Table

A Many-to-Many (M:N) relationship means rows on both sides can relate to multiple rows on the other side. Students enrol in many courses; each course has many students. A product appears in many orders; each order contains many products. Doctors treat many patients; patients see many doctors.

Here's the critical insight: you cannot model M:N directly between two tables. There's no column you can add to students or courses that handles multiple associations cleanly. What you need is a third table — a junction table (also called a bridge or associative table) — that turns the M:N into two separate 1:N relationships.

The junction table holds the foreign keys from both sides and its own primary key. But here's where it gets interesting: the junction table often carries its own meaningful data. An enrolment isn't just a link — it has an enrolment date, a grade, a status. That extra data is what makes the junction table a first-class entity in your schema, not just plumbing. When you recognize that, your design becomes far more expressive and your queries become cleaner.

many_to_many_enrolments.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
-- ─────────────────────────────────────────────────────────
-- SCENARIO: A university system.
-- Students enrol in many courses; each course has many students.
-- The junction table (enrolments) turns M:N into two 1:N links.
-- ─────────────────────────────────────────────────────────

CREATE TABLE students (
    student_id    INT          PRIMARY KEY AUTO_INCREMENT,
    full_name     VARCHAR(100) NOT NULL,
    email         VARCHAR(150) NOT NULL UNIQUE
);

CREATE TABLE courses (
    course_id     INT          PRIMARY KEY AUTO_INCREMENT,
    course_code   VARCHAR(10)  NOT NULL UNIQUE,  -- e.g. 'CS101'
    course_title  VARCHAR(200) NOT NULL
);

-- Junction table: each row represents ONE student enrolled in ONE course
CREATE TABLE enrolments (
    enrolment_id    INT         PRIMARY KEY AUTO_INCREMENT,
    student_id      INT         NOT NULL,
    course_id       INT         NOT NULL,
    enrolled_on     DATE        NOT NULL,
    final_grade     CHAR(2),                    -- NULL until the course ends

    -- Composite UNIQUE ensures a student can't enrol in the same course twice
    UNIQUE KEY uq_student_course (student_id, course_id),

    CONSTRAINT fk_enrolment_student
        FOREIGN KEY (student_id) REFERENCES students(student_id)
        ON DELETE CASCADE,   -- remove enrolments if student is deleted

    CONSTRAINT fk_enrolment_course
        FOREIGN KEY (course_id) REFERENCES courses(course_id)
        ON DELETE RESTRICT   -- block deleting a course that has enrolments
);

-- Seed data
INSERT INTO students (full_name, email) VALUES
    ('Priya Nair',    'priya@uni.edu'),
    ('Tom Bergmann',  'tom@uni.edu'),
    ('Aisha Mensah',  'aisha@uni.edu');

INSERT INTO courses (course_code, course_title) VALUES
    ('CS101', 'Introduction to Programming'),
    ('DB201', 'Database Design Fundamentals'),
    ('ML301', 'Machine Learning Basics');

INSERT INTO enrolments (student_id, course_id, enrolled_on) VALUES
    (1, 1, '2024-01-10'),  -- Priya in CS101
    (1, 2, '2024-01-10'),  -- Priya in DB201
    (2, 1, '2024-01-11'),  -- Tom in CS101
    (2, 3, '2024-01-11'),  -- Tom in ML301
    (3, 2, '2024-01-12'),  -- Aisha in DB201
    (3, 3, '2024-01-12');  -- Aisha in ML301

-- Query 1: Which courses is Priya enrolled in?
SELECT
    s.full_name,
    c.course_code,
    c.course_title,
    e.enrolled_on
FROM enrolments e
JOIN students s ON e.student_id = s.student_id
JOIN courses  c ON e.course_id  = c.course_id
WHERE s.full_name = 'Priya Nair'
ORDER BY e.enrolled_on;

-- Query 2: How many students are in each course?
SELECT
    c.course_code,
    c.course_title,
    COUNT(e.student_id) AS student_count
FROM courses c
LEFT JOIN enrolments e ON c.course_id = e.course_id
GROUP BY c.course_id, c.course_code, c.course_title
ORDER BY student_count DESC;
Output
-- Query 1 result:
full_name | course_code | course_title | enrolled_on
------------+-------------+---------------------------------+------------
Priya Nair | CS101 | Introduction to Programming | 2024-01-10
Priya Nair | DB201 | Database Design Fundamentals | 2024-01-10
-- Query 2 result:
course_code | course_title | student_count
------------+---------------------------------+--------------
CS101 | Introduction to Programming | 2
DB201 | Database Design Fundamentals | 2
ML301 | Machine Learning Basics | 2
Pro Tip: Name Your Junction Table After the Relationship, Not the Tables
Don't name it students_courses — name it enrolments. Why? Because it IS an enrolment, not just a link. Naming it after the business concept signals to every future developer that this table carries meaning and may store extra attributes. It also makes your queries read like English: 'SELECT FROM enrolments' tells a story; 'SELECT FROM students_courses' does not.
Production Insight
Junction tables grow faster than either parent — if each user has 50 favourites and you have 1M users, the favourites table has 50M rows.
Always add a composite index on both FK columns for the most common query direction.
Without it, every 'find all courses for this student' query scans the entire junction table.
Key Takeaway
Many-to-Many cannot exist without a junction table — it is not optional, it is the only correct implementation.
The junction table is a real entity — name it after the business concept and expect it to carry meaningful attributes.
Missing UNIQUE on the composite key is the #1 source of duplicate data bugs in M:N schemas.
When to Use Many-to-Many
IfBoth sides can independently have multiple connections
UseUse M:N — create a junction table with composite UNIQUE key
IfThe relationship itself carries business data (grade, quantity, role)
UseUse M:N — the junction table becomes a first-class entity
IfYou're considering a comma-separated column to store multiple IDs
UseExtract to a junction table — comma-separated IDs break JOINs and indexes
IfOne side always has exactly one parent (each order belongs to one customer)
UseSkip M:N — use One-to-Many with FK on the child table

One-to-One: Rare but Powerful for Schema Partitioning

A One-to-One (1:1) relationship means each row in Table A corresponds to at most one row in Table B, and vice versa. It's the least common relationship type — and beginners often ask: why not just put all those columns in one table?

The answer is: sometimes you should. But there are three legitimate reasons to split into a 1:1 relationship. First, optional data: a users table might have profile details (bio, avatar, website) that only some users ever fill in. Keeping sparse, optional columns in a separate user_profiles table avoids storing NULL across millions of rows. Second, security partitioning: store sensitive data like password hashes or payment tokens in a separate table with tighter access controls. Third, performance: if you have a table with 50 columns and some queries only ever need 5 of them, splitting into a 'hot' and 'cold' table dramatically reduces the I/O per query.

The implementation is a foreign key on the dependent table that also has a UNIQUE constraint, enforcing that no two rows can point to the same parent.

one_to_one_user_profiles.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
-- ─────────────────────────────────────────────────────────
-- SCENARIO: A SaaS app separating core login data from
-- optional profile details. Most queries only touch 'users'.
-- Profile data is only loaded when the profile page is viewed.
-- ─────────────────────────────────────────────────────────

-- Core login data — accessed on EVERY authenticated request
CREATE TABLE users (
    user_id       INT          PRIMARY KEY AUTO_INCREMENT,
    username      VARCHAR(50)  NOT NULL UNIQUE,
    email         VARCHAR(150) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at    TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
);

-- Extended profile — only loaded when a user visits their profile page
CREATE TABLE user_profiles (
    profile_id    INT           PRIMARY KEY AUTO_INCREMENT,
    user_id       INT           NOT NULL UNIQUE,  -- UNIQUE enforces the 1:1
    display_name  VARCHAR(100),
    bio           TEXT,
    avatar_url    VARCHAR(500),
    website_url   VARCHAR(500),
    location      VARCHAR(100),

    CONSTRAINT fk_profile_user
        FOREIGN KEY (user_id) REFERENCES users(user_id)
        ON DELETE CASCADE  -- delete the profile if the user account is removed
);

-- Seed data: only some users have profiles
INSERT INTO users (username, email, password_hash) VALUES
    ('sarah_m',  'sarah@example.com', '$2b$12$hashed...'),
    ('james_o',  'james@example.com', '$2b$12$hashed...'),
    ('priya_n',  'priya@example.com', '$2b$12$hashed...');

-- Only Sarah and Priya have filled in their profiles
INSERT INTO user_profiles (user_id, display_name, bio, location) VALUES
    (1, 'Sarah Mitchell', 'Software engineer & coffee enthusiast.', 'Dublin, Ireland'),
    (3, 'Priya Nair',     'ML researcher. Writes about data.',       'Bangalore, India');

-- Fetch a user's profile — use LEFT JOIN so users without a profile still appear
SELECT
    u.username,
    u.email,
    COALESCE(p.display_name, u.username) AS display_name,  -- fallback to username
    p.bio,
    p.location
FROM users u
LEFT JOIN user_profiles p ON u.user_id = p.user_id
ORDER BY u.user_id;
Output
username | email | display_name | bio | location
----------+---------------------+----------------+--------------------------------------+-----------------
sarah_m | sarah@example.com | Sarah Mitchell | Software engineer & coffee enthusiast| Dublin, Ireland
james_o | james@example.com | james_o | NULL | NULL
priya_n | priya@example.com | Priya Nair | ML researcher. Writes about data. | Bangalore, India
Interview Gold: Why Not Just Use One Table?
This is a classic interview question. The correct answer is: you often should use one table. Split into 1:1 only when you have a clear reason — optional sparse columns, security isolation, or query performance partitioning. Splitting without a reason adds JOIN complexity for zero benefit. Being able to articulate this trade-off shows senior-level thinking.
Production Insight
Splitting a table 1:1 without a measurable performance problem adds a mandatory JOIN to every query that needs both sets of columns.
Measure first: run EXPLAIN on your top 10 queries against the merged table.
If none scan excessive columns or show high I/O, keep it as one table — premature splitting is premature optimization.
Key Takeaway
One-to-One splits are a deliberate performance or security decision, not a default.
Merge into one table unless you have sparse columns, access control requirements, or a proven query performance problem.
The UNIQUE constraint on the FK column is what enforces the 1:1 — without it, you accidentally have a 1:N.
When to Use One-to-One
IfColumns are optional and sparse (profile filled by <20% of users)
UseSplit 1:1 — avoid NULL bloat across millions of rows
IfColumns contain sensitive data (password hashes, payment tokens)
UseSplit 1:1 — isolate into a table with tighter GRANT permissions
IfTable has 50+ columns but queries only touch 5
UseSplit 1:1 into hot/cold tables — reduces I/O per query
IfAll columns are frequently queried together and none are sparse
UseKeep as one table — splitting adds JOIN cost with no benefit

Self-Referencing Relationships: When a Table Points to Itself

A self-referencing (or recursive) relationship is when a row in a table has a foreign key pointing to another row in the same table. It sounds strange until you see the use cases: an employees table where each employee has a manager_id that points to another employee, a categories table where subcategories have a parent_category_id, or a comments table with threaded replies.

This is one of those patterns that feels clever the first time you see it, but it comes with tradeoffs. The big advantage is that you don't need a separate managers table or a separate categories table for each level of hierarchy — the structure is infinitely deep by design. The tradeoff is that querying hierarchical data in SQL requires recursive Common Table Expressions (CTEs), which not all developers are comfortable writing.

Knowing this pattern exists — and knowing when it's cleaner than a separate table — is a mark of a developer who thinks about schema design holistically rather than just creating tables reactively.

self_referencing_employees.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
-- ─────────────────────────────────────────────────────────
-- SCENARIO: A company org chart stored in a single table.
-- Each employee can have a manager, who is also an employee.
-- The CEO has no manager, so manager_id is NULL at the top.
-- ─────────────────────────────────────────────────────────

CREATE TABLE employees (
    employee_id   INT          PRIMARY KEY AUTO_INCREMENT,
    full_name     VARCHAR(100) NOT NULL,
    job_title     VARCHAR(100) NOT NULL,
    manager_id    INT          NULL,  -- NULL means this person is the top of the chain

    CONSTRAINT fk_employee_manager
        FOREIGN KEY (manager_id)
        REFERENCES employees(employee_id)  -- points to the SAME table
        ON DELETE SET NULL  -- if a manager is removed, reports become unmanaged
);

-- Build an org chart: CEO → VP → Managers → Developers
INSERT INTO employees (full_name, job_title, manager_id) VALUES
    ('Linda Forsythe',  'CEO',                NULL),   -- id=1, no manager
    ('Carlos Rivera',   'VP of Engineering',  1),      -- id=2, reports to Linda
    ('Aiko Tanaka',     'VP of Product',      1),      -- id=3, reports to Linda
    ('Ben Hughes',      'Engineering Manager',2),      -- id=4, reports to Carlos
    ('Fatima Al-Rashid','Senior Developer',   4),      -- id=5, reports to Ben
    ('Noah Eriksson',   'Developer',          4);      -- id=6, reports to Ben

-- Recursive CTE to walk the full org chart top-down
-- This works in PostgreSQL, MySQL 8+, SQL Server, and SQLite 3.35+
WITH RECURSIVE org_chart AS (

    -- Anchor: start with the CEO (no manager)
    SELECT
        employee_id,
        full_name,
        job_title,
        manager_id,
        0 AS depth,                         -- depth 0 = top level
        full_name AS reporting_chain
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- Recursive step: find direct reports of the current level
    SELECT
        e.employee_id,
        e.full_name,
        e.job_title,
        e.manager_id,
        oc.depth + 1,
        CONCAT(oc.reporting_chain, ' → ', e.full_name)  -- build the chain string
    FROM employees e
    JOIN org_chart oc ON e.manager_id = oc.employee_id  -- join child to parent
)
SELECT
    REPEAT('    ', depth) || full_name AS indented_name,  -- indent by depth
    job_title,
    depth
FROM org_chart
ORDER BY reporting_chain;
Output
indented_name | job_title | depth
---------------------------------+----------------------+-------
Linda Forsythe | CEO | 0
Carlos Rivera | VP of Engineering | 1
Ben Hughes | Engineering Manager | 2
Fatima Al-Rashid | Senior Developer | 3
Noah Eriksson | Developer | 3
Aiko Tanaka | VP of Product | 1
Self-Referencing: Think Trees, Not Tables
  • Each row has a parent_id pointing to another row in the same table
  • Root nodes have parent_id = NULL — the recursion anchor
  • Recursive CTEs walk from root to leaves by joining child to parent at each level
  • The structure is infinitely deep — no fixed number of tables needed
  • Trade-off: querying requires recursive CTEs, which not all developers write comfortably
Production Insight
Recursive CTEs on tables with >100K rows and deep hierarchies (depth > 10) can hit memory limits and slow down dramatically.
For read-heavy hierarchical queries at scale, consider a materialized path column (e.g., '1.2.5') or a closure table that pre-computes all ancestor-descendant pairs.
Rule: recursive CTEs are correct but not always fast — benchmark against your actual depth and row count before shipping to production.
Key Takeaway
Self-referencing tables replace N separate level-tables with one table and a parent_id column.
Always add a MAXRECURSION limit or depth guard — circular references will crash your database without one.
For high-read, deep-hierarchy workloads, pre-compute with a closure table instead of relying on recursive CTEs at query time.
When to Use Self-Referencing
IfParent and child are the same entity type (employee manages employee)
UseUse self-referencing — one table handles arbitrary depth
IfHierarchy depth is unknown or variable (categories, comments, org chart)
UseUse self-referencing — avoids creating N tables for N levels
IfParent and child have genuinely different columns (manager has direct_reports_count, developer has tech_stack)
UseUse separate tables — different schemas mean different entities
IfHierarchy is queried millions of times per second with deep nesting
UseConsider closure table or materialized path — recursive CTEs may not meet latency SLAs
● Production incidentPOST-MORTEMseverity: high

Orphaned Orders Broke Revenue Reports After Cascade Delete

Symptom
Finance reported a $2.3M revenue discrepancy between the application dashboard and the accounting system. The orders table had 14,000 fewer rows than the previous day's backup.
Assumption
The cleanup script only targeted test accounts created in the last 24 hours — the team assumed it was safe to run against production.
Root cause
The orders table had ON DELETE CASCADE on customer_id. When the script deleted 340 customer rows, the database silently deleted every associated order — including legitimate orders placed by customers whose accounts shared a creation-date pattern with test accounts.
Fix
Changed ON DELETE CASCADE to ON DELETE RESTRICT on the orders.customer_id foreign key. Implemented a soft-delete pattern (deleted_at column) for customer cleanup. Added a pre-deletion audit query that counts affected child rows before any bulk delete.
Key lesson
  • Never use ON DELETE CASCADE on tables with financial or audit data
  • Always run a COUNT query on child tables before deleting parent rows
  • Use ON DELETE RESTRICT as your default — switch to CASCADE only when child data is genuinely meaningless without the parent
  • Soft-delete (deleted_at) is almost always safer than hard-delete for customer-facing data
Production debug guideCommon symptoms when database relationships are misconfigured5 entries
Symptom · 01
COUNT query on junction table returns more rows than expected
Fix
Check for missing UNIQUE constraint on the composite key — duplicate rows are being inserted silently
Symptom · 02
DELETE on parent table fails with foreign key violation error
Fix
ON DELETE RESTRICT is blocking the delete — check for existing child rows first, then handle cleanup deliberately
Symptom · 03
JOIN returns zero rows despite data existing in both tables
Fix
Verify FK column types match exactly (INT vs BIGINT, VARCHAR length mismatch) — type mismatch causes silent join failures
Symptom · 04
Query on 1:N relationship is extremely slow despite correct indexes
Fix
Check if the FK column has an index — foreign key constraints do NOT auto-create indexes in most databases
Symptom · 05
Recursive CTE query runs until timeout
Fix
Check for circular references in self-referencing table — add MAXRECURSION limit and a depth guard in WHERE clause
★ Database Relationship Quick DebugFast diagnostic steps when relationship issues hit production
Duplicate rows in junction table
Immediate action
Identify duplicates and check for missing UNIQUE constraint
Commands
SELECT student_id, course_id, COUNT(*) FROM enrolments GROUP BY student_id, course_id HAVING COUNT(*) > 1;
SHOW INDEX FROM enrolments;
Fix now
ALTER TABLE enrolments ADD UNIQUE KEY uq_student_course (student_id, course_id);
Orphaned child rows with no parent+
Immediate action
Find orphaned rows and verify FK constraint exists
Commands
SELECT o.* FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id WHERE c.customer_id IS NULL;
SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_NAME = 'orders';
Fix now
ALTER TABLE orders ADD CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customers(customer_id);
Slow JOIN on foreign key column+
Immediate action
Check if FK column is indexed
Commands
SHOW INDEX FROM orders WHERE Column_name = 'customer_id';
EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.customer_id;
Fix now
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Circular reference in self-referencing table+
Immediate action
Detect the cycle and break it
Commands
WITH RECURSIVE cycle_check AS (SELECT employee_id, manager_id, CAST(employee_id AS CHAR(1000)) AS path FROM employees WHERE manager_id IS NOT NULL UNION ALL SELECT e.employee_id, e.manager_id, CONCAT(cc.path, ',', e.employee_id) FROM employees e JOIN cycle_check cc ON e.manager_id = cc.employee_id WHERE FIND_IN_SET(e.employee_id, cc.path) = 0) SELECT * FROM cycle_check WHERE FIND_IN_SET(manager_id, path) > 0 LIMIT 5;
SELECT employee_id, full_name, manager_id FROM employees WHERE employee_id IN (<ids from cycle_check>);
Fix now
UPDATE employees SET manager_id = NULL WHERE employee_id = <breaking_point_id>;
Relationship Type Comparison
AspectOne-to-Many (1:N)Many-to-Many (M:N)One-to-One (1:1)Self-Referencing
Real-world exampleCustomer → OrdersStudents ↔ CoursesUser → User ProfileEmployee → Manager
Where does the FK live?On the 'many' (child) tableIn a dedicated junction tableOn the dependent (optional) tableSame table — FK references own PK
Extra table needed?NoYes — alwaysNo, but sometimes worth itNo — single table handles it
Can carry extra data?Yes, on the child rowsYes, on the junction table rowsYes, on the dependent tableYes, on each row
Query complexitySimple JOINTwo JOINs through junctionSimple LEFT JOINRecursive CTE required
Main design riskForgetting the FK constraintMissing UNIQUE on junction pairUnnecessary splitting of one tableCircular references causing infinite loops
Use when...Hierarchy is clear and asymmetricBoth sides have multiple connectionsData is sparse, sensitive, or rarely accessedEntities form a variable-depth hierarchy of the same type

Key takeaways

1
The foreign key always lives on the 'many' side in a 1:N relationship
if you're ever unsure which table gets the FK, ask yourself which side has 'many' rows per relationship, and put it there.
2
Many-to-Many relationships cannot exist without a junction table
and that junction table is a real entity that often carries meaningful business data like enrolment dates, quantities, or statuses.
3
One-to-One splits are a deliberate performance or security decision, not a default
merging into one table is usually cleaner unless you have sparse columns, access control requirements, or a proven query performance problem.
4
Always declare your ON DELETE behaviour explicitly on every foreign key
relying on the database default is a silent time bomb waiting to corrupt or orphan your data when a parent row gets deleted.

Common mistakes to avoid

3 patterns
×

Storing multiple IDs in a single column

Symptom
Columns named course_ids VARCHAR(200) containing values like '1,2,5' — every JOIN requires string parsing, LIKE queries bypass indexes, and COUNT queries silently undercount.
Fix
Extract those relationships into a proper junction table — one row per relationship, with a foreign key to each side. Use UNIQUE on the composite key to prevent duplicates.
×

Forgetting the UNIQUE constraint on a junction table composite key

Symptom
A student can be inserted into the same course 50 times, inflating COUNT queries, duplicating rows in aggregations, and causing phantom enrolment bugs that only surface in reporting.
Fix
Add UNIQUE KEY uq_student_course (student_id, course_id) to the junction table — the database enforces the constraint at write time so application code never has to.
×

Using ON DELETE CASCADE everywhere without thinking

Symptom
Deleting a product category in a test environment silently wipes thousands of linked products and orders because every FK was set to CASCADE — data loss with no error.
Fix
Choose ON DELETE RESTRICT as your safe default. Only switch to CASCADE where the child data is genuinely owned by and meaningless without the parent (e.g., session tokens, enrolment rows).
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a One-to-Many and a Many-to-Many relation...
Q02SENIOR
You have a products table and an orders table, and each order can contai...
Q03SENIOR
A colleague suggests storing a user's list of favourite tags as a comma-...
Q01 of 03JUNIOR

What is the difference between a One-to-Many and a Many-to-Many relationship, and how do you physically implement each one in SQL?

ANSWER
A One-to-Many means one row in Table A connects to many rows in Table B, but each B row points to exactly one A row. Implementation: put a foreign key column on the 'many' side (e.g., orders.customer_id references customers.customer_id). A Many-to-Many means rows on both sides can connect to multiple rows on the other. Implementation: create a junction table with two foreign keys — one to each side — and a composite UNIQUE key to prevent duplicates. The junction table turns M:N into two separate 1:N relationships.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What is the difference between a foreign key and a relationship in a database?
02
Can a table have more than one foreign key?
03
When should I use a self-referencing relationship instead of a separate parent table?
🔥

That's Database Design. Mark it forged?

4 min read · try the examples if you haven't

Previous
Primary Key and Foreign Key
5 / 16 · Database Design
Next
Database Sharding