9.9 Many-to-Many Relationships
Table of Contents
Understanding Many-to-Many Relationships
Many-to-many relationships appear often in real applications. They describe situations where:
- One record can be related to many other records, and
- Each of those records can also be related to many records on the other side.
You already saw one-to-one and one-to-many. Many-to-many builds on top of them, so here we focus on what is special about many-to-many.
Typical examples:
- Students and Courses
- A student can enroll in many courses.
- A course has many students.
- Users and Roles
- A user can have many roles.
- A role can belong to many users.
- Products and Categories
- A product can belong to many categories.
- A category can contain many products.
- Authors and Books
- A book can have multiple authors.
- An author can write multiple books.
Relational databases cannot represent many-to-many with just two tables. You need a third table, the join table (also called a junction table or link table).
Key rule:
Relational databases implement a many-to-many relationship using two one-to-many relationships with an intermediate join table.
The Join Table Concept
A join table is a separate table that holds pairs of foreign keys. Each row connects one record from table A with one record from table B.
General pattern:
- Table A, for example
students - Table B, for example
courses - Join table, for example
student_coursesorenrollments
Basic structure of a join table:
- A foreign key to table A
- A foreign key to table B
- Usually a composite primary key on (A_id, B_id)
- Sometimes extra columns that describe the relationship itself, like
enrolled_at,role,grade,status
Example structure:
| Table | Columns |
|---|---|
| students | id (PK), name, email |
| courses | id (PK), title, description |
| student_courses | student_id (FK β students.id), course_id (FK β courses.id), enrolled_at |
Here student_courses does not represent a real-world object like "a separate entity" by itself. It represents the fact that a student takes a course.
Implementing Many-to-Many: Student and Course
Let us walk through an example from scratch.
Step 1: Main tables
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL
);So far this is just two independent tables.
Step 2: Join table
CREATE TABLE student_courses (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students (id),
FOREIGN KEY (course_id) REFERENCES courses (id)
);Important design decisions here:
student_idandcourse_idare bothNOT NULL, since a link without one side does not make sense.PRIMARY KEY (student_id, course_id)makes the pair unique. The same student cannot be enrolled in the same course twice.- Each foreign key ensures data consistency:
- A row in
student_coursesmust reference a real student. - It must also reference a real course.
Important design rule:
In many-to-many join tables, you usually create a composite primary key from the two foreign keys, for example (student_id, course_id), to prevent duplicate links.
Inserting Data into a Many-to-Many Relationship
You always insert into the main tables first, then insert the links in the join table.
1. Insert records into main tables
INSERT INTO students (name) VALUES
('Alice'),
('Bob'),
('Charlie');
INSERT INTO courses (title) VALUES
('Math 101'),
('History 201'),
('Programming 101');Assume the database assigns IDs:
students:
| id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Charlie |
courses:
| id | title |
|---|---|
| 1 | Math 101 |
| 2 | History 201 |
| 3 | Programming 101 |
2. Link students to courses
Example rules:
- Alice takes Math 101 and Programming 101
- Bob takes History 201 only
- Charlie takes Math 101 and History 201
INSERT INTO student_courses (student_id, course_id) VALUES
(1, 1), -- Alice - Math 101
(1, 3), -- Alice - Programming 101
(2, 2), -- Bob - History 201
(3, 1), -- Charlie - Math 101
(3, 2); -- Charlie - History 201
Now the student_courses table:
| student_id | course_id |
|---|---|
| 1 | 1 |
| 1 | 3 |
| 2 | 2 |
| 3 | 1 |
| 3 | 2 |
The many-to-many relationship is completely represented by this table.
Querying Many-to-Many Data
You use JOINs to read data across a many-to-many relationship. The join table sits in the middle.
Find courses that a specific student is taking
Goal: Get all course titles for Alice (id = 1).
SELECT c.id, c.title
FROM courses AS c
JOIN student_courses AS sc
ON c.id = sc.course_id
WHERE sc.student_id = 1;Result:
| id | title |
|---|---|
| 1 | Math 101 |
| 3 | Programming 101 |
Explanation:
- Start from
courses c. - Join
student_courses scwherec.id = sc.course_id, so each course is matched to its enrollment links. - Limit to
sc.student_id = 1, which is Alice.
You can also start from the other direction.
Find students enrolled in a specific course
Goal: Get all students in Math 101 (course id = 1).
SELECT s.id, s.name
FROM students AS s
JOIN student_courses AS sc
ON s.id = sc.student_id
WHERE sc.course_id = 1;Result:
| id | name |
|---|---|
| 1 | Alice |
| 3 | Charlie |
Show all students with their courses
You can join both sides and select both columns.
SELECT s.name AS student_name, c.title AS course_title
FROM students AS s
JOIN student_courses AS sc
ON s.id = sc.student_id
JOIN courses AS c
ON c.id = sc.course_id
ORDER BY s.name, c.title;Result:
| student_name | course_title |
|---|---|
| Alice | Math 101 |
| Alice | Programming 101 |
| Bob | History 201 |
| Charlie | History 201 |
| Charlie | Math 101 |
This is a very common pattern: main table A β join table β main table B.
Deleting and Updating in Many-to-Many
When you delete or change records in one table, you must consider the join table.
Removing a single link
To remove only the link between Alice and Math 101:
DELETE FROM student_courses
WHERE student_id = 1 AND course_id = 1;Alice still exists. Math 101 still exists. Only that relationship is removed.
Deleting a main record
If you try to delete a student who still has links in student_courses, the database will usually reject it unless you configured cascading.
Example:
DELETE FROM students WHERE id = 1;
If student_courses.student_id has a regular foreign key, you will get a foreign key violation error, because there are rows in student_courses that reference Alice.
Two options:
- Delete the links first:
DELETE FROM student_courses WHERE student_id = 1;
DELETE FROM students WHERE id = 1;- Use
ON DELETE CASCADEon the foreign key when you create the join table:
CREATE TABLE student_courses (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students (id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses (id) ON DELETE CASCADE
);
With ON DELETE CASCADE, when you delete a student, the database automatically deletes the related rows in student_courses.
Cascade rule:
If you use ON DELETE CASCADE on join table foreign keys, deleting a row in a main table will automatically remove all its links in the join table.
Join Table With Extra Attributes
Sometimes the relationship itself has data. For example, for student_courses you may want:
- The date when the student enrolled
- The final grade
- Whether the student is currently active in the course
In this case, the join table is not just a technical detail. It represents a real domain entity, for example "Enrollment".
Example: Enrollment with extra columns
CREATE TABLE enrollments (
id SERIAL PRIMARY KEY,
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_at TIMESTAMP NOT NULL DEFAULT NOW(),
grade NUMERIC(3,1), -- for example 0.0 to 10.0
status TEXT NOT NULL DEFAULT 'active',
UNIQUE (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students (id),
FOREIGN KEY (course_id) REFERENCES courses (id)
);Changes compared to the simple join table:
- We have an auto-increment primary key
id. (student_id, course_id)is nowUNIQUE, not the primary key.- Extra columns
enrolled_at,grade,status.
The relationship is still many-to-many, but the join table now holds richer information.
Query with extra attributes
Get all students with their courses and grades:
SELECT
s.name AS student_name,
c.title AS course_title,
e.grade,
e.status,
e.enrolled_at
FROM enrollments AS e
JOIN students AS s ON s.id = e.student_id
JOIN courses AS c ON c.id = e.course_id
ORDER BY s.name, c.title;Many-to-Many Between Products and Categories
Another classic example: products and categories.
- A product can be in multiple categories.
- A category includes many products.
Table design
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL
);
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE product_categories (
product_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (product_id, category_id),
FOREIGN KEY (product_id) REFERENCES products (id),
FOREIGN KEY (category_id) REFERENCES categories (id)
);Inserting data
INSERT INTO products (name, price) VALUES
('Laptop', 1200.00),
('Mouse', 20.00),
('Keyboard', 50.00);
INSERT INTO categories (name) VALUES
('Electronics'),
('Computers'),
('Accessories');Assume IDs:
- Products:
| id | name |
|---|---|
| 1 | Laptop |
| 2 | Mouse |
| 3 | Keyboard |
- Categories:
| id | name |
|---|---|
| 1 | Electronics |
| 2 | Computers |
| 3 | Accessories |
Define relationships:
- Laptop in Electronics and Computers
- Mouse in Electronics and Accessories
- Keyboard in Accessories only
INSERT INTO product_categories (product_id, category_id) VALUES
(1, 1), -- Laptop - Electronics
(1, 2), -- Laptop - Computers
(2, 1), -- Mouse - Electronics
(2, 3), -- Mouse - Accessories
(3, 3); -- Keyboard - AccessoriesQuery examples
All categories for a given product (Laptop, id = 1):
SELECT c.name
FROM categories AS c
JOIN product_categories AS pc
ON c.id = pc.category_id
WHERE pc.product_id = 1;All products in a given category (Accessories, id = 3):
SELECT p.name, p.price
FROM products AS p
JOIN product_categories AS pc
ON p.id = pc.product_id
WHERE pc.category_id = 3;Products with their categories:
SELECT
p.name AS product_name,
c.name AS category_name,
p.price
FROM products AS p
JOIN product_categories AS pc
ON p.id = pc.product_id
JOIN categories AS c
ON c.id = pc.category_id
ORDER BY p.name, c.name;Detecting Many-to-Many in Requirements
When you design a database, you can identify many-to-many situations in the requirements text.
Look for sentences that contain:
- "A X can have many Y, and a Y can have many X."
- "Users can be in many groups, and each group has many users."
- "Posts can have many tags, and each tag can belong to many posts."
Some patterns:
| Requirement sentence | Relationship type |
|---|---|
| Each order belongs to exactly one customer. | One-to-many |
| A customer can place many orders. | One-to-many |
| A book has one publisher, a publisher publishes many books. | One-to-many |
| A student can enroll in many courses, each course many students. | Many-to-many |
| A post can have many tags, a tag can be used for many posts. | Many-to-many |
| A user can have many roles, a role can be assigned to many users. | Many-to-many |
Whenever both directions say "many", you normally want a join table.
Common Design Patterns and Pitfalls
Naming conventions
Good practice is to give clear names:
- Join table describing what it connects:
student_coursesproduct_categoriesuser_roles- Sometimes you use a real domain name:
enrollmentsmembershipssubscriptions
Avoid duplicates
Always enforce uniqueness of the pair of foreign keys. This can be done through:
- Composite primary key:
PRIMARY KEY (student_id, course_id)- Or unique constraint:
UNIQUE (student_id, course_id)without a composite primary key, you might accidentally insert the same pair multiple times.
Direct foreign keys between main tables
A common beginner mistake is to try to add an array or a multiple-value column instead of using a join table.
Bad idea examples:
- Adding a
course_idscolumn tostudentswith a comma separated list like"1,3,7". - Adding an
INT[]array of course IDs in students.
Problems with this approach:
- You cannot enforce referential integrity with foreign keys easily.
- Queries become complex and inefficient.
- It breaks normalization principles.
The normalized and correct relational design uses the join table.
Do not store multiple IDs in a single column (like "1,3,7") to model many-to-many. Always use a separate join table.
Many-to-Many versus Two One-to-Many
Internally, a many-to-many is implemented as two one-to-many relationships:
- One student has many
student_courses. - One course has many
student_courses.
You can think of it as:
students1 β manystudent_coursescourses1 β manystudent_courses
Then you use the join table to "cross" from students to courses or from courses to students.
Summary
- Many-to-many relationships connect records so that each side can have multiple related records on the other side.
- Relational databases do not have a direct "many-to-many" feature. You implement it with a join table that holds foreign keys to both sides.
- The join table typically uses a composite primary key
(a_id, b_id)or a unique constraint on that pair. - You always:
- Insert into the main tables first.
- Insert relationships into the join table.
- Query across both sides using JOINs through the join table.
- If the relationship itself has data, you add extra columns to the join table, which often gets its own name like
enrollmentsormemberships. - Never try to model many-to-many by storing lists of IDs in a single column. Use a normalized join table instead.
Understanding many-to-many relationships and join tables is essential for real-world backend applications, because you will use this pattern in almost every nontrivial database design.
Views: 6
KAHIBARO