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.
Option 1 — Embed (recommended for one-to-one)
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.
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 size | Approach | Example |
|---|---|---|
| Small and bounded (< 100) | Embed array | Student grades per semester |
| Medium (100s) | Array of IDs on parent | Student course enrollments |
| Large or unbounded | Reference on child | Attendance 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.
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
| Relationship | Best Approach | Example |
|---|---|---|
| One-to-one (small) | Embed | Student → Guardian |
| One-to-one (large/independent) | Reference | Student → Profile photo metadata |
| One-to-many (small, bounded) | Embed array | Student → Grades |
| One-to-many (large/unbounded) | Reference on child | Student → Attendance records |
| One-to-many (medium) | Array of IDs on parent | Student → Course IDs |
| Many-to-many (no extra data) | Arrays of IDs on both sides | Simple tag relationships |
| Many-to-many (with extra data) | Junction collection | Student ↔ 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.