Modeling Many-to-Many Relationships Correctly
AI generated
SELECT
JOIN
SQL · Data Modeling · Relational Databases
Modeling Many-to-Many Relationships Correctly
Junction tables, key choice and relationship attributes

A many-to-many relationship cannot be represented directly in two tables, it needs a third table acting as a mediator. This article shows how junction tables are built, whether a composite or a surrogate primary key makes more sense, and how additional attributes are stored on the relationship itself without endangering referential integrity.

16 min read Junction table · Composite key · Surrogate key Standard SQL · MySQL · PostgreSQL

1. Why many-to-many cannot be modeled directly

A many-to-many relationship describes a connection where one record in a table can be linked to any number of records in another table, and vice versa. An author writes several books, a book has several authors. A student takes several courses, a course has several students. The relational model, however, only allows a fixed number of column values in a single row, not a variable list of foreign keys. A foreign key in the "books" table that points directly to several rows in the "authors" table cannot be represented in a single column without violating first normal form.

The naive attempt to store several author IDs comma separated in a text column technically works, but destroys any ability to filter, join or enforce referential integrity efficiently. A query for all books by a particular author would come down to a text pattern match with LIKE, which can neither use an index nor protect against typos. A many-to-many relationship therefore needs a dedicated structure that maps the problem cleanly onto the relational model: a third table that mediates between the two main tables.

This pattern is not a workaround, it is the only solution that enables referential integrity, indexability and extensibility at the same time. Anyone who models a many-to-many relationship correctly from the start avoids later migrations where millions of rows have to be moved out of a text column into a normalized structure. The following sections show the full setup, from a simple connecting table to relationships carrying their own attributes.

2. The junction table as the standard solution

The standard solution for any many-to-many relationship is what is called a junction table, also known as an associative table or link table. It contains at least two foreign key columns, one for each participating main table, representing each individual connection as its own row. Instead of a list of author IDs in the book table, one row per author-book combination is created in the "book_author" table. A book with three authors produces three rows, an author with five books produces five rows. This decomposition is the core of correctly modeling any many-to-many relationship in the relational model.

From the perspective of the two main tables, the original many-to-many relationship is thereby resolved into two one-to-many relationships: an author has many entries in the junction table, a book has many entries in the junction table. This resolution is exactly why the junction table works, because one-to-many relationships can be represented in the relational model through a simple foreign key without any trouble. In its simplest form, the junction table itself needs no business meaning of its own, it is pure infrastructure for the relationship.


-- Two main tables, connected via a junction table
CREATE TABLE author (
    author_id   INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name        VARCHAR(150) NOT NULL
);

CREATE TABLE book (
    book_id     INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    title       VARCHAR(255) NOT NULL,
    published   DATE NOT NULL
);

-- Junction table resolving the many-to-many relationship
CREATE TABLE book_author (
    book_id     INT UNSIGNED NOT NULL,
    author_id   INT UNSIGNED NOT NULL,
    PRIMARY KEY (book_id, author_id),
    CONSTRAINT fk_book_author_book
        FOREIGN KEY (book_id) REFERENCES book (book_id)
        ON DELETE CASCADE,
    CONSTRAINT fk_book_author_author
        FOREIGN KEY (author_id) REFERENCES author (author_id)
        ON DELETE CASCADE
);

3. Composite primary key vs. surrogate key

The junction table immediately raises the question of the primary key. The obvious solution is a composite primary key made of both foreign key columns, as shown in the previous example. This variant has a decisive advantage: the combination of book_id and author_id is automatically unique, an additional UNIQUE constraint becomes unnecessary. The composite key expresses exactly what the table represents in business terms, namely precisely one connection between a specific book and a specific author, which must not exist twice.

The alternative is an artificial surrogate key, usually a dedicated AUTO_INCREMENT column, combined with a separate UNIQUE constraint on both foreign key columns. This variant pays off once the junction table itself becomes the target of foreign keys from other tables, for instance when a third table refers to exactly one specific book-author assignment. A single integer foreign key is simpler to reference than a composite one, and ORM frameworks generally cope better with a single ID column than with composite keys.

As a rule of thumb: if the many-to-many relationship stays a pure connecting link without its own identity, the composite primary key is the leaner and clearer solution. If the relationship itself becomes a business entity with its own lifecycle, for example an order line with quantity and price, the advantages of a surrogate key with an accompanying UNIQUE constraint outweigh the alternative.


-- Variant A: composite primary key (no extra identity needed)
CREATE TABLE book_author (
    book_id     INT UNSIGNED NOT NULL,
    author_id   INT UNSIGNED NOT NULL,
    PRIMARY KEY (book_id, author_id)
);

-- Variant B: surrogate key, useful when other tables reference
-- a single relationship row directly
CREATE TABLE book_author (
    book_author_id  BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    book_id         INT UNSIGNED NOT NULL,
    author_id       INT UNSIGNED NOT NULL,
    CONSTRAINT uq_book_author UNIQUE (book_id, author_id)
);

4. Storing attributes on the relationship itself

A many-to-many relationship often does not stop at two bare foreign keys, it carries its own properties. In "book_author" this could be the order of author names on the cover, in an order it is the quantity and the price valid at the time the order was placed. This second example shows exactly why the junction table is more than pure infrastructure: the "order_item" table connects "orders" and "product", but additionally carries quantity and unit_price, which in business terms belong neither to the order nor to the product, but exclusively to the relationship between the two.

These additional columns are the reason why junction tables almost always get a name of their own that goes beyond a plain combination of the two main tables: "order_item" instead of "orders_product". The name signals that the table carries its own business meaning. For the price, an important rule applies: it is copied into the junction table at the time of the order and not reloaded from the product table at read time, because product prices change while historical orders must remain unchanged.


-- Junction table carrying attributes of the relationship itself
CREATE TABLE order_item (
    order_item_id   BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    order_id        BIGINT UNSIGNED NOT NULL,
    product_id      INT UNSIGNED NOT NULL,
    quantity        INT NOT NULL CHECK (quantity > 0),
    unit_price      DECIMAL(10,2) NOT NULL CHECK (unit_price >= 0),
    CONSTRAINT fk_order_item_order
        FOREIGN KEY (order_id) REFERENCES orders (order_id)
        ON DELETE CASCADE,
    CONSTRAINT fk_order_item_product
        FOREIGN KEY (product_id) REFERENCES product (product_id)
        ON DELETE RESTRICT,
    CONSTRAINT uq_order_item UNIQUE (order_id, product_id)
);

5. Foreign keys, ON DELETE and referential integrity

Every foreign key column of a junction table should carry an explicit ON DELETE rule instead of relying on the default behavior. ON DELETE CASCADE automatically removes all related rows in the junction table when the parent record is deleted. For the relationship between book and author this makes sense: if a book is removed entirely from the system, orphaned entries in "book_author" no longer make sense. For "order_item" the situation is different, because orders often may not be deleted for legal reasons, which is why ON DELETE RESTRICT is typically used there for the product foreign key, to prevent accidentally deleting referenced products.

Without foreign key constraints, the database cannot guarantee referential integrity for a many-to-many relationship, even if the application logic is implemented correctly. A failed batch job, a manual database script or a bug in another application sharing the same database can otherwise produce orphaned junction rows pointing to books or authors that no longer exist. These inconsistencies often only surface months later, when a JOIN suddenly returns NULL values where a valid record was expected.

6. Indexing junction tables for performance

A composite primary key on (book_id, author_id) automatically creates a composite index that serves queries by book_id efficiently, because book_id is the leading column of the index. Queries by author_id alone barely benefit from this index, however, because author_id is not at the front. For a many-to-many relationship that is queried frequently in both directions, for instance "all books by an author" and "all authors of a book", a second index that starts with author_id is therefore required.

For junction tables with a surrogate key, this second index is especially important, because the UNIQUE constraint does ensure uniqueness, but depending on column order only supports one of the two search directions efficiently. An additional index on the respective second foreign key column is in practice almost always necessary once the table grows beyond a few tens of thousands of rows. The storage cost of a second index is low compared to the cost of a full table scan on every query going in the other direction.

7. Querying: JOIN strategies for many-to-many

The standard way to query a many-to-many relationship uses two INNER JOINs: one from the starting table to the junction table, one from the junction table to the target table. This double JOIN chain is necessary because no direct foreign key relationship exists between the two main tables. It is important to understand that each row in the result corresponds to one combination of book and author, not one row per book. A book with three authors accordingly appears three times in the result, unless aggregation takes place.

For questions like "how many authors does this book have" or "list all author names comma separated per book", aggregate functions like COUNT and GROUP_CONCAT, or STRING_AGG respectively, come into play. For existence checks, for instance "is there at least one shared author between two books", EXISTS is often more performant than an additional JOIN, because the database can stop searching at the first match instead of materializing every combination.


-- Composite primary key covers lookups by book_id efficiently
-- but a second index is required for the reverse direction
CREATE INDEX idx_book_author_author
    ON book_author (author_id);

-- Standard two-hop join across the junction table
SELECT b.title, a.name
FROM book b
JOIN book_author ba ON ba.book_id = b.book_id
JOIN author a ON a.author_id = ba.author_id
WHERE b.book_id = 17;

-- Aggregate: count authors per book
SELECT b.title, COUNT(ba.author_id) AS author_count
FROM book b
LEFT JOIN book_author ba ON ba.book_id = b.book_id
GROUP BY b.book_id, b.title;

-- Existence check without materializing all combinations
SELECT EXISTS (
    SELECT 1
    FROM book_author ba1
    JOIN book_author ba2 ON ba2.author_id = ba1.author_id
    WHERE ba1.book_id = 17 AND ba2.book_id = 23
) AS shared_author;

8. Self-referencing many-to-many relationships

A many-to-many relationship does not have to exist between two different tables, it can also occur within a single table. The classic example is "related products" in an online shop: a product can be linked to any number of other products in the same table. The junction table then references the same main table twice, using two differently named foreign key columns such as product_id and related_product_id.

For symmetric relationships, where "A related to B" automatically means "B related to A", the question arises whether both directions are stored as separate rows or whether the application establishes the symmetry at query time. Storing both directions doubles the row count, but every query stays a simple filter on one column. Storing only one direction means every query has to check both columns with OR, or the smaller ID always has to be written into the first column to avoid duplicates in the first place.


-- Self-referencing many-to-many: related products
CREATE TABLE related_product (
    product_id          INT UNSIGNED NOT NULL,
    related_product_id  INT UNSIGNED NOT NULL,
    PRIMARY KEY (product_id, related_product_id),
    CHECK (product_id <> related_product_id),
    CONSTRAINT fk_related_source
        FOREIGN KEY (product_id) REFERENCES product (product_id)
        ON DELETE CASCADE,
    CONSTRAINT fk_related_target
        FOREIGN KEY (related_product_id) REFERENCES product (product_id)
        ON DELETE CASCADE
);

9. Common mistakes and anti-patterns

The most common mistake with a many-to-many relationship is a comma separated list of IDs in a text column, usually born from a perceived simplification. It prevents indexing, referential integrity and turns even simple queries into error-prone string operations. A second widespread mistake is missing the second index on the junction table, causing queries in one direction to be fast while queries in the other direction become catastrophically slow, often unnoticed until the table reaches a critical size.

A third mistake is reloading mutable values instead of copying them at the time of the relationship, for instance when an order shows the current instead of the historical product price. A fourth, more subtle mistake is a missing UNIQUE constraint when using a surrogate key: without it, the same combination of two foreign keys can accidentally be inserted more than once, which distorts aggregations like COUNT and produces double counted relationships.

Aspect Composite primary key Surrogate key Recommendation
Uniqueness Automatic through the primary key Only with an additional UNIQUE constraint Composite where possible
Referencing from other tables Cumbersome, two columns required Simple, one ID column is enough Surrogate when referenced
ORM compatibility Composite keys poorly supported in places Broad support across all ORMs Surrogate for ORM use
Index storage overhead No additional index required Additional UNIQUE index required Composite is leaner
Business meaning of the relationship Fitting for a pure link Fitting for its own entity Depends on the use case

Mironsoft

Data modeling, schema design and database consulting

A data model with clean relationships instead of organic sprawl?

We analyze existing schemas, identify problematic many-to-many relationships and bring them onto a clean, performant foundation, including migration of existing data.

Schema review

Analysis of existing table structures for normalization and integrity

Migration

Moving text lists into normalized junction tables without data loss

Performance tuning

Indexing and query optimization for heavily used relationships

10. Summary

A many-to-many relationship can only be correctly represented in the relational model through a junction table that resolves the relationship into two one-to-many relationships. Whether a composite primary key or a surrogate key with an accompanying UNIQUE constraint is used depends on whether the junction table stays pure link infrastructure or becomes a business entity with its own attributes. Foreign keys with explicit ON DELETE rules secure referential integrity, a second index on the respective other foreign key column secures performance in both query directions.

Anyone who follows these basic rules avoids the most common mistakes: comma separated ID lists, missing indexes in the reverse direction, and mutable values that are reloaded at the wrong time instead of being copied at the right time. A cleanly modeled many-to-many relationship stays performant even as data volume grows and can be extended with new attributes without structural rework.

Modeling many-to-many relationships correctly, the essentials at a glance

Junction table

Resolves any many-to-many relationship into two one-to-many relationships, with one row per connection.

Key choice

Composite primary key for pure links, surrogate key with UNIQUE constraint for its own entity.

Referential integrity

Foreign keys with explicit ON DELETE rules instead of implicit default behavior.

Performance

Second index on the respective other foreign key column for both query directions.

11. FAQ: Modeling Many-to-Many Relationships Correctly

1What is a many-to-many relationship in SQL?
A record can be linked to any number of records in another table, and vice versa. It is represented through a junction table, because several foreign keys cannot be stored in one column.
2Why is a comma separated ID list not enough?
It prevents indexing, referential integrity and efficient JOINs. Queries need LIKE instead of index access, and invalid references cannot be prevented.
3Composite or surrogate key on the junction table?
Composite primary key for a pure link without its own identity. Surrogate key once other tables need to reference a single relationship row.
4How do I store attributes on the relationship?
Directly as additional columns in the junction table, for example quantity and unit_price. Mutable values are copied at the time of the relationship, not loaded later.
5Does a junction table need two indexes?
Yes, once the table grows. The composite primary key only efficiently covers the leading column, a second index secures the reverse direction.
6Which ON DELETE rule is fitting?
CASCADE for meaningless orphaned rows, RESTRICT to protect against deleting referenced records like products in historical orders.
7How does self-referencing many-to-many work?
The junction table references the same main table twice through differently named foreign key columns, for example product_id and related_product_id.
8Do I need to store symmetric relationships twice?
Optional. Storing both directions simplifies queries into simple filters, but doubles the row count compared to a one-sided variant.
9How do I count linked records correctly?
COUNT over a LEFT JOIN on the junction table with GROUP BY on the starting table, so that records without a relationship appear with 0 too.
10What is the most common mistake with junction tables?
A missing UNIQUE constraint when using a surrogate key. Without it, combinations can be inserted twice, which distorts aggregations.