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
// 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") } })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
// 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 })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
// 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" }
])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.