How do you implement a many-to-many relationship between two tables in a relational database, and why can't a single foreign key column express it?
answer
- M:N → third table, one FK per side
- Composite PK = identity + no duplicate pairs
- One M:N decomposes into two 1:N
- Link attributes (grade, quantity) live on the link row
- Second index on reversed column order
basics
~20 sUse a third table holding one foreign key to each side, keyed on the pair of them. A single foreign key column stores one value per row, so it cannot record many links from the same row.
solid answer
~60 sA many-to-many relationship is implemented with a **junction table** (also called associative, link, or bridge table). It holds one foreign key column per participating table, and its primary key is normally the composite of those two columns, which also guarantees a pair cannot be recorded twice. A plain foreign key column can hold exactly one value per row, so it can only express "many rows point at one row" — that is 1:N. To let a student have many courses *and* a course have many students, the pairing has to live in its own row. Two practical points I'd add: - Any attribute that describes the *pairing* rather than either side (enrolled_on, grade, role) goes on the junction row. - The composite primary key indexes the pair in one direction only, so I add a second index on the reversed column order to make lookups fast from the other side. Delete behaviour is usually `ON DELETE CASCADE` from both parents: removing a student should remove their enrolments, not orphan them.
code
sql · 11 linesCREATE TABLE enrollment (
student_id BIGINT NOT NULL REFERENCES student(id) ON DELETE CASCADE,
course_id BIGINT NOT NULL REFERENCES course(id) ON DELETE CASCADE,
enrolled_on DATE NOT NULL,
grade SMALLINT,
PRIMARY KEY (student_id, course_id)
);
-- serves lookups that start from the course side
CREATE INDEX enrollment_course_student_idx
ON enrollment (course_id, student_id);go deeper
Be able to name the junction table, put one foreign key to each side in it, and say why a single column can only express one-to-many.
Add the key and constraint detail: composite primary key prevents duplicate pairs, both foreign keys NOT NULL, cascade on delete, relationship attributes live on the link row.
Talk about access paths — the leading-column rule, the second index on the reversed pair, index-only joins, and when a link deserves a surrogate key.
Frame it as choosing a representation: composite-key link table by default; surrogate-keyed associative entity when links are referenced elsewhere or must repeat over time; and be explicit about the write-amplification and constraint cost of each.
## What a many-to-many relationship is A relationship is many-to-many (M:N) when a row on each side can be related to many rows on the other side. Students and courses: one student takes many courses, one course has many students. Authors and books, orders and products, users and roles — all M:N. Contrast with one-to-many (1:N), where only one side is "many": a customer has many orders, but an order belongs to exactly one customer. ## Why a foreign key column cannot do it A foreign key is a column (or set of columns) in one table whose value must match a key value in another table. Because a column holds exactly one value per row, a foreign key can only encode "this row points at that one row". Putting `course_id` on `students` limits a student to one course. Putting `student_id` on `courses` limits a course to one student. Putting both on both sides is worse: it stores the same fact twice with nothing keeping the two copies in agreement. The non-relational escapes people reach for — a comma-separated `course_ids` text column, or repeating columns `course_1, course_2, course_3` — violate first normal form. They cannot be constrained by a foreign key, cannot be joined or indexed sensibly, break as soon as someone needs a fourth course, and force string surgery for the simplest query. ## The junction table The relational answer is to make the relationship itself a table. Each row of the junction table represents one link: ``` enrollment(student_id -> student.id, course_id -> course.id) ``` The M:N relationship is thereby decomposed into two 1:N relationships pointing *into* the junction table: one student has many enrolment rows, one course has many enrolment rows, and each enrolment row references exactly one of each. That is why the mapping rule is often stated as "every M:N becomes a new relation whose key is the union of the two participating keys". ## Keys and constraints The default primary key is the composite `(student_id, course_id)`. It buys three things at once: identity for the row, a guarantee that the same pair cannot be inserted twice, and an index on that column pair. If instead you give the junction table a surrogate `id`, you **must** still add a unique constraint on the pair, or duplicates will appear and every count query becomes wrong. Both foreign keys should be `NOT NULL` — a link to nothing is not a link — and each usually declares `ON DELETE CASCADE`, because a junction row has no meaning once either parent is gone. That is exactly the case where cascade is safe: you are deleting a relationship, not business data. ## Relationship attributes Facts that depend on *both* sides belong on the junction row: the grade a student got in a course, the date a user joined a team, the quantity of a product on an order, the role a person plays on a project. They cannot go on either parent, because a student has one grade *per course*, not one grade. If the junction table carries such columns it is often called an associative entity, and it is a perfectly normal table — it can have its own check constraints, defaults, and audit columns. ## Indexing and access paths The composite primary key index on `(student_id, course_id)` supports lookups that supply `student_id` (the leading column) — "which courses does this student take". It does **not** efficiently support "who is enrolled in this course", because the leading column is not given. So a second index on `(course_id, student_id)` is standard. Both indexes cover the pair entirely, so joins can often be satisfied from the index without touching the table rows. Many databases also require an index on a foreign key column for parent deletes to be efficient, which is another reason the reverse index earns its keep. ## Querying Retrieving the related rows is a two-join query: parent → junction → other parent. Counting per side is a group-by on the junction table alone, which is cheap because it is narrow. This is another advantage over any denormalised representation: the link table is small, dense, and index-friendly. ## Common wrong turns Modelling the pair with nullable foreign keys "just in case"; forgetting the uniqueness constraint when using a surrogate key; storing lists in a text column; or promoting the junction to a full entity with its own `id` before there is any reason to. Start with the composite key and add the surrogate only when something else needs to reference an individual link.
- When would you give the junction table a surrogate primary key instead of the composite one?When an individual link needs to be referenced by other tables — say each enrolment has payment rows or attachments — a single-column key is far more convenient than propagating a two-column key everywhere. Also when the same pair may legitimately recur over time (a student re-taking a course), because the composite key would forbid it. In both cases you keep a unique constraint on the natural pair, or on the pair plus a validity date, so duplicates cannot creep in.
- Someone proposes storing the related ids as a comma-separated string in one column. What do you tell them?It breaks first normal form and gives up everything the database does for you: no foreign key can validate the ids, no index can serve a lookup by a single id, and every query becomes string parsing. Inserting or removing one link means rewriting the whole value, which also makes concurrent updates lose data. A junction table costs one table and gives correctness, indexing and constraints for free.
A junction table is the guest list of a party: neither the people table nor the parties table can hold the pairing, so you keep a separate sheet with one line per (person, party) — and that line is also the natural place to note what dish they brought.
saying these in an interview costs you the question
- Claiming you can model M:N with a foreign key on each side of the two tables
- Storing related ids as a comma-separated list or as repeating columns like tag1, tag2, tag3
- Adding a surrogate id to the junction table and forgetting the unique constraint on the pair, so duplicate links appear
- Putting relationship attributes such as a grade or quantity on one of the parent tables
- Assuming the composite primary key alone makes lookups fast from both directions