Many-to-Many Relationships

A many-to-many relationship is any relationship where records on both sides can be linked to multiple records on the other side: a student enrolls in many courses, and each course has many students; a product belongs to many categories, and each category holds many products. In SQL you would model this with a join table and foreign keys. MongoDB has no foreign keys and no enforced joins, so you have to deliberately choose a schema pattern based on how your application reads and writes the data.

Overview / How it works

In a relational database, a many-to-many relationship is always resolved with a third table (a junction, or associative, table) holding pairs of foreign keys, because a row cannot hold a variable-length list of other rows’ keys. MongoDB documents are BSON, and BSON natively supports arrays, so a document can hold a list of related ids directly — but that flexibility does not mean you should always embed everything. The database still has a 16MB per-document limit, and an array that grows without bound (all the students ever enrolled in a popular course) will eventually cause problems: slow document loads, slow updates, and eventually a document that is too large to write at all.

There are three common patterns for many-to-many in MongoDB, and picking between them is a real design decision, not a syntax detail:

  • Two-way referencing: each side stores an array of ObjectId references to the other side (a courseIds array on the student, a studentIds array on the course). Good when both arrays stay reasonably small (a student takes dozens of courses, not millions) and you often query from either direction.
  • Junction (linking) collection: a separate collection holds one document per relationship, each with the two related ids — the same idea as a SQL join table. This is the right choice when the relationship itself has data (an enrollment date, a grade, a role) or when either side’s array could grow unbounded.
  • One-way referencing: only one side stores the array of ids, and the reverse direction is answered with a query (find the courses whose studentIds contains this student) instead of a stored array. Useful when you only ever query in one direction.

Whichever pattern you choose, MongoDB does not enforce referential integrity the way a foreign key constraint does. If you delete a course, nothing automatically removes it from every student’s courseIds array or from the junction collection — your application code (or a scheduled cleanup job) is responsible for that. This is a deliberate trade-off: MongoDB gives you flexibility and horizontal scalability in exchange for taking on consistency work the relational engine used to do for you.

Syntax

There is no single dedicated syntax for many-to-many — it’s built from ordinary array update operators and $lookup. The pieces you combine are:

Operation Purpose
$addToSet Adds a value to an array only if it isn’t already present — the safe way to link two documents without creating duplicate references.
$pull Removes a matching value from an array — used to unlink two documents.
$lookup An aggregation stage that performs a left outer join against another collection in the same database, matching localField against foreignField.
$unwind Deconstructs an array field from the input documents into one document per array element — commonly used right after a $lookup that returns an array.

$lookup‘s parameters:

  • from — the collection to join against, in the same database.
  • localField — the field on the input (current pipeline) documents to match.
  • foreignField — the field on the from collection to match against.
  • as — the name of the new array field that will hold the matched documents.

Examples

Example 1: Two-way referencing between students and courses

db.courses.insertMany([
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), title: "Databases 101", department: "CS", studentIds: [] },
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d2"), title: "Web Development", department: "CS", studentIds: [] }
]);

db.students.insertMany([
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1"), name: "Amit Shah", email: "amit@example.com", courseIds: [] },
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e2"), name: "Priya Nair", email: "priya@example.com", courseIds: [] }
]);

// Enroll Amit in both courses -- update BOTH sides of the relationship
db.students.updateOne(
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1") },
  { $addToSet: { courseIds: { $each: [
    new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
    new ObjectId("64f1a2b3c4d5e6f7a8b9c0d2")
  ] } } }
);

db.courses.updateOne(
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
  { $addToSet: { studentIds: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1") } }
);

db.courses.updateOne(
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d2") },
  { $addToSet: { studentIds: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1") } }
);

Output:

{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c1e1"),
  name: "Amit Shah",
  email: "amit@example.com",
  courseIds: [
    ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
    ObjectId("64f1a2b3c4d5e6f7a8b9c0d2")
  ]
}

$addToSet is used instead of $push deliberately: if this code ran twice (say, a retried request), $push would insert the same ObjectId twice, while $addToSet is a no-op the second time. Note that both collections had to be updated separately — MongoDB does not propagate the link automatically.

Example 2: Joining across the reference with $lookup

db.students.aggregate([
  { $match: { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1") } },
  {
    $lookup: {
      from: "courses",
      localField: "courseIds",
      foreignField: "_id",
      as: "courses"
    }
  },
  { $project: { _id: 0, name: 1, "courses.title": 1, "courses.department": 1 } }
]);

Output:

[
  {
    name: "Amit Shah",
    courses: [
      { title: "Databases 101", department: "CS" },
      { title: "Web Development", department: "CS" }
    ]
  }
]

Because courseIds is an array, $lookup matches every element against courses._id and returns all matching course documents in the new courses array — this is the standard way to “join” a many-to-many reference at query time.

Example 3: Junction collection with relationship data

When the relationship itself carries information — an enrollment date, a grade — a junction collection is cleaner than cramming that data into both arrays:

db.enrollments.insertMany([
  { studentId: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1"), courseId: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), enrolledAt: new Date("2026-01-15"), grade: "A" },
  { studentId: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1"), courseId: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d2"), enrolledAt: new Date("2026-01-15"), grade: "B+" },
  { studentId: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e2"), courseId: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), enrolledAt: new Date("2026-02-01"), grade: null }
]);

db.enrollments.createIndex({ studentId: 1, courseId: 1 }, { unique: true });

db.enrollments.aggregate([
  { $match: { studentId: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1") } },
  {
    $lookup: {
      from: "courses",
      localField: "courseId",
      foreignField: "_id",
      as: "course"
    }
  },
  { $unwind: "$course" },
  { $project: { _id: 0, grade: 1, enrolledAt: 1, "course.title": 1 } }
]);

Output:

[
  { grade: "A", enrolledAt: ISODate("2026-01-15T00:00:00.000Z"), course: { title: "Databases 101" } },
  { grade: "B+", enrolledAt: ISODate("2026-01-15T00:00:00.000Z"), course: { title: "Web Development" } }
]

The unique compound index on { studentId, courseId } prevents the same enrollment from being inserted twice and makes lookups by student (or by course, if you add a matching index) index-backed instead of a collection scan. $unwind turns the one-element course array from $lookup back into a plain embedded object, which is easier to project.

How it works step by step

For the two-way referencing example, each updateOne is an independent, single-document write — MongoDB guarantees that write is atomic, but the two writes (student side, course side) are not atomic together. If your process crashes between them, you get a one-sided link; for most course-enrollment apps that’s an acceptable, self-healing inconsistency, but if it isn’t, wrap both writes in a multi-document transaction.

For $lookup, the query planner runs, conceptually, one equality match against foreignField per document (or per array element) flowing out of the previous stage. If foreignField is indexed — and _id always is, by default — each of those matches is an index lookup (IXSCAN). If it isn’t indexed, every single input document triggers a full collection scan (COLLSCAN) of the foreign collection, which gets dramatically slower as both collections grow. Indexing an array field like courseIds creates a multikey index, where MongoDB indexes each array element separately so that queries and lookups matching any single element can use the index.

Common Mistakes

Mistake 1: Embedding full documents instead of ids, on the side that can grow without bound.

// Wrong: embedding entire student documents inside the course
{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
  title: "Databases 101",
  students: [
    { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c1e1"), name: "Amit Shah", email: "amit@example.com" }
    // ...potentially thousands more full documents, duplicated from the students collection
  ]
}

This duplicates data that lives in students, goes stale the moment a student updates their email, and can push the course document toward the 16MB limit for a popular course. Store only the ObjectId references (as in Example 1), or move to a junction collection if the array could grow into the thousands.

Mistake 2: Using $push where $addToSet was needed.

db.students.updateOne(
  { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c1e1") },
  { $push: { courseIds: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") } }
);
// Running this a second time (a retried request, a duplicate click) adds the SAME id again

Fix: use $addToSet for link/unlink operations unless you specifically want duplicates (you almost never do for a reference array).

Mistake 3: Comparing an ObjectId field to a plain string.

// courseIdFromUrl came from req.params.courseId, so it's a string like "64f1a2b3c4d5e6f7a8b9c0d1"
db.courses.findOne({ studentIds: courseIdFromUrl }); // never matches -- string !== ObjectId

Fix: convert it first: db.courses.findOne({ studentIds: new ObjectId(courseIdFromUrl) }). This is one of the most common bugs when relationship ids arrive from an HTTP route or a JSON request body.

Best Practices

  • Choose two-way referencing only when both sides’ arrays stay small and bounded (dozens to low hundreds of elements); reach for a junction collection once either side could grow into the thousands or the relationship needs its own fields (a grade, a timestamp, a role).
  • Index the field you’ll query on: courseIds on students, studentIds on courses, or { studentId, courseId } on a junction collection, ideally as a unique compound index to prevent duplicate relationship rows.
  • Always use $addToSet/$pull for linking and unlinking, never $push for a reference array you don’t want duplicated.
  • Put $match as the first stage before any $lookup in an aggregation pipeline, so the join runs against the smallest possible set of input documents.
  • Reach for a multi-document transaction only if an inconsistent link (one side updated, the other not) would actually break your application — otherwise treat two-way references as eventually consistent and accept the small risk.
  • When deleting a document that participates in a many-to-many relationship, clean up the other side too (pull the id from arrays, or delete matching junction documents) — MongoDB will not do this for you.

Practice Exercises

  • Model a many-to-many relationship between db.authors and db.books (a book can have multiple authors, an author can write multiple books) using two-way referencing, then write a $lookup query that returns one author with all of their books’ titles.
  • Rebuild the same relationship as a junction collection db.authorships that also records each author’s royaltyPercentage for that book, and write an aggregation that lists every book an author co-wrote along with their royalty share for each.
  • Given a course with 50,000 enrolled students, explain (in your own words, no need to run it) why embedding full student documents in the course would be a poor choice, and what explain() would show for a $lookup against an unindexed studentId field.

Summary

  • MongoDB has no foreign keys or automatic joins — many-to-many relationships are built from array references plus $lookup, or a junction collection, and consistency is the application’s responsibility.
  • Two-way referencing (an array of ids on each side) works well when both arrays stay small; a junction collection is safer once either side can grow unbounded or the relationship itself has data.
  • $addToSet prevents duplicate references; $push does not.
  • $lookup performs a left outer join at query time using localField/foreignField, and benefits enormously from an index on foreignField — without one, every input document triggers a collection scan.
  • Never compare an ObjectId to a raw string; always wrap ids coming from outside MongoDB (URLs, request bodies) in new ObjectId(...).