---
title: 'How to Use Junction Tables in Airtable for Many-to-Many Relationships'
description: 'Master Airtable junction tables — when you need one, how to build it, and real examples for enrollments, order line items, event attendance, and project assignments.'
canonical_url: 'https://www.business-automated.com/tutorials/airtable-junction-tables-many-to-many'
md_url: 'https://www.business-automated.com/tutorials/airtable-junction-tables-many-to-many.md'
last_updated: 2026-07-13
---

The phrase "[Airtable](/airtable-consultant) junction table" appears in roughly zero of Airtable's onboarding materials. It also appears in roughly every well-designed Airtable base running a real business. The reason for the gap: junction tables are the most important pattern in relational data modelling, and Airtable's visual approach hides them just enough that most builders go years without learning the name.

This guide explains what a junction table is, when you need one, and how to build it cleanly. We will work through four real business examples — student enrollments, order line items, event attendance, and project assignments — with the exact field configurations, primary-field formulas, and rollup patterns. If you have not yet mastered the building blocks, [linked records](/tutorials/airtable-linked-records-explained), [lookups](/tutorials/how-to-use-airtable-lookup-fields), and [rollups](/tutorials/how-to-use-airtable-rollup-fields) are prerequisites — this article assumes you have them.

## What This Guide Covers

- What a junction table is and when you need one
- Direct many-to-many vs junction table — the test
- Building a junction step-by-step
- Example 1: Student enrollments (grades, dates)
- Example 2: Order line items (quantity, price)
- Example 3: Event attendance (status, check-in)
- Example 4: Project assignments (role, allocation)
- Rollup patterns that pay for the extra table
- Common mistakes and how to migrate from direct links
- When a junction table is the wrong answer

## What a Junction Table Actually Is

A junction table is a third table that sits between two other tables and stores the **relationship** as a record. Each row on the junction has at least two linked-record fields — one pointing at each parent — and additional fields that describe the connection.

In classical database terms, this is called an associative entity or a bridge table. In Airtable, the most common names are:

- Enrollments (Students × Classes)
- Order Items / Order Lines (Orders × Products)
- Registrations / Attendance (Events × Attendees)
- Assignments (Projects × Team Members)
- Stock Movements (Products × Warehouses)
- Memberships (People × Groups)

The pattern is always the same. The relationship gets its own table because the relationship has its own data.

## Direct Many-to-Many vs Junction Table: The Test

[Airtable supports direct many-to-many links](/tutorials/airtable-linked-records-explained) — just add a linked-record field on each side and let both fields hold multiple records. For simple "is-a-member-of" relationships, that is enough.

You need a junction table when **the relationship has its own attributes.** Walk this checklist:

| Question                                                | Direct link works? | Junction needed? |
| ------------------------------------------------------- | ------------------ | ---------------- |
| Just need to know which records are connected?          | Yes                | No               |
| Need a quantity on each connection (3 widgets per order)? | No               | Yes              |
| Need a date specific to the connection (enrollment date)? | No             | Yes              |
| Need a role or status on the connection (lead, member)? | No                 | Yes              |
| Need to score or rank the connection (grade, rating)?   | No                 | Yes              |
| Need to record connections changing over time?          | No                 | Yes              |

If any answer in the right column is Yes, you need a junction table. The signal is always: "I want to store a field, but there is no record to put it on." That missing record is the junction.

> Direct many-to-many links record that a relationship exists. Junction tables record that a relationship exists *and what it is*.

## Building a Junction Table — The Universal Recipe

The exact same five steps work for every junction table you will ever build.

1. **Create a new table** named after the relationship (Enrollments, Order Items, Assignments). Name it as a noun describing what one row represents — one enrollment, one order item, one assignment.
2. **Add a linked-record field** pointing to the first parent table. Restrict it to a single record per cell.
3. **Add a linked-record field** pointing to the second parent table. Restrict it to a single record per cell.
4. **Add the relationship's own fields** — Quantity, Role, Date, Grade, Status, Notes, anything that describes this specific connection.
5. **Set the primary field to a formula** that concatenates identifying fields from both linked records via lookups. This makes the junction rows readable when they show up on parent records.

That is the whole recipe. The remaining work is choosing useful rollups on each parent to surface the junction data back where users want to see it.

## Example 1: Student Enrollments

The canonical many-to-many. Students take many Classes. Classes have many Students. Each enrollment has its own grade, enrollment date, and attendance rate — none of which belong on either Student or Class.

### Tables

- **Students** — Name, Email, Year, Major
- **Classes** — Class Name, Subject, Term, Credits
- **Enrollments** (junction) — links to Student, Class, plus enrollment data

### Enrollments table fields

| Field            | Type            | Configuration                                       |
| ---------------- | --------------- | --------------------------------------------------- |
| Identifier       | Formula (primary) | `{Student Name} & " — " & {Class Name}`           |
| Student          | Linked record   | → Students, single record                           |
| Class            | Linked record   | → Classes, single record                            |
| Student Name     | Lookup          | Student → Name                                      |
| Class Name       | Lookup          | Class → Class Name                                  |
| Enrollment Date  | Date            |                                                     |
| Status           | Single select   | Enrolled, Completed, Dropped, Failed                |
| Final Grade      | Number          | 0–100                                               |
| Letter Grade     | Formula         | Bucketed from Final Grade (see [IF statements](/tutorials/airtable-if-statements-complete-guide)) |
| Credits Earned   | Formula         | `IF({Status}="Completed", {Class Credits}, 0)`     |

### Rollups on Students

| Rollup          | Source                                                          |
| --------------- | --------------------------------------------------------------- |
| Total Credits   | Enrollments → Credits Earned, `SUM(values)`                     |
| GPA             | Enrollments → Final Grade, condition Status = Completed, `AVERAGE(values)` |
| Active Classes  | Enrollments → Class Name, condition Status = Enrolled, COUNT    |

### Rollups on Classes

| Rollup          | Source                                                            |
| --------------- | ----------------------------------------------------------------- |
| Class Size      | Enrollments → Student Name, condition Status = Enrolled, COUNT    |
| Class Average   | Enrollments → Final Grade, condition Status = Completed, AVERAGE  |
| Pass Rate       | Two rollups (Passed count / Completed count) feeding a formula    |

The Enrollments table now answers questions that a direct Students ↔ Classes link could not even hold: "What was Maya's grade in CS 101?" "What is the average grade in CS 101 this term?" "Which classes did Maya drop?"

## Example 2: Order Line Items

E-commerce, B2B sales, agency services — all the same pattern. Orders have many Products. Products are in many Orders. Each line item has quantity, unit price, and a per-line total.

### Tables

- **Orders** — Order Number, Customer, Order Date, Status
- **Products** — SKU, Product Name, List Price, Stock
- **Order Items** (junction)

### Order Items fields

| Field          | Type              | Configuration                                          |
| -------------- | ----------------- | ------------------------------------------------------ |
| Line ID        | Formula (primary) | `{Order Number} & " / " & {Product SKU}`               |
| Order          | Linked record     | → Orders, single                                       |
| Product        | Linked record     | → Products, single                                     |
| Order Number   | Lookup            | Order → Order Number                                   |
| Product SKU    | Lookup            | Product → SKU                                          |
| List Price     | Lookup            | Product → List Price                                   |
| Quantity       | Number            |                                                        |
| Discount %     | Number            | 0–100                                                  |
| Line Total     | Formula           | `{Quantity} * {List Price} * (1 - {Discount %}/100)`   |

### Rollups on Orders

| Rollup        | Source                                          |
| ------------- | ----------------------------------------------- |
| Subtotal      | Order Items → Line Total, `SUM(values)`         |
| Item Count    | Order Items → Quantity, `SUM(values)`           |
| Product List  | Order Items → Product SKU, `ARRAYJOIN(values, ", ")` |

### Rollups on Products

| Rollup           | Source                                                            |
| ---------------- | ----------------------------------------------------------------- |
| Units Sold       | Order Items → Quantity, condition Order Status = Shipped, SUM     |
| Revenue          | Order Items → Line Total, condition Order Status = Shipped, SUM   |
| Open Orders      | Order Items → Order Number, condition Order Status = Pending, COUNTA |

A direct Orders ↔ Products link has nowhere to put Quantity. The whole point of an order is to record how many of each product the customer is buying — junction table is mandatory.

## Example 3: Event Attendance

Events have many Attendees. Attendees can attend many Events. Each registration has a status, ticket type, dietary needs, and a check-in time.

### Tables

- **Events** — Event Name, Date, Venue, Capacity
- **Attendees** — Name, Email, Company, Title
- **Registrations** (junction)

### Registrations fields

| Field          | Type              | Configuration                                  |
| -------------- | ----------------- | ---------------------------------------------- |
| Reg ID         | Formula (primary) | `{Attendee Name} & " @ " & {Event Name}`       |
| Event          | Linked record     | → Events, single                               |
| Attendee       | Linked record     | → Attendees, single                            |
| Attendee Name  | Lookup            | Attendee → Name                                |
| Event Name     | Lookup            | Event → Event Name                             |
| Ticket Type    | Single select     | General, VIP, Speaker, Sponsor                 |
| Status         | Single select     | Registered, Confirmed, Cancelled, Attended    |
| Registered At  | Created time      |                                                |
| Checked In At  | Date with time    |                                                |
| Dietary Notes  | Long text         |                                                |

### Rollups on Events

| Rollup            | Source                                                                  |
| ----------------- | ----------------------------------------------------------------------- |
| Total Registered  | Registrations → Status, condition Status != Cancelled, COUNTA           |
| Total Attended    | Registrations → Checked In At, COUNTA                                   |
| VIP Count         | Registrations → Reg ID, condition Ticket Type = VIP, COUNTA             |
| Diet Summary      | Registrations → Dietary Notes, `ARRAYJOIN(ARRAYCOMPACT(values), "; ")`  |

The dietary notes example is where junction tables shine: the data attaches to the (attendee, event) pair, not to either side individually. Maya might be vegetarian at one event and have no notes at another.

## Example 4: Project Assignments

Projects have many Team Members. Team Members work on many Projects. Each assignment has a role, an allocation percentage, and start/end dates.

### Tables

- **Projects** — Project Name, Client, Start, End, Status
- **Team Members** — Name, Email, Department, Capacity
- **Assignments** (junction)

### Assignments fields

| Field         | Type              | Configuration                                          |
| ------------- | ----------------- | ------------------------------------------------------ |
| Assignment    | Formula (primary) | `{Member Name} & " on " & {Project Name}`              |
| Project       | Linked record     | → Projects, single                                     |
| Team Member   | Linked record     | → Team Members, single                                 |
| Project Name  | Lookup            | Project → Project Name                                 |
| Member Name   | Lookup            | Team Member → Name                                     |
| Role          | Single select     | Lead, Designer, Developer, PM, Reviewer                |
| Allocation %  | Number            | 0–100                                                  |
| Start Date    | Date              |                                                        |
| End Date      | Date              |                                                        |
| Hourly Rate   | Number            | Optional override; else use the member's default       |

### Rollups on Projects

| Rollup           | Source                                                              |
| ---------------- | ------------------------------------------------------------------- |
| Team Size        | Assignments → Member Name, COUNTA                                   |
| Roles Covered    | Assignments → Role, `ARRAYUNIQUE(values)`                           |
| Total Allocation | Assignments → Allocation %, `SUM(values)`                           |

### Rollups on Team Members

| Rollup            | Source                                                                 |
| ----------------- | ---------------------------------------------------------------------- |
| Current Projects  | Assignments → Project Name, condition Project Status = Active          |
| Total Allocation  | Assignments → Allocation %, condition Project Status = Active, SUM     |
| Over-allocated    | Formula on Team Member: `IF({Total Allocation} > 100, "OVER", "OK")`  |

The "Over-allocated" flag is a single formula on Team Member that catches anyone scheduled at over 100% across active projects — a metric impossible to compute without the Assignments junction.

## The Primary Field Formula Pattern

Every junction record needs a meaningful primary field. The default is the autonumber or first-created field, which produces useless cells like "Item 1, Item 2, Item 3" when junctions show up on parent records.

The fix is a formula primary that concatenates lookups from both sides:

```
{Member Name} & " on " & {Project Name}
```

Or for orders:

```
{Order Number} & " / " & {Product Name} & " (" & {Quantity} & ")"
```

Now when you open a Project, the Assignments cell shows "Maya on Acme Rebrand" — not "rec1xJ4...". Users can read junction cells without clicking through.

For more on building good formula primary fields, see the [formulas cheat sheet](/tutorials/airtable-formulas-cheat-sheet) and the [text formulas guide](/tutorials/airtable-text-formulas).

## Migrating from a Direct Many-to-Many

Many bases start with a direct Students ↔ Classes link, then realise they need grades. To migrate without losing data:

1. **Create the Enrollments table** with the two linked fields (Student, Class) and any new fields (Grade, Status).
2. **Write an [Airtable script](/tutorials/airtable-scripting-guide)** that iterates every Student record, reads its linked Classes, and creates one Enrollment record per (student, class) pair.
3. **Verify counts** — the number of Enrollments should equal the total number of student-class pairs in the old direct link.
4. **Update views and interfaces** to point at Enrollments instead of the direct link.
5. **Delete the direct linked-record field** on Students and Classes (the reverse link auto-removes).

Run the migration in a sandbox base first. If you have not scripted in Airtable before, the same migration can be done with [Make](/make-automation-agency) using an iterator over the source linked field — slower, but no code.

## Common Mistakes

**Treating the junction as an afterthought.** New builders create a "Notes" table to capture the grade and link both Students and Classes to it as multi-record. This is not a junction — it is a parallel many-to-many with no rules. Always restrict junction linked fields to single record so each row represents exactly one connection.

**Setting up junctions when you do not need them.** If the relationship has no attributes of its own, a direct many-to-many is simpler and faster. Tags on Articles, Categories on Products, Skills on People — none need junctions unless you start tracking per-pair data.

**Forgetting the primary field formula.** Junctions with default primary fields show up as gibberish on parent records, making the system unusable for end users. Set the formula primary first.

**Not adding lookups for the parent fields.** Without lookups for Student Name and Class Name on the junction, every view of Enrollments shows only linked-record IDs. Add the lookups immediately on table creation.

**Building rollups on the wrong side.** Total credits goes on Student, not on Class. Class size goes on Class, not on Student. The aggregation lives on the parent whose perspective the metric describes.

**Stacking three or more linked records when a junction with three linked fields would be cleaner.** When a relationship genuinely involves three entities (a stock movement involves Product + Warehouse + Supplier), one junction with three linked-record fields is cleaner than three pairwise junctions.

## When a Junction Table Is the Wrong Answer

Two cases where a junction is overkill.

**Pure tagging or categorisation.** Many-to-many with no relationship attributes. A direct linked-record field on both sides is the right tool. Tags, categories, channels, regions.

**Hierarchical containment with no per-pair data.** If a Project contains many Tasks and a Task belongs to exactly one Project, that is one-to-many — a single linked-record field on Task, no junction needed.

The signal that a junction is wrong is the absence of any field that "belongs to the pair." If there are no per-pair fields, you do not have a junction's worth of data.

## Where Junction Tables Fit

Junction tables are how Airtable handles the parts of relational modelling that one-to-many cannot reach. Combined with [linked records](/tutorials/airtable-linked-records-explained), [lookups](/tutorials/how-to-use-airtable-lookup-fields) for surfacing parent fields on the junction, and [rollups](/tutorials/how-to-use-airtable-rollup-fields) for aggregating junction data back to the parents, you can model any business relationship Airtable can hold.

Most real bases have at least one junction table. The CRM has Activities between Contacts and Deals. The agency has Assignments between Projects and Team. The e-commerce base has Order Items between Orders and Products. Once you have built one cleanly, the rest follow the same five-step recipe.

For the official Airtable take, the [linked record field documentation](https://support.airtable.com/docs/linked-record-fields) covers the underlying mechanics that junction tables build on. From there, the practical work is choosing good rollups and writing primary-field formulas that make the junction rows readable to humans.


## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
