MongoDBMany-to-Many Relationships

Many-to-Many Relationships

Many-to-many is where MongoDB's document model diverges most from SQL. There is no automatic join table — you choose how to represent both directions of the relationship, and the right choice depends on which direction you query more often, and how large each side can grow.

The Three Patterns
  • Two-way array references — both sides store an array of the other side's IDs.

  • One-way array reference — only one side stores the array; the other direction is queried via $in or $lookup.

  • Junction collection — a separate collection holds one document per pairing (like a SQL join table), used when the relationship itself carries data or either side's array would grow too large.

Worked Example: Students and Courses

A student enrolls in many courses; a course has many students. Let's model all three ways with the same domain.

Pattern 1: Two-Way Array References

Both sides hold an array of the other's IDs

JS
// students collection
{ _id: ObjectId("s1"), name: "Alice", courseIds: [ObjectId("c1"), ObjectId("c2")] }

// courses collection
{ _id: ObjectId("c1"), title: "Databases 101", studentIds: [ObjectId("s1"), ObjectId("s2")] }
{ _id: ObjectId("c2"), title: "Distributed Systems", studentIds: [ObjectId("s1")] }

// Query: courses a student is enrolled in
db.courses.find({ _id: { $in: [ObjectId("c1"), ObjectId("c2")] } })
// (courseIds read from the student document first)

// Query: students enrolled in a course
db.students.find({ _id: { $in: [ObjectId("s1"), ObjectId("s2")] } })
// (studentIds read from the course document first)

// Enrolling: must update BOTH documents — no single-document atomicity
db.students.updateOne({ _id: ObjectId("s1") }, { $addToSet: { courseIds: ObjectId("c3") } })
db.courses.updateOne({ _id: ObjectId("c3") }, { $addToSet: { studentIds: ObjectId("s1") } })
Warning
Two-way references require updating two documents for every enrollment/drop, with no automatic consistency guarantee unless you wrap both writes in a multi-document transaction. They also duplicate the relationship, and both arrays can grow — a popular course could have tens of thousands of student IDs in one array.
Pattern 2: One-Way Array Reference

If you mostly query in one direction, only store the array on that side, and use $lookup or a reverse query for the other direction. This halves the write overhead.

Only students store courseIds

JS
// students collection — the array lives here
{ _id: ObjectId("s1"), name: "Alice", courseIds: [ObjectId("c1"), ObjectId("c2")] }

// courses collection — no back-reference array
{ _id: ObjectId("c1"), title: "Databases 101" }

// Forward direction (fast, direct): a student's courses
db.students.aggregate([
  { $match: { _id: ObjectId("s1") } },
  { $lookup: { from: "courses", localField: "courseIds", foreignField: "_id", as: "courses" } }
])

// Reverse direction: students in a given course — requires an $in query
// against every student's courseIds array (indexed, but a broader scan)
db.students.createIndex({ courseIds: 1 })
db.students.find({ courseIds: ObjectId("c1") }, { name: 1 })
Tip
Choose the array side based on which direction you query more often, or which side has the smaller, more bounded cardinality (fewer courses per student than students per course, typically).
Pattern 3: Junction Collection

When either array could grow very large, or the relationship itself has attributes (enrollment date, grade, status), model it like SQL would: a separate collection with one document per pairing.

enrollments junction collection

JS
// students and courses collections hold no relationship data at all
{ _id: ObjectId("s1"), name: "Alice" }
{ _id: ObjectId("c1"), title: "Databases 101" }

// enrollments collection — the relationship, with its own attributes
{
  _id: ObjectId("e1"),
  studentId: ObjectId("s1"),
  courseId: ObjectId("c1"),
  enrolledAt: ISODate("2026-01-10"),
  grade: null,
  status: "active"
}

// Unique index prevents double-enrollment (see Unique & Partial Index page)
db.enrollments.createIndex({ studentId: 1, courseId: 1 }, { unique: true })
db.enrollments.createIndex({ courseId: 1 })

// Query: courses a student is enrolled in, with enrollment metadata
db.enrollments.aggregate([
  { $match: { studentId: ObjectId("s1"), status: "active" } },
  { $lookup: { from: "courses", localField: "courseId", foreignField: "_id", as: "course" } },
  { $unwind: "$course" }
])

// Query: students enrolled in a course, with enrollment metadata
db.enrollments.aggregate([
  { $match: { courseId: ObjectId("c1") } },
  { $lookup: { from: "students", localField: "studentId", foreignField: "_id", as: "student" } },
  { $unwind: "$student" }
])
Note
The junction collection is the only pattern that scales to arbitrarily large relationships in both directions, and it's the natural place to store per-pairing data like a grade or a subscription tier. The cost is an extra collection and an extra $lookup for every query.
Choosing Between the Three

Situation

Recommended Pattern

Both sides small and roughly symmetric

Two-way array references

One direction queried far more than the other

One-way array reference

Either side could grow very large (popular course, prolific author)

Junction collection

The relationship itself has data (grade, role, joined date)

Junction collection

Need to enforce "no duplicate pairing"

Junction collection with a unique compound index

Summary
  • Two-way references: simplest to query both directions, but doubles write cost and both arrays can grow unbounded.

  • One-way reference: halves write cost, favor the direction you query most.

  • Junction collection: most scalable and most SQL-like, required once the relationship needs its own attributes or either side is large.

  • Students/courses is the textbook case — start with a junction collection (enrollments) unless you know both sides stay small.