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)CREATETABLEcustomers (
customer_id INTPRIMARYKEY AUTO_INCREMENT,
full_name VARCHAR(100) NOTNULL,
email VARCHAR(150) NOTNULLUNIQUE
);
-- Step 2: Create the 'many' side (child table)-- Notice: customer_id here is a FOREIGN KEY pointing to the parentCREATETABLEorders (
order_id INTPRIMARYKEY AUTO_INCREMENT,
customer_id INTNOTNULL, -- FK column
order_date DATENOTNULL,
total_amount DECIMAL(10,2) NOTNULL,
CONSTRAINT fk_order_customer
FOREIGNKEY (customer_id)
REFERENCEScustomers(customer_id)
ONDELETERESTRICT-- prevent deleting a customer who has ordersONUPDATECASCADE-- if customer_id changes, propagate it
);
-- Step 3: Seed some dataINSERTINTOcustomers (full_name, email) VALUES
('Sarah Mitchell', 'sarah@example.com'),
('James Okafor', 'james@example.com');
INSERTINTOorders (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 relationshipsSELECT
c.full_name,
COUNT(o.order_id) AS total_orders,
SUM(o.total_amount) AS lifetime_value
FROM customers c
LEFTJOIN orders o ON c.customer_id = o.customer_id -- LEFT JOIN keeps customers with 0 ordersGROUPBY c.customer_id, c.full_name
ORDERBY 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.-- ─────────────────────────────────────────────────────────CREATETABLEstudents (
student_id INTPRIMARYKEY AUTO_INCREMENT,
full_name VARCHAR(100) NOTNULL,
email VARCHAR(150) NOTNULLUNIQUE
);
CREATETABLEcourses (
course_id INTPRIMARYKEY AUTO_INCREMENT,
course_code VARCHAR(10) NOTNULLUNIQUE, -- e.g. 'CS101'
course_title VARCHAR(200) NOTNULL
);
-- Junction table: each row represents ONE student enrolled in ONE courseCREATETABLEenrolments (
enrolment_id INTPRIMARYKEY AUTO_INCREMENT,
student_id INTNOTNULL,
course_id INTNOTNULL,
enrolled_on DATENOTNULL,
final_grade CHAR(2), -- NULL until the course ends-- Composite UNIQUE ensures a student can't enrol in the same course twiceUNIQUEKEYuq_student_course (student_id, course_id),
CONSTRAINT fk_enrolment_student
FOREIGNKEY (student_id) REFERENCESstudents(student_id)
ONDELETECASCADE, -- remove enrolments if student is deletedCONSTRAINT fk_enrolment_course
FOREIGNKEY (course_id) REFERENCEScourses(course_id)
ONDELETERESTRICT-- block deleting a course that has enrolments
);
-- Seed dataINSERTINTOstudents (full_name, email) VALUES
('Priya Nair', 'priya@uni.edu'),
('Tom Bergmann', 'tom@uni.edu'),
('Aisha Mensah', 'aisha@uni.edu');
INSERTINTOcourses (course_code, course_title) VALUES
('CS101', 'Introduction to Programming'),
('DB201', 'Database Design Fundamentals'),
('ML301', 'Machine Learning Basics');
INSERTINTOenrolments (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'ORDERBY 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
LEFTJOIN enrolments e ON c.course_id = e.course_id
GROUPBY c.course_id, c.course_code, c.course_title
ORDERBY student_count DESC;
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 requestCREATETABLEusers (
user_id INTPRIMARYKEY AUTO_INCREMENT,
username VARCHAR(50) NOTNULLUNIQUE,
email VARCHAR(150) NOTNULLUNIQUE,
password_hash VARCHAR(255) NOTNULL,
created_at TIMESTAMPDEFAULT CURRENT_TIMESTAMP
);
-- Extended profile — only loaded when a user visits their profile pageCREATETABLEuser_profiles (
profile_id INTPRIMARYKEY AUTO_INCREMENT,
user_id INTNOTNULLUNIQUE, -- 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
FOREIGNKEY (user_id) REFERENCESusers(user_id)
ONDELETECASCADE-- delete the profile if the user account is removed
);
-- Seed data: only some users have profilesINSERTINTOusers (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 profilesINSERTINTOuser_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 appearSELECT
u.username,
u.email,
COALESCE(p.display_name, u.username) AS display_name, -- fallback to username
p.bio,
p.location
FROM users u
LEFTJOIN user_profiles p ON u.user_id = p.user_id
ORDERBY u.user_id;
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.-- ─────────────────────────────────────────────────────────CREATETABLEemployees (
employee_id INTPRIMARYKEY AUTO_INCREMENT,
full_name VARCHAR(100) NOTNULL,
job_title VARCHAR(100) NOTNULL,
manager_id INTNULL, -- NULL means this person is the top of the chainCONSTRAINT fk_employee_manager
FOREIGNKEY (manager_id)
REFERENCESemployees(employee_id) -- points to the SAME tableONDELETESETNULL-- if a manager is removed, reports become unmanaged
);
-- Build an org chart: CEO → VP → Managers → DevelopersINSERTINTOemployees (full_name, job_title, manager_id) VALUES
('LindaForsythe', 'CEO', NULL), -- id=1, no manager
('CarlosRivera', 'VP of Engineering', 1), -- id=2, reports to Linda
('AikoTanaka', 'VP of Product', 1), -- id=3, reports to Linda
('BenHughes', 'EngineeringManager',2), -- id=4, reports to Carlos
('FatimaAl-Rashid','SeniorDeveloper', 4), -- id=5, reports to Ben
('NoahEriksson', '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+WITHRECURSIVE org_chart AS (
-- Anchor: start with the CEO (no manager)SELECT
employee_id,
full_name,
job_title,
manager_id,
0AS depth, -- depth 0 = top level
full_name AS reporting_chain
FROM employees
WHERE manager_id ISNULLUNIONALL-- Recursive step: find direct reports of the current levelSELECT
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 stringFROM employees e
JOIN org_chart oc ON e.manager_id = oc.employee_id -- join child to parent
)
SELECTREPEAT(' ', depth) || full_name AS indented_name, -- indent by depth
job_title,
depth
FROM org_chart
ORDERBY reporting_chain;
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';
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
Aspect
One-to-Many (1:N)
Many-to-Many (M:N)
One-to-One (1:1)
Self-Referencing
Real-world example
Customer → Orders
Students ↔ Courses
User → User Profile
Employee → Manager
Where does the FK live?
On the 'many' (child) table
In a dedicated junction table
On the dependent (optional) table
Same table — FK references own PK
Extra table needed?
No
Yes — always
No, but sometimes worth it
No — single table handles it
Can carry extra data?
Yes, on the child rows
Yes, on the junction table rows
Yes, on the dependent table
Yes, on each row
Query complexity
Simple JOIN
Two JOINs through junction
Simple LEFT JOIN
Recursive CTE required
Main design risk
Forgetting the FK constraint
Missing UNIQUE on junction pair
Unnecessary splitting of one table
Circular references causing infinite loops
Use when...
Hierarchy is clear and asymmetric
Both sides have multiple connections
Data is sparse, sensitive, or rarely accessed
Entities 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.
Q02 of 03SENIOR
You have a products table and an orders table, and each order can contain multiple products with their own quantity and unit price. Where do you store the quantity and unit price, and why?
ANSWER
Store quantity and unit_price in the junction table (often called order_items or order_lines). This is because the relationship itself carries business data — the same product can have different quantities and prices in different orders. The junction table is a first-class entity here, not just a link. It has foreign keys to both orders and products, plus its own columns for quantity and unit_price. This design also captures price-at-time-of-order, which is critical for historical accuracy since product prices change over time.
Q03 of 03SENIOR
A colleague suggests storing a user's list of favourite tags as a comma-separated string in a single VARCHAR column to 'keep things simple'. How do you respond, and what are the specific problems that approach causes at scale?
ANSWER
Comma-separated IDs in a VARCHAR column violate First Normal Form and cause compounding problems at scale: (1) You cannot JOIN on the column — every lookup requires string parsing with FIND_IN_SET or LIKE, which cannot use indexes. (2) You cannot enforce referential integrity — deleted tags leave orphaned references with no error. (3) COUNT and aggregation queries are unreliable — you must split the string in application code. (4) Adding or removing a tag requires rewriting the entire string. The fix is a junction table (user_favourites) with one row per user-tag pair, foreign keys to both tables, and a composite UNIQUE key.
01
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?
JUNIOR
02
You have a products table and an orders table, and each order can contain multiple products with their own quantity and unit price. Where do you store the quantity and unit price, and why?
SENIOR
03
A colleague suggests storing a user's list of favourite tags as a comma-separated string in a single VARCHAR column to 'keep things simple'. How do you respond, and what are the specific problems that approach causes at scale?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
What is the difference between a foreign key and a relationship in a database?
A relationship is the logical concept — the rule that a customer can have many orders. A foreign key is the physical mechanism that enforces that rule in the database. The foreign key is a column in the child table that must match a value in the parent table's primary key, and the database engine rejects any insert or update that would violate that link.
Was this helpful?
02
Can a table have more than one foreign key?
Absolutely — and it's common. A junction table in a Many-to-Many relationship has at least two foreign keys by definition. An orders table might have a foreign key to customers and another to shipping_addresses. Each FK represents an independent relationship to a different parent table, and they don't interfere with each other.
Was this helpful?
03
When should I use a self-referencing relationship instead of a separate parent table?
Use a self-referencing table when the parent and child are fundamentally the same type of thing — an employee managing other employees, a category containing subcategories, a comment replying to another comment. If the parent and child are genuinely different entities (like a manager role vs a developer role with different attributes), a separate table is cleaner. The test is: do both levels share the exact same columns and meaning?