Home Database Database Relationships Explained: One-to-Many, Many-to-Many & When to Use Each

Database Relationships Explained: One-to-Many, Many-to-Many & When to Use Each

In Plain English 🔥
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.
⚡ Quick Answer
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. Change their address once? You'd need to update hundreds of rows — and miss one, and your data lies to you. Relationships let you store each fact exactly once and reference it everywhere it's needed.

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.sql · SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
-- ─────────────────────────────────────────────────────────
-- 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 BehaviourIf 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.

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.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
-- ─────────────────────────────────────────────────────────
-- 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 TablesDon'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.

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.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
-- ─────────────────────────────────────────────────────────
-- 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.

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.sql · SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
-- ─────────────────────────────────────────────────────────
-- 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
⚠️
Watch Out: Infinite Loop Risk in Recursive CTEsIf your data accidentally creates a cycle — employee A's manager is B, and B's manager is A — a recursive CTE will loop forever and crash. Always add a MAXRECURSION limit (SQL Server: OPTION (MAXRECURSION 100)) or a depth guard in your WHERE clause. In production, add a CHECK at insert time to prevent circular references, or use a nested set / closure table pattern for heavy hierarchical workloads.
AspectOne-to-Many (1:N)Many-to-Many (M:N)One-to-One (1:1)
Real-world exampleCustomer → OrdersStudents ↔ CoursesUser → User Profile
Where does the FK live?On the 'many' (child) tableIn a dedicated junction tableOn the dependent (optional) table
Extra table needed?NoYes — alwaysNo, but sometimes worth it
Can carry extra data?Yes, on the child rowsYes, on the junction table rowsYes, on the dependent table
Query complexitySimple JOINTwo JOINs through junctionSimple LEFT JOIN
Main design riskForgetting the FK constraintMissing UNIQUE on junction pairUnnecessary splitting of one table
Use when...Hierarchy is clear and asymmetricBoth sides have multiple connectionsData is sparse, sensitive, or rarely accessed

🎯 Key Takeaways

  • 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.
  • 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.
  • 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.
  • 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

  • Mistake 1: Storing multiple IDs in a single column — Symptoms include columns named course_ids VARCHAR(200) containing values like '1,2,5'. This breaks every JOIN, makes queries use LIKE instead of indexes, and violates First Normal Form. Fix it by extracting those relationships into a proper junction table — one row per relationship, always.
  • Mistake 2: Forgetting the UNIQUE constraint on a junction table's composite key — Symptom: a student can be inserted into the same course 50 times, inflating COUNT queries and causing duplicate enrolment bugs. Fix it by adding UNIQUE KEY uq_student_course (student_id, course_id) to the junction table, which makes the database enforce the constraint at write time so your application code never has to.
  • Mistake 3: Using ON DELETE CASCADE everywhere without thinking — Symptom: a developer deletes a product category in a test environment and silently wipes thousands of linked products and orders because every FK was set to CASCADE. Fix it by choosing ON DELETE RESTRICT as your safe default, and only switching to CASCADE where the child data is genuinely owned by and meaningless without the parent (e.g. a user's session tokens).

Interview Questions on This Topic

  • QWhat is the difference between a One-to-Many and a Many-to-Many relationship, and how do you physically implement each one in SQL?
  • QYou 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?
  • QA 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

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.

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.

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?

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPrimary Key and Foreign KeyNext →Database Sharding
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged