DocsHub
Schema Design

Relationships

Learn how to model one-to-one, one-to-many, and many-to-many relationships in MongoDB using embedding and referencing.

Relationships

Every real application has relationships between pieces of data. A student has one guardian. A teacher teaches many courses. A student takes many courses and a course has many students.

MongoDB does not enforce relationships like SQL does — there are no foreign key constraints. But you can absolutely model relationships — you just choose how to represent them based on your query patterns.

There are three types of relationships:

  • One-to-one — one document relates to exactly one other document
  • One-to-many — one document relates to many documents
  • Many-to-many — many documents relate to many other documents

One-to-One

A one-to-one relationship means one document is related to exactly one other document. In our school system — a student has one guardian, a teacher has one office.

STUDENT string name string grade int age GUARDIAN string name string phone string relation has

For most one-to-one relationships, embedding is the right choice. The related data is small, belongs to one document, and is always read together.

// Guardian embedded inside student — best approach
db.students.insertOne({
  name: "Ali Hassan",
  age: 16,
  grade: "10th",
  guardian: {
    name: "Mr. Hassan Senior",
    phone: "0300-1234567",
    relation: "Father",
    email: "hassan.sr@gmail.com"
  }
})

Fetching a student's guardian:

// One query — guardian comes with the student
db.students.findOne(
  { name: "Ali Hassan" },
  { name: 1, guardian: 1 }
)

Option 2 — Reference (for large or independently queried data)

Sometimes the related document is large or you need to query it on its own. In that case, store it in a separate collection and reference it.

// Guardian in separate collection
db.guardians.insertOne({
  _id: new ObjectId("64a1f2c3e4b0a1b2c3d4e5f1"),
  name: "Mr. Hassan Senior",
  phone: "0300-1234567",
  relation: "Father",
  email: "hassan.sr@gmail.com"
})

// Student references guardian by ID
db.students.insertOne({
  name: "Ali Hassan",
  age: 16,
  grade: "10th",
  guardianId: new ObjectId("64a1f2c3e4b0a1b2c3d4e5f1")
})

Fetching student with guardian:

db.students.aggregate([
  { $match: { name: "Ali Hassan" } },
  {
    $lookup: {
      from: "guardians",
      localField: "guardianId",
      foreignField: "_id",
      as: "guardian"
    }
  },
  { $unwind: "$guardian" }
])

For one-to-one relationships, embedding is almost always better. Use a separate collection only if the related data is large (many fields), changes frequently and independently, or needs to be queried on its own.


One-to-Many

A one-to-many relationship means one document relates to many documents. In our school system — a teacher teaches many students, a grade has many students, a course has many enrolled students.

TEACHER string name string subject string email COURSE string title string code string grade int totalStudents teaches

For one-to-many, you have three options depending on how many items are on the "many" side.

Option 1 — Embed array (for small, bounded "many" side)

If the "many" side is small and bounded — like a student's grades per semester — embed as an array.

// Student with embedded grades — small, bounded array
db.students.insertOne({
  name: "Ali Hassan",
  grade: "10th",
  grades: [
    { subject: "Math",    score: 88, semester: 1 },
    { subject: "Physics", score: 92, semester: 1 },
    { subject: "English", score: 79, semester: 1 }
  ]
})

Fetching Ali's grades:

// One query — grades come with the student
db.students.findOne(
  { name: "Ali Hassan" },
  { grades: 1 }
)

Option 2 — Reference from the "many" side (for large "many" side)

If the "many" side is large or grows over time, store the reference on the "many" side — each child document references the parent.

This is the most common pattern for large one-to-many relationships.

// Attendance records — one per day per student — grows forever
// Store studentId on each attendance record
db.attendance.insertMany([
  {
    studentId: new ObjectId("64a1f2c3e4b0a1b2c3d4e5f6"),
    date: new Date("2024-09-01"),
    present: true
  },
  {
    studentId: new ObjectId("64a1f2c3e4b0a1b2c3d4e5f6"),
    date: new Date("2024-09-02"),
    present: false
  }
])

Fetching all attendance records for a student:

db.attendance.find({
  studentId: new ObjectId("64a1f2c3e4b0a1b2c3d4e5f6")
})

Fetching student with their attendance via $lookup:

db.students.aggregate([
  { $match: { name: "Ali Hassan" } },
  {
    $lookup: {
      from: "attendance",
      localField: "_id",
      foreignField: "studentId",
      as: "attendanceRecords"
    }
  }
])

Option 3 — Store array of IDs on the "one" side (for medium "many" side)

If the "many" side is medium-sized — like the courses a student is enrolled in — store an array of IDs on the parent document.

// Student stores an array of course IDs
db.students.insertOne({
  name: "Ali Hassan",
  grade: "10th",
  courseIds: [
    new ObjectId("64a1f2c3e4b0a1b2c3d4e5f1"),
    new ObjectId("64a1f2c3e4b0a1b2c3d4e5f2"),
    new ObjectId("64a1f2c3e4b0a1b2c3d4e5f3")
  ]
})

Fetching the student's full course details:

db.students.aggregate([
  { $match: { name: "Ali Hassan" } },
  {
    $lookup: {
      from: "courses",
      localField: "courseIds",
      foreignField: "_id",
      as: "courses"
    }
  }
])

Choosing the right one-to-many approach

"Many" side sizeApproachExample
Small and bounded (< 100)Embed arrayStudent grades per semester
Medium (100s)Array of IDs on parentStudent course enrollments
Large or unboundedReference on childAttendance records, activity logs

Many-to-Many

A many-to-many relationship means many documents on both sides relate to each other. In our school system — a student takes many courses, and a course has many students.

STUDENT string name string grade COURSE string title string code

Option 1 — Arrays of IDs on both sides

The simplest approach — each side stores an array of IDs pointing to the other:

// Student stores course IDs
db.students.insertOne({
  name: "Ali Hassan",
  grade: "10th",
  courseIds: [
    new ObjectId("course1"),
    new ObjectId("course2")
  ]
})

// Course stores student IDs
db.courses.insertOne({
  title: "Mathematics",
  code: "MATH-10",
  studentIds: [
    new ObjectId("student1"),
    new ObjectId("student2"),
    new ObjectId("student3")
  ]
})

Fetching a student's courses:

db.students.aggregate([
  { $match: { name: "Ali Hassan" } },
  {
    $lookup: {
      from: "courses",
      localField: "courseIds",
      foreignField: "_id",
      as: "courses"
    }
  }
])

Fetching all students in a course:

db.courses.aggregate([
  { $match: { code: "MATH-10" } },
  {
    $lookup: {
      from: "students",
      localField: "studentIds",
      foreignField: "_id",
      as: "students"
    }
  }
])

Storing IDs on both sides means you have to update two documents every time a student enrolls or drops a course. If you add a student to a course, you must add the courseId to the student AND add the studentId to the course. Missing one creates inconsistency. Always do both updates together — ideally in a transaction.

Option 2 — Junction collection (for extra relationship data)

In SQL, a many-to-many is modeled with a junction table. In MongoDB, use a junction collection when the relationship itself has extra data — like enrollment date, grade, or status.

// Enrollment junction collection
db.enrollments.insertMany([
  {
    studentId: new ObjectId("student1"),
    courseId: new ObjectId("course1"),
    enrolledAt: new Date("2024-09-01"),
    grade: null,           // filled in at end of semester
    status: "active"
  },
  {
    studentId: new ObjectId("student1"),
    courseId: new ObjectId("course2"),
    enrolledAt: new Date("2024-09-01"),
    grade: null,
    status: "active"
  },
  {
    studentId: new ObjectId("student2"),
    courseId: new ObjectId("course1"),
    enrolledAt: new Date("2024-09-01"),
    grade: null,
    status: "active"
  }
])

Now querying is clean and flexible:

// All courses a student is enrolled in
db.enrollments.aggregate([
  { $match: { studentId: new ObjectId("student1"), status: "active" } },
  {
    $lookup: {
      from: "courses",
      localField: "courseId",
      foreignField: "_id",
      as: "course"
    }
  },
  { $unwind: "$course" },
  { $project: { _id: 0, "course.title": 1, "course.code": 1, enrolledAt: 1 } }
])

// All students in a course
db.enrollments.aggregate([
  { $match: { courseId: new ObjectId("course1"), status: "active" } },
  {
    $lookup: {
      from: "students",
      localField: "studentId",
      foreignField: "_id",
      as: "student"
    }
  },
  { $unwind: "$student" },
  { $project: { _id: 0, "student.name": 1, "student.grade": 1, enrolledAt: 1 } }
])

// Update grade when semester ends
db.enrollments.updateOne(
  {
    studentId: new ObjectId("student1"),
    courseId: new ObjectId("course1")
  },
  { $set: { grade: "A", status: "completed" } }
)

The junction collection approach is cleaner, avoids duplicate IDs on both sides, and lets you store relationship metadata.

Use a junction collection for many-to-many when the relationship has its own data — like enrollment date, status, or grade. Use the dual-array approach only for simple relationships with no extra data and when the arrays stay small.


Our School System — Final Relationship Model

Here is how we model all relationships in our school system:

// students collection
{
  _id: ObjectId("student1"),
  name: "Ali Hassan",
  age: 16,
  grade: "10th",
  enrolled: true,
  enrollmentDate: new Date("2024-09-01"),

  // One-to-one — embedded
  address: { city: "Lahore", country: "Pakistan" },
  guardian: { name: "Mr. Hassan", phone: "0300-1234567" },

  // One-to-many — embedded (small, bounded)
  grades: [
    { subject: "Math", score: 88, semester: 1 }
  ],
  subjects: ["Math", "Physics", "English"]
}

// courses collection
{
  _id: ObjectId("course1"),
  title: "Mathematics",
  code: "MATH-10",
  grade: "10th",
  credits: 4,
  teacherId: ObjectId("teacher1")   // one-to-one reference to teacher
}

// teachers collection
{
  _id: ObjectId("teacher1"),
  name: "Mr. Khan",
  subject: "Math",
  email: "khan@school.com",
  active: true
}

// enrollments collection — many-to-many junction
{
  studentId: ObjectId("student1"),
  courseId: ObjectId("course1"),
  enrolledAt: new Date("2024-09-01"),
  grade: null,
  status: "active"
}

// attendance collection — one-to-many (large, unbounded)
{
  studentId: ObjectId("student1"),
  date: new Date("2024-09-01"),
  present: true,
  courseId: ObjectId("course1")
}

Quick Reference

RelationshipBest ApproachExample
One-to-one (small)EmbedStudent → Guardian
One-to-one (large/independent)ReferenceStudent → Profile photo metadata
One-to-many (small, bounded)Embed arrayStudent → Grades
One-to-many (large/unbounded)Reference on childStudent → Attendance records
One-to-many (medium)Array of IDs on parentStudent → Course IDs
Many-to-many (no extra data)Arrays of IDs on both sidesSimple tag relationships
Many-to-many (with extra data)Junction collectionStudent ↔ Course enrollments

When modeling relationships in MongoDB, always start by asking two questions — how many items will be on each side, and will the relationship itself have data? The answers to these two questions point you directly to the right approach.

On this page