ON DELETE CASCADE — The $2.3M Revenue Discrepancy
A cleanup script deleted 340 customers and silently removed 14,000 orders via CASCADE.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
Database relationships define how tables connect to each other through shared keys, enforcing data integrity and preventing the orphaned records that silently corrupt analytics. Without explicit relationships, you're essentially managing separate CSV files — any application bug or manual data change can create mismatches that compound into revenue discrepancies like the $2.3M example in this article.
Foreign keys are the mechanism that enforces these relationships at the database level, rejecting any insert, update, or delete that would break referential integrity.
The four relationship types map directly to real-world data patterns. One-to-many (e.g., one customer to many orders) covers roughly 80% of business relationships and is implemented by adding a foreign key column to the 'many' side. Many-to-many (e.g., products to categories) always requires a junction table with two foreign keys — never store comma-separated IDs in a single column, as that breaks queryability and indexing.
One-to-one is rare but useful for schema partitioning (splitting large tables for performance or security), while self-referencing relationships (e.g., employee to manager) use a single foreign key pointing back to the same table's primary key.
When you skip foreign keys for 'flexibility' or performance, you trade data integrity for a ticking time bomb. The ON DELETE CASCADE clause automates cleanup when a parent record is deleted — without it, you either manually delete children first or risk orphaned rows that silently inflate counts and skew aggregations.
The $2.3M discrepancy in this article originated from exactly this: a missing cascade on a one-to-many relationship between orders and line items, causing stale line items to double-count revenue after a bulk cleanup operation.
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.
Why Database Relationships Are Not Optional
A database relationship is a logical link between two tables, enforced by foreign keys that guarantee referential integrity. The core mechanic: a column in one table references the primary key of another, preventing orphaned rows and ensuring every child has a valid parent. Without relationships, your data is just a collection of unrelated spreadsheets.
In practice, relationships define cardinality — one-to-one, one-to-many, many-to-many — and dictate how deletions propagate. ON DELETE CASCADE is one such rule: when a parent row is deleted, all dependent child rows are automatically removed. This is not magic; it's a declarative constraint executed at the database level, bypassing application code entirely. The key property: it's atomic and consistent — no partial deletions, no race conditions.
Use relationships and cascade rules when child data has no meaning without its parent — orders without customers, comments without posts. In real systems, failing to define them leads to silent data corruption: dangling references that crash joins, inflate counts, and produce phantom revenue. A $2.3M discrepancy often starts with a missing foreign key.
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.
-- ───────────────────────────────────────────────────────── -- 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;
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.
-- ───────────────────────────────────────────────────────── -- 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;
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.
-- ───────────────────────────────────────────────────────── -- 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;
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.
-- ───────────────────────────────────────────────────────── -- 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;
- 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
Foreign Keys: The Only Thing Preventing Orphaned Data
You've drawn a line on a whiteboard connecting 'users' to 'orders'. That's cute. Now make the database enforce it before a bulk delete screws your production reporting.
Foreign keys aren't decoration. They're the safety net that stops you from having orders referencing deleted users, or user profiles pointing to non-existent addresses. Without them, you're relying on application-level discipline — which breaks the second someone runs a raw UPDATE.
The pain point hits hardest during cascading deletes or when you try to backfill data. If your foreign key isn't indexed, every JOIN between these tables becomes a full table scan. That's how a simple page load turns into a 30-second query.
Here's the rule: define the constraint AND index the referencing column. The constraint prevents data rot. The index makes your joins fast. Most ORMs create the constraint but forget the index. Your database doesn't care about your ORM's opinion.
// io.thecodeforge — database tutorial -- Bad: no foreign key, no index CREATE TABLE orders ( id SERIAL PRIMARY KEY, user_id INT NOT NULL, -- no constraint, no index total DECIMAL(10,2) ); -- Good: foreign key with index CREATE TABLE orders ( id SERIAL PRIMARY KEY, user_id INT NOT NULL, total DECIMAL(10,2), CONSTRAINT fk_orders_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- or RESTRICT, based on your business rules ); CREATE INDEX idx_orders_user_id ON orders(user_id); -- critical for JOIN performance -- Check constraint violations don't exist: SELECT count(*) FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.id IS NULL; -- Output: 0 (if enforcement is working)
Composite Keys: When a Single Column Can't Uniquely Identify a Row
Ignore the cargo cult that tells you every table needs a surrogate integer primary key. Sometimes real-world data won't fit that mold.
A composite primary key — using two or more columns — is mandatory when no single column is unique, but the combination naturally is. Think enrollment tables: student_id + course_id. A student can take many courses, a course has many students, but the pair is unique.
Why not just slap an auto-increment 'id' on there? You can, but then you lose the built-in uniqueness enforcement for the real-world combination. You'd need a separate unique constraint on the pair. That's redundant, and it's another index burning disk space. The composite key is both primary key and unique constraint in one.
The tradeoff is real: composite keys make your JOIN queries longer to write, and they complicate foreign key references from child tables. If 'enrollments' has details in a 'grades' table, you'll need to repeat both columns in the foreign key. That's verbose but correct.
Only use composites when the pair is genuinely the natural key and stable (won't change). Avoid them for transactional tables where speed of inserts matters more than logical purity.
// io.thecodeforge — database tutorial -- Student-course enrollment with composite primary key CREATE TABLE enrollments ( student_id INT NOT NULL, course_id INT NOT NULL, enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, grade CHAR(2), -- Natural key: no two enrollments for same student+course pair PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES students(id), FOREIGN KEY (course_id) REFERENCES courses(id) ); -- Child table referencing composite key CREATE TABLE assignments ( student_id INT NOT NULL, course_id INT NOT NULL, assignment_id SERIAL, score DECIMAL(5,2), PRIMARY KEY (student_id, course_id, assignment_id), -- Must reference both columns FOREIGN KEY (student_id, course_id) REFERENCES enrollments(student_id, course_id) ); -- Query needs both columns SELECT e.*, a.score FROM enrollments e JOIN assignments a ON e.student_id = a.student_id AND e.course_id = a.course_id WHERE e.student_id = 42;
Ternary and Higher-Degree Relationships: Why Binary Assumptions Fail
Most databases model relationships between two entities: a student enrolls in a course. But real-world constraints often involve three or more entities simultaneously. Ternary relationships (three entities) solve cases a chain of binary tables cannot. Example: a doctor prescribes a specific medication to a specific patient. That's not three separate one-to-many links — it's one fact constrained by all three. If you model doctor-patient, patient-medication, and medication-doctor as separate pairs, you can insert invalid combinations. A ternary table DoctorPrescription(DoctorID, PatientID, MedicationID) with a composite primary key enforces that a prescribing event is exactly one row. Higher-degree relationships work the same way but require more columns. The cost is query complexity — joining four or five tables for one fact. Only use ternary+ when a single business rule involves all entities simultaneously; otherwise, decompose into binary relationships.
// io.thecodeforge — database tutorial CREATE TABLE Doctor ( DoctorID INT PRIMARY KEY, Name VARCHAR(100) ); CREATE TABLE Patient ( PatientID INT PRIMARY KEY, Name VARCHAR(100) ); CREATE TABLE Medication ( MedicationID INT PRIMARY KEY, DrugName VARCHAR(100) ); CREATE TABLE Prescription ( DoctorID INT, PatientID INT, MedicationID INT, Dosage VARCHAR(50), PRIMARY KEY (DoctorID, PatientID, MedicationID), FOREIGN KEY (DoctorID) REFERENCES Doctor(DoctorID), FOREIGN KEY (PatientID) REFERENCES Patient(PatientID), FOREIGN KEY (MedicationID) REFERENCES Medication(MedicationID) );
Mapping Cardinalities: One Number Changes Everything
Mapping cardinality defines the maximum number of relationship instances one entity can participate in. The four formal options — one-to-one, one-to-many, many-to-one, many-to-many — dictate physical schema design. A one-to-many cardinality creates a foreign key on the 'many' side. A many-to-many cardinality always demands a junction table. Many-to-one is simply the inverse perspective of one-to-many. Choosing the wrong mapping cardinality guarantees duplicate data or missing links. For example, labeling a department-to-employee relationship as many-to-many would allow an employee to belong to multiple departments simultaneously — which might be correct for a matrix organization, but incorrect for a strict reporting hierarchy. Always audit each relationship with two questions: 'How many of entity A can relate to one B?' and 'How many of B can relate to one A?' The numbers set the foreign key placement and table structure from day one.
// io.thecodeforge — database tutorial -- One-to-Many: One department, many employees CREATE TABLE Department ( DeptID INT PRIMARY KEY ); CREATE TABLE Employee ( EmpID INT PRIMARY KEY, DeptID INT FOREIGN KEY REFERENCES Department(DeptID) ); -- Many-to-Many: Many students, many courses CREATE TABLE Student ( StudentID INT PRIMARY KEY ); CREATE TABLE Course ( CourseID INT PRIMARY KEY ); CREATE TABLE Enrollment ( StudentID INT FOREIGN KEY REFERENCES Student(StudentID), CourseID INT FOREIGN KEY REFERENCES Course(CourseID), PRIMARY KEY (StudentID, CourseID) );
Orphaned Orders Broke Revenue Reports After Cascade Delete
- 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
SELECT student_id, course_id, COUNT(*) FROM enrolments GROUP BY student_id, course_id HAVING COUNT(*) > 1;SHOW INDEX FROM enrolments;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;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>);| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| one_to_many_orders.sql | CREATE TABLE customers ( | One-to-Many |
| many_to_many_enrolments.sql | CREATE TABLE students ( | Many-to-Many |
| one_to_one_user_profiles.sql | CREATE TABLE users ( | One-to-One |
| self_referencing_employees.sql | CREATE TABLE employees ( | Self-Referencing Relationships |
| ForeignKeyEnforcement.sql | CREATE TABLE orders ( | Foreign Keys |
| CompositePrimaryKey.sql | CREATE TABLE enrollments ( | Composite Keys |
| TernaryDoctorPrescription.sql | CREATE TABLE Doctor ( | Ternary and Higher-Degree Relationships |
| CardinalityExamples.sql | CREATE TABLE Department ( | Mapping Cardinalities |
Key takeaways
Common mistakes to avoid
3 patternsStoring multiple IDs in a single column
Forgetting the UNIQUE constraint on a junction table composite key
Using ON DELETE CASCADE everywhere without thinking
Interview Questions on This Topic
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?
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?
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?
Frequently Asked Questions
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.
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.
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?
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's Database Design. Mark it forged?
6 min read · try the examples if you haven't