Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 088 / 100

Embedding vs referencing: real examples, explained simply

Decisions from production schemas.

Expona Admin6 min read

Embedding vs referencing: real examples, explained simply. Decisions from production schemas.

Why embedding vs referencing matters

When you're building a MongoDB schema, one of the most consequential decisions you'll make is whether to embed data directly in a document or store it separately and reference it by ID. Get this wrong in production, and you'll either face runaway document sizes, complex multi-document queries, or both. We've learned these lessons across e-commerce, SaaS, and trading platforms—here's what actually works.

User addresses: Embed when the relationship is one-to-few

A user typically has between 1 and 10 addresses. They rarely change, and whenever you fetch a user, you almost always want their addresses too. This is the textbook case for embedding.

javascript
{
  "_id": ObjectId("..."),
  "email": "alice@example.com",
  "name": "Alice Chen",
  "addresses": [
    {
      "type": "billing",
      "street": "123 Oak Lane",
      "city": "Portland",
      "state": "OR",
      "zipCode": "97201",
      "country": "US"
    },
    {
      "type": "shipping",
      "street": "456 Pine Ave",
      "city": "Seattle",
      "state": "WA",
      "zipCode": "98101",
      "country": "US"
    }
  ]
}

Notice that the address data lives inside the user document. When you query for a user, you get everything in a single round trip. No $lookup, no joins, no second database call.

In a production e-commerce system, this is efficient because:

  • Addresses are small (a few hundred bytes per user).
  • They're read together with the user almost always.
  • They're updated infrequently and atomically (all user data updates together).
  • A user document stays well under MongoDB's 16 MB document limit.

The downside: if you ever need to query "all users in Portland," you'd need to index addresses.city and use $elemMatch or array filtering. That's fine for occasional queries, but if you need this constantly, you might reconsider. For most platforms, it's not a pain point.

Order items: Embed with a price snapshot

When an order is placed, you capture the items, quantities, and prices at that moment in time. You should embed these, not reference the product catalog.

javascript
{
  "_id": ObjectId("..."),
  "orderId": "ORD-2024-001",
  "userId": ObjectId("user-123"),
  "createdAt": ISODate("2024-01-15T10:30:00Z"),
  "status": "completed",
  "items": [
    {
      "productId": ObjectId("prod-456"),
      "productName": "Wireless Earbuds",
      "sku": "WE-BLU-001",
      "quantity": 1,
      "pricePerUnit": 79.99,
      "priceTotal": 79.99,
      "tax": 6.40
    },
    {
      "productId": ObjectId("prod-789"),
      "productName": "USB-C Cable",
      "sku": "USB-C-2M",
      "quantity": 2,
      "pricePerUnit": 12.99,
      "priceTotal": 25.98,
      "tax": 2.08
    }
  ],
  "subtotal": 105.97,
  "tax": 8.48,
  "total": 114.45
}

This is critical: you embed the snapshot of the product data and pricing as it was at purchase time. The product's current price in your catalog might be $69.99, but this order locked in $79.99. If you only stored a reference to the product, you'd lose the historical record.

Why embed here:

  • Order data is immutable. Once placed, items don't change.
  • You need the price history for refunds, disputes, and auditing.
  • Orders are frequently read together (showing a user's order history, generating invoices).
  • You avoid the N+1 problem when querying orders and needing product details.

You still keep productId as a reference so you can link back to the product catalog if needed, but the real data—name, price, tax—lives in the order document.

Product reviews: Reference when the relationship is one-to-many or unbounded

A popular product might have hundreds or thousands of reviews. Embedding all of them in the product document would bloat it and make updates slow. Here you reference instead.

First, the products collection:

javascript
{
  "_id": ObjectId("prod-456"),
  "name": "Wireless Earbuds",
  "sku": "WE-BLU-001",
  "price": 79.99,
  "description": "Premium noise-canceling earbuds",
  "category": ObjectId("cat-audio"),
  "reviewCount": 342,
  "averageRating": 4.6,
  "createdAt": ISODate("2023-06-01T00:00:00Z")
}

And the reviews collection:

javascript
{
  "_id": ObjectId("rev-001"),
  "productId": ObjectId("prod-456"),
  "userId": ObjectId("user-789"),
  "rating": 5,
  "title": "Best earbuds I've owned",
  "body": "Great sound, excellent battery life...",
  "verified": true,
  "helpfulCount": 42,
  "createdAt": ISODate("2024-01-10T14:22:00Z")
}

When you fetch a product, you get the lightweight product document. If you need reviews, you query the reviews collection separately, optionally with pagination:

javascript
db.reviews
  .find({ productId: ObjectId("prod-456") })
  .sort({ createdAt: -1 })
  .limit(10)
  .toArray()

Why reference here:

  • Reviews grow without limit and can cause the product document to exceed size or performance thresholds.
  • You read reviews independently from product data (separate page load, API call).
  • You update reviews frequently without touching the product document.
  • You can index and filter reviews separately (by rating, date, helpfulness).

Note that we denormalize reviewCount and averageRating in the product document. This trades a bit of consistency (you need to update both collections on a new review) for query speed on the product detail page. On write, you increment these atomically; they drift slightly if a review is deleted, but the trade-off is acceptable in most systems.

Categories: Reference by ID, copy the name for display

Categories change rarely but are used everywhere: product catalogs, filters, navigation. You should reference by ID, but denormalize the name for display to avoid extra lookups.

Categories collection:

javascript
{
  "_id": ObjectId("cat-audio"),
  "name": "Audio & Headphones",
  "slug": "audio-headphones",
  "description": "Speakers, headphones, earbuds, and more",
  "parentId": ObjectId("cat-electronics"),
  "createdAt": ISODate("2023-01-01T00:00:00Z")
}

Products collection with category denormalization:

javascript
{
  "_id": ObjectId("prod-456"),
  "name": "Wireless Earbuds",
  "sku": "WE-BLU-001",
  "price": 79.99,
  "categoryId": ObjectId("cat-audio"),
  "categoryName": "Audio & Headphones",
  "categorySlug": "audio-headphones"
}

On the frontend, you show "Audio & Headphones" without a second lookup. If a category name changes (rare, but it happens), you have a background job or migration script that updates all products in that category. This is a clean balance between performance and maintainability.

Why denormalize the name:

  • Category names are stable; changes are infrequent.
  • You need the name for every product view (navbar, product cards, filters).
  • An extra $lookup per product would slow down list queries significantly.
  • A batch job to sync name changes is simpler than runtime lookups.

Common mistakes

Embedding everything for simplicity. If you embed unbounded arrays (reviews, comments, logs), your documents grow until queries slow and updates lock contention rises. We've seen this in production SaaS platforms where user activity logs were embedded in the user document. It seemed simple initially—one document to update—but after a few months of data, queries degraded.

Never denormalizing, even when it's safe. Some teams reference everything religiously to maintain a "pure" schema. This causes N+1 queries and unnecessary round trips. A small amount of denormalization—like category names or product prices in orders—speeds up common queries without creating maintenance burden.

Ignoring 16 MB document limits. MongoDB has a hard 16 MB limit per document. If you're embedding unbounded data (like all user messages or all sensor readings), you'll eventually hit it. Build with growth in mind.

Inconsistent snapshots in orders. Some teams embed an order item but reference the product, then update the product price without updating past orders. This breaks reconciliation and audits. Always capture immutable snapshots of pricing and product details at transaction time.

Key takeaways

  • Embed small, bounded relationships that are always read together (addresses, order items with prices). This minimizes queries and keeps data atomic.
  • Reference unbounded relationships that grow without limit (reviews, comments, logs) or are accessed independently. This keeps documents lean and queryable.
  • Denormalize sparingly when the denormalized field is stable and read frequently (category names, product snapshots in orders). A batch sync job is simpler than runtime joins.
  • Think about access patterns first. How is the data queried? Read together or separately? Updated together or independently? Your schema should follow your queries, not the other way around.
  • Test growth. Embed data and simulate a year of growth. Will the document exceed 16 MB? Will update latency spike? Run load tests before committing to a schema.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles