All skills
Skillintermediate

fundamental embed vs reference

**This is one of the most important schema decisions you'll make.** Choose embedding or referencing based on access patterns, not just entity relationships.

Claude Code Knowledge Pack7/10/2026

Overview

Embed vs Reference Decision Framework

This is one of the most important schema decisions you'll make. Choose embedding or referencing based on access patterns, not just entity relationships.

Embed when:

  • Data is always accessed together (1:1 or 1:few relationships)
  • Child data doesn't make sense without parent
  • Updates to both happen atomically
  • Child array is clearly bounded by product constraints

Reference when:

  • Data is accessed independently
  • Many-to-many relationships exist
  • Child data is large relative to the parent or array growth is unbounded
  • Different update frequencies

Decision Matrix:

RelationshipCardinalityAccess PatternBounded?Decision
User → Profile1:1Always togetherYesEmbed
User → Addresses1:few (1-5)Usually togetherYesEmbed array
Order → Line Items1:few (1-50)Always togetherYesEmbed array
Publisher → Books1:many (1000+)Often separateNoReference
Post → Comments1:many (unbounded)Separate addsNoReference
Students ↔ ClassesMany-to-manyBoth directionsModerateReference both ways
Product ↔ CategoryMany-to-manyEither wayModerateEmbed refs in primary direction

One-to-One: embed in the parent document

Embed one-to-one related data directly in the parent when it is consistently co-accessed. Keeping it in one document eliminates a round-trip and guarantees atomicity.

Incorrect (separate collections for 1:1 data): Storing user accounts and profiles in separate collections when they are always accessed together requires two queries per lookup, two index lookups, and risks orphaned records.

Correct (embedded):

{
  _id: "user123",
  email: "alice@example.com",
  createdAt: ISODate("2024-01-01"),
  profile: {
    name: "Alice Smith",
    avatar: "https://cdn.example.com/alice.jpg",
    bio: "Developer building cool things"
  }
}

// Single query, atomic updates
db.users.updateOne(
  { _id: "user123" },
  { $set: { "profile.name": "Alice Johnson" } }
)

Use subdocuments to logically group related fields — e.g. auth (passwordHash, lastLogin), profile (name, avatar), settings (theme, notifications) — all 1:1 data, logically organized without separate collections.

Common 1:1 relationships to embed: User/Profile, Country/Capital, Building/Address, Order/ShippingAddress, Product/Dimensions.

When NOT to embed 1:1:

  • Data accessed independently (profile page separate from auth operations)
  • Different security requirements (auth vs profile)
  • Extreme size difference (embedded doc >10KB, parent <1KB)
  • Different update frequencies (profile hourly, auth rarely)

One-to-Few: embed bounded arrays

Embed bounded, small arrays directly in the parent document. When a parent has a limited number of children usually accessed together, embedding keeps data in one read path.

Incorrect (separate collection for few items):

// Addresses in separate collection — user typically has 1-3
{ userId: "user123", type: "home", street: "123 Main", city: "Boston" }
// Requires $lookup for ~2 addresses, orphan risk on user delete

Correct (embedded array):

{
  _id: "user123",
  name: "Alice Smith",
  addresses: [
    { type: "home", street: "123 Main St", city: "Boston", state: "MA", zip: "02101" },
    { type: "work", street: "456 Oak Ave", city: "Boston", state: "MA", zip: "02102" }
  ]
}

// Add address atomically
db.users.updateOne(
  { _id: "user123" },
  { $push: { addresses: { type: "vacation", street: "789 Beach", city: "Miami" } } }
)

// Update specific address
db.users.updateOne(
  { _id: "user123", "addresses.type": "home" },
  { $set: { "addresses.$.city": "Cambridge" } }
)

Common one-to-few: User/Addresses (1-5), User/PhoneNumbers (1-3), Product/Variants (3-10), Author/PenNames (1-3), Order/LineItems (1-50).

Enforce bounds with schema validation:

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      properties: {
        addresses: {
          bsonType: "array",
          maxItems: 10,
          items: {
            bsonType: "object",
            required: ["city"],
            properties: {
              type: { enum: ["home", "work", "billing", "shipping"] },
              city: { bsonType: "string" }
            }
          }
        }
      }
    }
  }
})

(See fundamental-schema-validation.md for full validation guidance).

When NOT to embed arrays:

  • Unbounded growth (comments, orders, events) — use separate collection
  • Independent access (addresses queried without user context)
  • Large child documents relative to parent
  • Steadily growing array size approaching unbounded behavior

One-to-Many: reference in child documents

Use references when the "many" side is unbounded or frequently accessed independently. Store the parent's ID in each child document with an index on that field.

Incorrect (embedding unbounded arrays): Embedding all 10,000+ books inside a publisher document means adding one book rewrites the entire large document, eventually exceeding 16MB.

Correct (reference in children):

// Publisher stays small and fixed-size
{ _id: "oreilly", name: "O'Reilly Media", founded: 1978, bookCount: 3500 }

// Each book references publisher; index on { publisherId: 1 }
{ _id: "book001", title: "New MongoDB Book", publisherId: "oreilly" }

// Efficient indexed queries
db.books.find({ publisherId: "oreilly" })

// $lookup when you need details from both sides
db.books.aggregate([
  { $match: { publisherId: "oreilly" } },
  { $lookup: {
    from: "publishers",
    localField: "publisherId",
    foreignField: "_id",
    as: "publisher"
  }},
  { $unwind: "$publisher" }
])

Hybrid with subset: Embed a bounded subset (e.g. top 5 featured books with _id, title, isbn) in the publisher for display without $lookup. "View all books" queries the books collection.

Keep denormalized counts in sync:

db.books.insertOne({ title: "New Book", publisherId: "oreilly" })
db.publishers.updateOne({ _id: "oreilly" }, { $inc: { bookCount: 1 } })

When to reference: Unbounded children (Publisher→Books), large child documents (User→Orders), independent queries (Department→Employees), different lifecycles (Author→Articles).

When NOT to reference: Bounded small arrays (User's 3 addresses), always accessed together (Order→LineItems), never queried without parent.


Many-to-Many: choose a primary query direction

Many-to-many relationships require choosing a primary query direction. Unlike SQL's join tables, MongoDB favors denormalization toward your most common query pattern.

Incorrect (SQL-style junction table):

// 3 collections, always need joins
// students: { _id, name }  /  classes: { _id, name }  /  enrollments: { studentId, classId }
// Every query requires aggregation with $lookup

Correct (embed in primary query direction):

Embed references on the side you query most. If you primarily query "which classes is this student in," embed class summaries in the student. For the reverse, embed student summaries in the class.

Bidirectional embedding (when both directions are common):

// Book with author summaries
{
  _id: "book001",
  title: "Cell Biology",
  authors: [
    { authorId: "author124", name: "Ellie Smith" },
    { authorId: "author381", name: "John Palmer" }
  ]
}

// Author with book summaries
{
  _id: "author124",
  name: "Ellie Smith",
  books: [
    { bookId: "book001", title: "Cell Biology" },
    { bookId: "book042", title: "Molecular Biology" }
  ]
}
// Trade-off: data duplication, but fast queries in both directions

Reference-only (for large cardinality):

// Product stores category IDs (small array per product)
{ _id: "prod123", name: "Laptop", categoryIds: ["cat1", "cat2", "cat3"] }
// Category has no back-reference array (avoid huge arrays)
{ _id: "cat1", name: "Electronics" }
// Products in a category: db.products.find({ categoryIds: "cat1" })

Choosing strategy:

Query PatternCardinalityStrategy
Students → ClassesFew classes per studentEmbed in student
Classes → StudentsMany students per classReference only
Both directions commonModerate both sidesBidirectional embed
High cardinality bothLarge/growing both sidesReference-only + $lookup

Maintaining bidirectional data — use transactions for atomicity:

const session = client.startSession()
session.withTransaction(async () => {
  await db.students.updateOne(
    { _id: "student1" },
    { $push: { classes: { classId: "class101", name: "Database Systems" } } },
    { session }
  )
  await db.classes.updateOne(
    { _id: "class101" },
    { $push: { students: { studentId: "student1", name: "Alice Smith" } } },
    { session }
  )
})

Tree and hierarchical data

Hierarchical data requires choosing a tree pattern based on your primary operations. MongoDB offers multiple patterns, each with different tradeoffs.

Common hierarchical data: Category trees, org charts, file/folder structures, comment threads, geographic hierarchies.

Pattern 1: Parent References

Best for: Finding parent, updating parent, simple child listing.

{ _id: "MongoDB", parent: "Databases" }
{ _id: "Databases", parent: "Programming" }
{ _id: "Programming", parent: null }

db.categories.createIndex({ parent: 1 })
db.categories.find({ parent: "Databases" })  // immediate children

Con: Finding all descendants requires recursive queries or $graphLookup.

Pattern 2: Child References

Best for: Finding children, graph-like structures.

{ _id: "Databases", children: ["MongoDB", "PostgreSQL", "MySQL"] }

Con: Finding ancestors requires recursion; array updates on every child add/remove.

Pattern 3: Array of Ancestors

Best for: Breadcrumb navigation, ancestor and descendant lookups.

{ _id: "MongoDB", parent: "Databases", ancestors: ["Programming", "Databases"] }
{ _id: "Atlas", parent: "MongoDB", ancestors: ["Programming", "Databases", "MongoDB"] }

db.categories.createIndex({ ancestors: 1 })
db.categories.find({ ancestors: "Databases" })  // all descendants

Including a parent field enables $graphLookup traversal without application-side recursion.

Pattern 4: Materialized Paths

Best for: Subtree queries, regex-based lookups, hierarchy sorting.

{ _id: "MongoDB", path: ",Programming,Databases,MongoDB," }
{ _id: "Atlas", path: ",Programming,Databases,MongoDB,Atlas," }

db.categories.createIndex({ path: 1 })
db.categories.find({ path: /^,Programming,Databases,MongoDB,/ })  // all descendants
db.categories.find({}).sort({ path: 1 })  // hierarchy display order

Tree pattern comparison

PatternParentChildrenDescendantsAncestorsUpdate Cost
Parent RefsDirectIndexedRecursive/$graphLookupRecursiveLow
Child RefsMembership queryDirectRecursive/$graphLookupRecursiveLow–moderate
Array of AncestorsVia parentVia parentFast (indexed)Direct (stored)Moderate
Materialized PathsVia path/parentPrefix queryRegex/prefixFrom stored pathModerate

Recommended by use case: Category breadcrumbs → Array of Ancestors. File browser → Parent References. Org chart reporting → Materialized Paths. Comment threads → Parent References.


When NOT to embed (summary)

  • Unbounded growth: Comments, logs, events — separate collection.
  • Large child documents: If each child is large relative to the parent, references are usually safer.
  • Independent access: If you ever query child without parent, reference.
  • Different lifecycles: If child data is archived/deleted separately.
  • Graph-like data: Multiple parents → use $graphLookup or a graph database.

Verify with

// Check document sizes for embedded collections
db.collection.aggregate([
  { $project: {
    size: { $bsonSize: "$$ROOT" },
    arrayLen: { $size: { $ifNull: ["$items", []] } }
  }},
  { $match: { size: { $gt: 1000000 } } }
])
// Large documents may indicate embedding that should be referencing

// Check embedded array sizes (one-to-few validation)
db.users.aggregate([
  { $project: { addressCount: { $size: { $ifNull: ["$addresses", []] } } } },
  { $group: { _id: null, avg: { $avg: "$addressCount" }, max: { $max: "$addressCount" } } }
])
// If max keeps growing, consider a separate collection

// Check for orphaned references (1:1 that should be embedded)
db.profiles.aggregate([
  { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
  { $match: { user: { $size: 0 } } }
])
// Orphans suggest 1:1 data should be embedded

// Check for missing indexes on reference fields
db.books.getIndexes()
// Must have index on publisherId for efficient child lookups

// Verify bidirectional many-to-many consistency
db.students.aggregate([
  { $unwind: "$classes" },
  { $lookup: {
    from: "classes",
    let: { sid: "$_id", cid: "$classes.classId" },
    pipeline: [
      { $match: { $expr: { $eq: ["$_id", "$$cid"] } } },
      { $match: { $expr: { $in: ["$$sid", "$students.studentId"] } } }
    ],
    as: "match"
  }},
  { $match: { match: { $size: 0 } } }
])
// Mismatches indicate inconsistent bidirectional data

// Check tree consistency (no orphaned nodes)
db.categories.aggregate([
  { $match: { parent: { $ne: null } } },
  { $lookup: { from: "categories", localField: "parent", foreignField: "_id", as: "parentDoc" } },
  { $match: { parentDoc: { $size: 0 } } },
  { $count: "orphanedNodes" }
])

References: