Many-to-many, and the join table
A book belongs to several categories. A category contains several books. Neither table can hold the other's key in a column, because a column holds one value. The answer is a third table, and it is the most useful structure in the relational model after the foreign key itself.
Why a column cannot do it
-- no
CREATE TABLE books (
id bigint PRIMARY KEY,
title text,
category_ids text -- '3,7,9'
);
That is a list in a string, and it gives up everything:
- No foreign key, so category 47 can be in there when no such category exists.
- No index, so "all books in category 7" scans every row and does string matching — and matches category 17 and 70 too, unless you are very careful.
- No count of books per category without parsing.
- Adding or removing one means reading, parsing, editing and writing the whole string, which is a lost update waiting to happen.
PostgreSQL has real arrays and jsonb, which are better than a comma string and are still
the wrong tool here. Module 10 covers when they are right; for a relationship between two
tables, they are not.
The join table
CREATE TABLE book_categories (
book_id bigint NOT NULL REFERENCES books(id) ON DELETE CASCADE,
category_id bigint NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (book_id, category_id)
);
books book_categories categories
id ◀────────── book_id id
title category_id ──────────▶ name
Two foreign keys, and a composite primary key of both. Four decisions in five lines, each doing work:
The composite primary key means a book cannot be in the same category twice. Without it you would eventually have duplicates from a double-clicked form.
ON DELETE CASCADE on both is correct here, from the foreign-key lesson: a link is
meaningless without either end.
No surrogate id. The pair is the identity. Adding an id would let the same pair be
stored twice unless you also added a UNIQUE (book_id, category_id), which is the composite
key you just declined to use.
Column order matters for the index. PRIMARY KEY (book_id, category_id) creates an
index that supports lookups by book_id, and by the pair — but not by category_id
alone. Since "all books in this category" is a query you will certainly run:
CREATE INDEX ON book_categories (category_id);
That second index is easy to forget and module 7 explains exactly why it is needed.
Names: book_categories — both tables, joined, plural. Other conventions exist
(books_categories, book_category); pick one and be consistent.
Querying across it
Both joins, every time:
SELECT c.name, count(*) AS books
FROM categories c
JOIN book_categories bc ON bc.category_id = c.id
GROUP BY c.id, c.name
ORDER BY books DESC
LIMIT 5;
name | books
--------------+-------
Contemporary | 12
Non-fiction | 10
Translation | 10
Classics | 9
Poetry | 9
(5 rows)
Note this one only needed one join, because the count is of link rows and the book details were not wanted. Going all the way across needs both:
SELECT b.title, c.name
FROM books b
JOIN book_categories bc ON bc.book_id = b.id
JOIN categories c ON c.id = bc.category_id
WHERE c.name = 'Poetry'
ORDER BY b.title;
Join only as far as you need. If you have a category_id already, you do not need the
categories table at all.
The fan-out, again
Joining across a join table multiplies rows:
books 40 rows
book_categories 83 rows
joined 83 rows ← a book in 3 categories appears 3 times
So sum(b.copies) across that join gives 202 instead of 95, as module 3 demonstrated.
Aggregate before you join, or count distinct. The habit, once more:
-- books with how many categories, without fanning out anything else
SELECT b.title, c.n
FROM books b
JOIN (SELECT book_id, count(*) AS n FROM book_categories GROUP BY book_id) c
ON c.book_id = b.id;
Collapsing the many into one row
Usually what a screen wants — one row per book, with its categories together:
SELECT b.title,
string_agg(c.name, ', ' ORDER BY c.name) AS categories
FROM books b
JOIN book_categories bc ON bc.book_id = b.id
JOIN categories c ON c.id = bc.category_id
GROUP BY b.id, b.title
ORDER BY b.title
LIMIT 5;
string_agg with an ORDER BY inside it — without that the order is arbitrary and the
output changes between runs, which makes tests flap.
array_agg gives a real array rather than a string, and json_agg gives JSON, which is
what an API usually wants. These three turn "many rows" into "one row with a list" and are
the answer to a lot of awkward application code.
EXISTS for "in any of these"
-- books in at least one of two categories, with no duplicates
SELECT b.title FROM books b
WHERE EXISTS (
SELECT 1 FROM book_categories bc
JOIN categories c ON c.id = bc.category_id
WHERE bc.book_id = b.id AND c.name IN ('Poetry', 'Classics')
);
A join would return a book in both categories twice and you would reach for DISTINCT.
EXISTS asks a yes/no question and cannot fan out — which is the point of the DISTINCT
warning from module 3.
"In all of these" — the relational division
Harder, and worth seeing once:
SELECT b.title
FROM books b
JOIN book_categories bc ON bc.book_id = b.id
JOIN categories c ON c.id = bc.category_id
WHERE c.name IN ('Poetry', 'Classics')
GROUP BY b.id, b.title
HAVING count(DISTINCT c.name) = 2;
Keep the matching links, group per book, and require that both names were present. The
DISTINCT inside count matters — without it, a duplicate link would satisfy the condition
on its own.
This shape — "has all of the following" — comes up constantly in filtering and search.
When the join table has attributes
The moment a link carries information of its own, it is an entity:
CREATE TABLE loans (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
book_id bigint NOT NULL REFERENCES books(id),
member_id bigint NOT NULL REFERENCES members(id),
borrowed_on date NOT NULL,
due_on date NOT NULL,
returned_on date
);
loans is a many-to-many between books and members — and it has its own dates, its own
identity, and the same pair can occur many times. So it gets a surrogate id and no
composite key.
That is the distinction:
A pure link, no attributes, each pair once → composite key, no id
A link with its own data or repeated pairs → its own id; it is an entity
Getting this wrong in the second direction is the common mistake: a composite key on
(book_id, member_id) in loans would mean a member could never borrow the same book
twice.
Check your work
Why a column cannot hold a many-to-many. A column holds one value — and a list in a string gives up foreign keys, indexes, counting and safe editing.
What the join table is. Two foreign keys, with the pair as the primary key.
What the composite key prevents. The same pair being recorded twice.
Why no surrogate id on a pure join table. The pair is the identity.
What the composite key's index does not support. Lookups by the second column alone — add a separate index.
Why ON DELETE CASCADE is right here. A link is meaningless without either end.
What joining across it does to row counts. Multiplies them — 40 books become 83 rows.
How to collapse the many into one row. string_agg, array_agg or json_agg, with an
ORDER BY inside.
Why EXISTS beats a join for "in any of these". It cannot fan out, so no DISTINCT is
needed.
How to express "in all of these". Group per item and HAVING count(DISTINCT ...) = n.
When a join table should get its own id. When it has attributes, or the same pair can
occur more than once.
Why loans is not a pure join table. It has dates, and a member can borrow the same book
twice.
Practice
- Add a
category_ids textcolumn to a copy ofbooksand store'3,7,9'. Then write the query for "all books in category 7" and find the bug involving category 17. - Write the
book_categoriestable from memory, with both foreign keys and the composite key. - Insert the same pair twice and read the error.
- Count books per category.
- List every book in Poetry, with its title.
- Check whether an index on
category_idalone exists. Add one and explain why the composite key did not cover it. - Count the rows in
books, inbook_categories, and in the join. Explain all three. - Compute
sum(copies)across the join and explain why it is wrong. - Fix it with a pre-aggregated subquery.
- Produce one row per book with its categories as a comma-separated string, ordered.
- Do the same with
array_aggand withjson_agg. - Find books in Poetry or Classics using a join, notice the duplicates, then rewrite it
with
EXISTS. - Find books in Poetry and Classics.
- Argue whether
loansshould have had a composite primary key. Then try to add one and see what it would forbid.
Official documentation
- PostgreSQL — Constraints — Composite primary keys and the cascade options.
- PostgreSQL — Aggregate functions —
string_agg,array_aggandjson_agg, including theORDER BYinside an aggregate. - PostgreSQL — Subquery expressions —
EXISTS, and why it does not multiply rows. - PostgreSQL — Multicolumn indexes — Why a
(a, b)index does not help a lookup onb.
Next: subqueries and self joins.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship