Many-to-many
On This Lesson 11 topics
Lesson Overview
In the last lesson we gave each book one author_id. Real books break that rule: a textbook can have three authors, and each of those authors has written other books. "One author, many books" is true, and so is "one book, many authors". When both sides are "many", a single foreign key column cannot express the relationship.
The standard solution is a junction table (also called a link, bridge, or associative table): a third table that holds one row for each pairing. In this lesson you build one, choose its primary key, and learn when it should carry extra columns of its own.
You need foreign keys and one-to-many relationships from the previous lesson, and primary keys from the one before.
Learning Objectives
By the end of this lesson you will be able to:
- Recognize a many-to-many relationship using the two-question test.
- Explain why one foreign key, or a comma-separated list, cannot model it.
- Build a junction table with two foreign keys and a composite primary key.
- Add extra columns to a junction table when they describe the pairing itself.
- Replace a one-to-many design with a many-to-many design when requirements change.
- Read a junction table to answer "which books did this author write?" and "who wrote this book?".
Written Explanation
Spotting many-to-many
Use the two-question test from lesson L03 on each pair of entities:
- Can one A relate to many B?
- Can one B relate to many A?
For authors and books: one author writes many books (yes); one book can have many authors (yes). Both yes means many-to-many. Other examples: students and courses, products and orders, actors and films, books and tags.
Why the obvious attempts fail
Attempt 1: a foreign key on one side. Put author_id in books. Then each book has exactly one author, which cannot represent a co-written book. Put book_id in authors instead and each author has exactly one book. Either way, one side is forced to be "one".
Attempt 2: extra columns. Add author1_id, author2_id, author3_id to books. It works until a book has four authors, and then you must change the table's structure. Most rows have empty cells. Searching "all books by author 7" means checking three columns. Microsoft's guidance on database design calls repeating columns like Class1, Class2, Class3 a sign of design trouble, and recommends putting them in a separate table.
Attempt 3: a list in one cell. A column author_ids holding 3,7,12. A cell must hold a single value (first normal form, lesson L05). The database cannot check that 12 is a real author, cannot find "books by author 7" without taking the text apart, and the list can contain typos like 3;7,,12.
The junction table
The fix is to model the pairing as its own kind of thing. Each row says: "this book was written by this author".
authors(id PK, name)
books(id PK, title, isbn, published_year)
book_authors(book_id FK -> books.id, author_id FK -> authors.id, PK(book_id, author_id))Each row of book_authors is one pairing. A book with three authors has three rows; an author with five books appears in five rows.
| books.id | title |
|---|---|
| 1 | Algorithms Made Simple |
| 2 | Databases for Beginners |
| authors.id | name |
|---|---|
| 1 | Nora Khalil |
| 2 | Sami Hadad |
| 3 | Rana Youssef |
| book_authors.book_id | book_authors.author_id |
|---|---|
| 1 | 1 |
| 1 | 2 |
| 2 | 2 |
| 2 | 3 |
Reading it: book 1 has authors 1 and 2; author 2 appears in books 1 and 2. Everything is stored with ids, so no names are duplicated.
The junction table converts one many-to-many relationship into two one-to-many relationships:
books 1 ---- * book_authors * ---- 1 authorsOne book has many book_authors rows; one author has many book_authors rows; and each book_authors row belongs to exactly one book and one author. The foreign keys go on the "many" side, following the rule from lesson L03, and the junction table is the many side of both.
The primary key of a junction table
Each row is identified by the pair of ids. The PostgreSQL manual uses exactly this pattern in its many-to-many example, an order_items table whose primary key is the pair (product_no, order_id), and notes that the key overlaps with the two foreign keys. We write:
PK(book_id, author_id)This does two jobs at once:
- It prevents duplicates. The same author cannot be recorded twice for the same book, because the pair would repeat.
- It identifies the row, so no separate
idis needed.
Each column alone can repeat (book 1 appears twice, author 2 appears twice), but a pair cannot. Neither part can be NULL, since primary-key columns cannot be NULL. Some designers add a surrogate id anyway; with the pair key, the no-duplicates rule comes free.
Extra columns on a junction table
Sometimes the pairing itself has facts. The columns that describe the relationship, not either entity, belong in the junction table.
book_authors(book_id FK, author_id FK, author_order, role, PK(book_id, author_id))Here author_order says who is listed first and role says "author" or "translator". Neither belongs in books (it differs per author) nor in authors (it differs per book). In an online shop, order_items(order_id, product_id, quantity, unit_price) has quantity, which belongs to one product in one order. The PostgreSQL manual's example junction table has a quantity column too.
Rule of thumb: if a fact needs both ids to make sense, it belongs on the junction table.
Is it a junction table or an entity?
Sometimes a relationship becomes a thing in its own right. Our loans table connects members and books, so it is technically a many-to-many link between them, but it has its own identity: the same member can borrow the same book twice, on different dates. A pair key PK(member_id, book_id) would forbid the second loan. So loans has its own generated id as primary key and two foreign keys. Ask: can the same pair legitimately occur more than once? If yes, give the table its own id. If no, use the pair as the key.
Working in both directions
A junction table lets you travel either way, by following ids as you did with one-to-many:
- Books by author 2: take every
book_authorsrow withauthor_id = 2, then read thebook_idvalues, then look them up inbooks. - Authors of book 1: take every row with
book_id = 1, then look up eachauthor_idinauthors.
SQL joins, in a later module, automate those lookups.
Deleting and the junction table
Remove a book and its pairing rows must go too, otherwise they point at nothing. The PostgreSQL manual's guidance fits: pairings are components of a book and do not exist alone, so deleting the book should delete its pairing rows (cascade). Deleting an author who still has books is different: you may want to refuse, which is what the manual's example does for products that still appear in orders. You choose these actions when you create the table, in a later module.
Examples
Example 1: students and courses (simple)
A student takes many courses; a course has many students.
students(id PK, name)
courses(id PK, title)
enrollments(student_id FK -> students.id, course_id FK -> courses.id, PK(student_id, course_id))| student_id | course_id |
|---|---|
| 1 | 10 |
| 1 | 11 |
| 2 | 10 |
Student 1 is in courses 10 and 11; course 10 has students 1 and 2. Inserting (1, 10) a second time would be refused because the pair is already there.
Example 2: books and authors, with order (practical)
We drop author_id from books and add the junction table with an ordering column.
authors(id PK, name)
books(id PK, title, isbn, published_year)
book_authors(book_id FK -> books.id, author_id FK -> authors.id, author_order, PK(book_id, author_id))| book_id | author_id | author_order |
|---|---|---|
| 1 | 1 | 1 |
| 1 | 2 | 2 |
| 2 | 2 | 1 |
| 2 | 3 | 2 |
To print a citation for book 1 you list its authors ordered by author_order: Nora Khalil, then Sami Hadad. Changing who is first edits one row of book_authors.
Example 3: shop orders with a quantity (realistic)
A shop sells many products; each order contains many products; each product appears in many orders. The pair also needs a quantity and the price at purchase time.
products(id PK, name, current_price)
orders(id PK, customer_id FK -> customers.id, ordered_on)
order_items(order_id FK -> orders.id, product_id FK -> products.id, quantity, unit_price, PK(order_id, product_id))quantity clearly belongs to the pairing: it is "how many of this product in this order". unit_price is the price at the time of the sale, which is deliberately stored on the pairing, because the product's current_price will change later while old orders must keep their original amounts. This is not a normalization violation: the two prices are different facts (a price right now, and a price that was charged then).
Note that one schema commonly uses both patterns: orders has a one-to-many link to customers and a many-to-many link to products.
Backend Insight
Most real applications are full of many-to-many relationships: users and roles, articles and tags, products and categories. They all follow the same junction pattern, so recognizing it makes you fast at schema design.
In Java code, the junction table is often hidden by a framework: a Book object may appear to have a list of Author objects, and the framework reads and writes the junction rows for you. Knowing the table underneath explains the behavior you will see in the database and why queries need to look at three tables.
If your code adds the same pairing twice, a pair primary key makes the database reject the second insert instead of silently creating a duplicate.
Common Mistakes
Mistake 1: Comma-separated ids or names in a column
- Incorrect:
books(id, title, authors)withauthorsset toNora Khalil, Sami Hadad. - Correct: one row per pairing in
book_authors.
Mistake 2: Numbered columns
- Incorrect:
author1_id,author2_id,author3_idinbooks. - Correct: a junction table. It handles one author or fifty without changing the structure.
Mistake 3: Forgetting the uniqueness of the pair
- Incorrect: a junction table with two foreign keys but no primary key, so
(1, 2)can be stored five times. - Correct:
PK(book_id, author_id), or a surrogate id plus a no-duplicates rule on the pair.
Mistake 4: Putting a relationship fact in one of the two tables
- Incorrect:
quantityinproducts, which would mean one quantity per product for all orders. - Correct:
quantityinorder_items, because it needs both an order and a product.
Exercises
Level A: Foundation
A1. Apply the two-question test to each pair and say whether the relationship is one-to-many or many-to-many: (a) teacher and class, where a class has one teacher; (b) student and course; (c) film and actor; (d) customer and order.
Show solution A1
A1. (a) One-to-many (a teacher has many classes, a class has one teacher). (b) Many-to-many. (c) Many-to-many. (d) One-to-many (a customer places many orders, an order belongs to one customer).
A2. What goes in a junction table, and what is its usual primary key?
Show solution A2
A2. One row per pairing, with a foreign key to each side. The usual key is the pair of the two foreign keys, so a pairing cannot be repeated.
A3. Why can't a single author_id column in books model a book with two authors?
Show solution A3
A3. One column holds one author id, so each book could have only one author. A second author would need a second column or a list in a cell, and both break the rules.
Level B: Application
B1. An online recipe site has recipes and ingredients. A recipe uses many ingredients, and an ingredient is used by many recipes. Write the three tables in our notation. Where would you put the amount of each ingredient (for example, 200 grams)? Explain.
Show solution B1
B1.
recipes(id PK, title, instructions)
ingredients(id PK, name)
recipe_ingredients(recipe_id FK -> recipes.id, ingredient_id FK -> ingredients.id, amount, unit, PK(recipe_id, ingredient_id))amount and unit go in recipe_ingredients, because 200 grams describes this ingredient in this recipe and differs between recipes.
B2. A books table has a column tags holding java,sql,web. Redesign it so each tag is a separate row, give the tables and keys, and show the rows for a book with id 4 that has those three tags.
Show solution B2
B2.
books(id PK, title, ...)
tags(id PK, name)
book_tags(book_id FK -> books.id, tag_id FK -> tags.id, PK(book_id, tag_id))With tags java (1), sql (2), web (3), book 4 gets rows (4, 1), (4, 2), (4, 3) in book_tags. Each tag name is stored once in tags.
B3. Using the book_authors data from this lesson, answer by following ids: (a) which authors wrote book 2? (b) in which books does author 2 appear?
Show solution B3
B3. (a) Rows of book_authors with book_id = 2 have author_id 2 and 3, so Sami Hadad and Rana Youssef. (b) Rows with author_id = 2 have book_id 1 and 2, so books 1 and 2.
Level C: Challenge
C1. A hospital has doctors, patients, and appointments. A patient can see the same doctor more than once, on different dates. Design the table for appointments. Should the key be the pair (doctor_id, patient_id)? Justify your answer and give the final table.
Show solution C1
C1. No. A pair key would forbid a second visit by the same patient to the same doctor, which is legitimate. Use an own key: appointments(id PK, doctor_id FK -> doctors.id, patient_id FK -> patients.id, scheduled_at, notes). It is still a link between two entities, but repeated pairings are real events, so it behaves like an entity.
C2. Design the tables for a music app: songs can appear on many playlists, playlists contain many songs in a specific position, and each playlist belongs to one user. Give every table with keys, mark the junction, and say which relationships are one-to-many and which are many-to-many. What stops the same song from occupying the same position twice in one playlist?
Show solution C2
C2.
users(id PK, name)
songs(id PK, title, duration_seconds)
playlists(id PK, name, user_id FK -> users.id)
playlist_songs(playlist_id FK -> playlists.id, song_id FK -> songs.id, position, PK(playlist_id, position))One-to-many: user to playlists. Many-to-many: songs and playlists, through playlist_songs. The key (playlist_id, position) stops two songs sharing a position in a playlist. Whether the same song may appear twice in a playlist is a design choice: with this key it can, at different positions; to forbid repeats, add a no-duplicates rule on (playlist_id, song_id).
Mini Challenge
Choose two entities from a system you know where both sides can be "many" (for example, pupils and clubs, or doctors and clinics). Prove it with the two-question test. Write the three tables in our notation, add at least one extra column to the junction table with a reason, and write four sample junction rows that show one entity on both sides having several pairings. Finally say whether a pair key or an own id is right and why.
Check your work: both foreign keys are in the junction table; there is no list in any cell; the extra column needs both ids to make sense.
Summary & Next Lesson
Key takeaways
- When both sides can be "many", use a junction table with one row per pairing.
- The junction table has two foreign keys; together they usually form its composite primary key, which also prevents duplicate pairings.
- It turns one many-to-many relationship into two one-to-many relationships.
- Facts about the pairing itself (quantity, order, role) live in the junction table.
- If the same pair can legitimately repeat (loans), the table needs its own id.
Next lesson
You can now model most relationships. The next lesson, "Normalization in practice", gives the formal rules behind everything we have been doing and shows how to clean a flat table into properly separated tables.
Self-check
- How do you model "a book has many authors and an author has many books"?
- What is a common primary key for a junction table, and what does it prevent?
- Why is a comma-separated list in a column not an acceptable solution?
- Where does
quantitybelong in a shop's orders and products? - When should a link table get its own id instead of a pair key?
Show answers
- With a junction table such as
book_authors(book_id, author_id), one row per pairing. - The pair of both foreign keys; it prevents the same pairing being stored twice.
- A cell must hold one value; the database cannot check, search, or constrain items inside a list.
- In the junction table
order_items, because it needs both an order and a product to make sense. - When the same pair can legitimately occur more than once, as with repeated loans of the same book by the same member.
References
- PostgreSQL documentation, Constraints. Its
order_itemsexample shows a junction table with two foreign keys, a quantity column, and a composite primary key over both foreign keys, plus the restrict/cascade guidance used above. - Microsoft Learn, Database normalization description. Describes repeating columns such as Class1, Class2, Class3 as a design problem and recommends a separate table linked by keys.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.