Data modelling: embedding vs referencing and schema validation

Decide when a child belongs inside the parent document, enforce the shape you settled on with JSON Schema, and evolve that shape without a stop-the-world migration.

Embed or reference

FactorEmbedReference
Access patternRead with the parent every timeRead on its own, independently
CardinalityOne-to-fewOne-to-many or many-to-many
GrowthBounded and predictableUnbounded over time
SharingPrivate to the parentShared between several parents
Write patternUpdated with the parentUpdated on its own schedule
ConsistencyOne atomic write covers everythingNeeds a transaction for atomicity
// embedded: bounded, always read with the order
{
  _id: ObjectId("66e0a1f2c3b4d5e6f7a8b9c0"),
  customer: { id: ObjectId("..."), name: "Ada" },
  items: [ { sku: "LAMP-01", qty: 2 }, { sku: "CABLE-3", qty: 1 } ],
  status: "paid"
}

// referenced: unbounded, queried on its own
{
  _id: ObjectId("..."),
  orderId: ObjectId("66e0a1f2c3b4d5e6f7a8b9c0"),
  at: ISODate("2026-09-14T09:12:00Z"),
  event: "payment.authorised"
}
  • The document is the unit of atomicity. Modelling so that a single logical change fits in one document is both simpler and faster than reaching for a transaction.
  • Duplicate the two or three fields you always need rather than joining for them — but treat the copy as a cache and know what refreshes it.
  • An array that grows on every write is the most common modelling mistake. Bound it, bucket it, or move the children to their own collection.

JSON Schema validation

db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["customer", "status", "placedAt"],
      properties: {
        customer: { bsonType: "objectId", description: "reference to customers" },
        status:   { enum: ["new", "paid", "shipped", "cancelled"] },
        total:    { bsonType: "decimal", minimum: 0 },
        items: {
          bsonType: "array", minItems: 1,
          items: {
            bsonType: "object",
            required: ["sku", "qty"],
            properties: { qty: { bsonType: "int", minimum: 1 } }
          }
        }
      },
      additionalProperties: true
    }
  },
  validationLevel: "moderate",     // strict or moderate
  validationAction: "error"        // error or warn
});

// tighten the rules later without recreating the collection
db.runCommand({ collMod: "orders", validationLevel: "strict" });
  • validationLevel: "strict" applies to inserts and to every update; "moderate" only validates inserts and updates to already-valid documents, which lets pre-existing bad data stay while new writes are held to the rule.
  • validationAction: "warn" records a violation in the log and allows the write. It is the right first step when you want to measure how much existing code would break.
  • Validators apply to the future, not the past. Documents already stored are never re-checked unless something updates them.

Evolving a schema safely

// version the shape, then migrate lazily in batches
db.orders.updateMany(
  { schemaVersion: { $lt: 2 } },
  { $set: { schemaVersion: 2, "shipping.method": "standard" } }
);

db.orders.createIndex({ schemaVersion: 1 });
db.orders.countDocuments({ schemaVersion: { $lt: 2 } });   // backlog remaining
  • Store a schemaVersion and let the application read both generations while the migration runs. A field the old code ignores is safer than a field it misinterprets.
  • Additive changes are cheap; renaming or retyping a field is a deployment that must be ordered so that both versions of the code work against both versions of the document.
  • Migrate in bounded batches. One unbounded updateMany over a large collection holds locks and writes an enormous oplog entry.
  • A validator is a guard rail, not a migration tool. It rejects new writes that break the rule and says nothing at all about data already stored.
⚠️
An unbounded array is the mistake that eventually forces a migration. $push grows the document on every write until it reaches the 16 MB limit, and by then the fix is a data migration. Bound the array, or move the children into their own collection with a bucket pattern.

FAQ

Should every collection have a validator?
Every collection whose shape matters to correctness. A validator costs one comparison per write and catches the class of bug that otherwise shows up months later as a missing field in production. Use warn first to measure existing violations, then switch to error.
How large should a collection be?
There is no useful limit per collection; the limit that matters is per document. The practical question is whether a single document stays bounded and whether the working set of live documents fits in RAM.

Documents, collections and queries Indexes and schema design patterns

Last refreshed 2026-09-18.