Model Many-to-Many with References

Model many-to-many relationships with references in MongoDB. Learn how to store related data using arrays of document references, perform joins with $lookup, and choose when to use references over embedding.

Focus: model many-to-many relationships with references

Sponsored

You’ve been modeling one-to-many relationships with references, but now you hit a wall: an order can contain many products, and a product can appear in many orders. Duplicating product details in every order isn’t just wasteful—it becomes a maintenance nightmare when a price changes or a description updates. In MongoDB, the solution is to model many-to-many relationships with references: store lightweight arrays of IDs on both sides and join at query time with $lookup. This lesson walks you through exactly that pattern, with practical examples you can run today.

The problem this lesson solves

Relational databases give you join tables for many-to-many relationships. MongoDB doesn’t have tables or native joins—so how do you represent a relationship where both sides have many of each other?

Consider a typical e-commerce catalog:

  • A product can belong to many categories (e.g., "Electronics", "Featured", "Clearance").
  • A category can contain many products (e.g., "Electronics" holds hundreds of items).

If you embed the full product documents in each category, you duplicate data across every category. That leads to inconsistent updates, bloated documents, and slow write performance. If you embed the full category list in each product, you hit the same problem in reverse—and you risk hitting the 16 MB document size limit with large lists.

Additionally, consider users and groups in a collaboration app: users belong to many groups, and groups have many users. Without a join table, you need a strategy that stays flexible and performant as your data grows. The answer is to reference documents by _id and keep only the necessary foreign keys.

Core concept / mental model

Think of a many-to-many relationship in MongoDB as two-way arrows between documents. Instead of storing copies, you store pointers (the _id values) in arrays on both sides of the relationship.

  • On the "one" side of a one-to-many relationship, you already store an array of references. For many-to-many, you simply do that on both sides.
  • Each document stays small: it holds just a list of IDs, not full copies of related data.
  • When you need the actual related documents, you perform a join at query time using the aggregation pipeline's $lookup stage.

Let’s define the terms:

  • Referencing — storing the _id of another document in an array field (e.g., categoryIds in a product).
  • Many-to-many — both sides of the relationship can have multiple related entities (e.g., many products per category, many categories per product).
  • $lookup — an aggregation stage that performs a left outer join equivalent, pulling in matching documents from another collection.

Why not just embed?

Embedding is great for tightly coupled, atomic data—like an address inside a user profile. But many-to-many relationships are inherently loosely coupled: a product’s category list changes frequently, and you don’t want to update every product when a category is renamed. References keep your documents small and your writes atomic.

How it works step by step

Modeling many-to-many relationships with references follows a repeatable pattern:

  1. Design the two collections that participate in the relationship (e.g., products and categories).
  2. Add an array field to each collection to store the _ids of the other side.
  3. Insert documents with those references — you can generate ObjectIds in your application and set them on both sides.
  4. Query with $lookup when you need the full related documents.

This pattern avoids duplication, keeps documents small, and allows each collection to evolve independently.

When to use this pattern

  • Data changes at different rates (e.g., product descriptions vs. category names).
  • The relationship is truly many-to-many and you need to query from both directions.
  • You want to keep documents under the 16 MB limit even with large relationship sets.

The trade-off is that you need to handle joins manually—either in the aggregation pipeline or in your application code—and you must keep references consistent across updates.

Hands-on walkthrough

Let’s build a real example step by step. We’ll model products and categories with a many-to-many relationship using references.

Step 1: Create the collections and insert data

First, insert some categories and products. Notice how each side stores the other side’s IDs.

from pymongo import MongoClient
from bson.objectid import ObjectId

client = MongoClient("mongodb://localhost:27017")
db = client["shop"]

# Insert categories
cat_electronics = db.categories.insert_one({"name": "Electronics"})
cat_clearance = db.categories.insert_one({"name": "Clearance"})

# Insert a product that belongs to both categories
product = {
    "name": "Wireless Headphones",
    "price": 89.99,
    "category_ids": [cat_electronics.inserted_id, cat_clearance.inserted_id]
}
product_id = db.products.insert_one(product).inserted_id

# Update categories to reference the product
# In practice, you might do this in the same transaction or through app logic
print("Inserted product with ID:", product_id)

Step 2: Query with $lookup — get full categories for a product

Now that we have references, let’s join to retrieve the full category documents.

from bson import ObjectId

pipeline = [
    {"$match": {"_id": product_id}},
    {"$lookup": {
        "from": "categories",
        "localField": "category_ids",
        "foreignField": "_id",
        "as": "categories"
    }}
]

result = db.products.aggregate(pipeline).next()
for cat in result["categories"]:
    print("Category:", cat["name"])

Expected output:

Category: Electronics
Category: Clearance

Step 3: Reverse direction — get all products in a category

To find all products in a given category, start from the category side and $lookup products.

cat_id = cat_electronics.inserted_id

pipeline = [
    {"$match": {"_id": cat_id}},
    {"$lookup": {
        "from": "products",
        "localField": "_id",
        "foreignField": "category_ids",
        "as": "products"
    }}
]

result = db.categories.aggregate(pipeline).next()
for prod in result["products"]:
    print("Product:", prod["name"])

Expected output:

Product: Wireless Headphones

That’s the essence of modeling many-to-many with references—store IDs on both sides, then join when needed.

Compare options / when to choose what

When modeling many-to-many relationships, you have two main choices: references (arrays of IDs) or embedded documents (full copies). Here’s a comparison:

Approach Pros Cons Best for
References (arrays of IDs) Small documents, no duplication, flexible queries with $lookup Requires manual joins, needs app-level consistency Frequently changing data, large relationships
Embedded documents (full copies) Single read gets everything, atomic updates Duplication risk, document size limits Small, static data tightly coupled to parent

Variations of the reference pattern

  • Single-sided references: If you only ever query in one direction (e.g., always find categories for a product), you might store references on one side only and use $lookup from that side. But for true many-to-many, two-sided is standard.
  • Two-sided references (shown above): Both collections hold arrays, making both query directions efficient.
  • Join collection (a dedicated collection storing pairs): This acts like a relational join table, useful for adding metadata to the relationship (e.g., quantity in a product-order pairing).

For most many-to-many cases, the two-sided reference pattern is the sweet spot: it balances query flexibility with document size.

Pro tip: For very large datasets, you can create indexes on the reference fields (e.g., category_ids) to speed up $lookup and reverse queries. $lookup performs a foreign collection scan by default, but an index on the foreign field dramatically improves performance.

Troubleshooting & edge cases

Even experienced developers hit snags with reference-based many-to-many models. Here are the most common pitfalls and fixes.

Symptom: $lookup returns empty array

If your $lookup returns no matches, the most common cause is a mismatch of data types. localField on the product might be a string, while foreignField on the category is an ObjectId. Always ensure you store the _id values as the same type.

# Wrong: string vs ObjectId
db.products.insert_one({"name": "x", "category_ids": ["12345"]})

# Right: store actual ObjectIds
db.products.insert_one({"name": "x", "category_ids": [ObjectId("...")]})

Symptom: Duplicate data appears after updates

If you only store references on one side and manually update the other, you might forget to sync. Solution: Keep both sides in sync within a single transaction (MongoDB supports multi-document transactions in replica sets) or centralize the logic in a service-layer function.

# Example of a service function that updates both sides atomically
from pymongo import MongoClient

with client.start_session() as session:
    with session.start_transaction():
        db.products.update_one({"_id": product_id}, {"$addToSet": {"category_ids": cat_id}}, session=session)
        db.categories.update_one({"_id": cat_id}, {"$addToSet": {"product_ids": product_id}}, session=session)

Edge case: Overlapping references

If you use $push instead of $addToSet, you may create duplicates. Always consider using $addToSet when the semantics are a set (a product either belongs to a category or it doesn’t).

Edge case: Removing a relationship

Removing a relationship means updating both sides. If you only remove the reference from one side, the reverse query becomes stale. Always perform two updates in a transaction.

What you learned & what's next

You now understand how to model many-to-many relationships with references in MongoDB. You learned:

  • Why embedding fails for many-to-many scenarios.
  • How to store arrays of _ids on both sides of the relationship.
  • How to use the aggregation framework's $lookup to fetch related documents.
  • How to choose between references and embedding, and common edge cases to avoid.

This pattern is foundational for building scalable, real-world applications—from e-commerce catalogs to social graphs. As you move forward in the MongoDB track, you’ll apply these skills in more complex aggregation pipelines and schema design challenges. Next, you’ll tackle advanced aggregation techniques or transactions to handle multi-document updates with atomicity—building on the reference pattern you just mastered.

Remember: With great power comes great responsibility—keep your references clean, index wisely, and test both query directions.

Practice recap

Now try modeling a students and courses many-to-many relationship on your own. Insert at least two courses and three students, assign students to courses with references, then write one $lookup query to list all students in a given course. Verify that the reverse query also works.

Common mistakes

  • Storing references as strings instead of ObjectIds, causing $lookup to return empty arrays.
  • Only updating one side of the relationship, leaving stale references and inconsistent data.
  • Using $push instead of $addToSet, allowing duplicate IDs in the array and breaking uniqueness expectation.
  • Over-embedding full documents in arrays, blowing past the 16 MB size limit or creating consistency problems.

Variations

  1. Single-sided references: store IDs on only one collection if you only query from that side, sacrificing reverse query efficiency.
  2. Two-sided references: the standard pattern shown here, ideal for bidirectional queries.
  3. Join collection: a dedicated collection storing relationship pairs (e.g., order_id and product_id) when you need extra metadata like quantity.

Real-world use cases

  • E-commerce product-category associations, allowing a product to appear in multiple categories and categories to have many products.
  • Social platform user-group memberships, where users join many groups and groups contain many users.
  • Content tagging systems, where an article has many tags and a tag applies to many articles.

Key takeaways

  • Model many-to-many relationships with references by storing arrays of ObjectIds on both sides of the relationship.
  • Use the aggregation framework's $lookup stage to join referenced documents at query time.
  • References keep documents small and avoid duplication, unlike embedding which risks inconsistency.
  • Always keep references in sync—use transactions to update both sides atomically.
  • Use $addToSet to prevent duplicate references and create indexes on reference fields for $lookup performance.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.