← Back to all posts

MongoDB Relationships: Embed vs Reference and Visualize in DbSchema

For developers deciding whether related MongoDB data goes in one document or in two collections; $lookup, the document size limit and DbSchema's virtual relations are explained where they appear.

On this page

You have two pieces of related data and one question: do they go in the same document, or in two collections? Embed the child inside the parent when the two are read together and the child stays small. Store the parent's _id in the child, and join the two back with $lookup, when the child is read on its own, is shared by several parents, or grows without a limit. MongoDB checks neither form, so the links between collections live only in your code until a diagram shows them.

  1. Introduction to MongoDB
  2. Installation and database creation
  3. CRUD operations on documents
  4. Embedded documents and arrays
  5. Validation rules that enforce structure
  6. Visualize MongoDB relationships, embedded against referenced (you are here)
  7. What is an index in MongoDB?
  8. Aggregation pipeline explained

Every statement below was run in mongosh against MongoDB 8.2.6.

Referenced relationships

A reference works like a foreign key in SQL, without the enforcement. One document has an _id, and a document in another collection stores that value in a field of its own.

db.users.insertOne({ _id: ObjectId("64fabcde1234567890abc001"), name: "Bob Johnson", email: "[email protected]" })
db.movies.insertOne({ _id: ObjectId("64fabcde1234567890abc002"), title: "Inception", genre: "Sci-Fi", releaseYear: 2010 })
db.reviews.insertOne({
  _id: ObjectId("64fabcde1234567890abc003"),
  user_id: ObjectId("64fabcde1234567890abc001"),
  movie_id: ObjectId("64fabcde1234567890abc002"),
  rating: 4,
  comment: "Great movie!"
})

The review's user_id holds the _id of Bob's user document, and movie_id the _id of the movie. To read the review with its author, add a $lookup stage, which puts the matching documents into a new array field:

db.reviews.aggregate([
  { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "author" } }
])

The review comes back with Bob's document inside author:

[
  {
    _id: ObjectId('64fabcde1234567890abc003'),
    user_id: ObjectId('64fabcde1234567890abc001'),
    movie_id: ObjectId('64fabcde1234567890abc002'),
    rating: 4,
    comment: 'Great movie!',
    author: [
      { _id: ObjectId('64fabcde1234567890abc001'), name: 'Bob Johnson', email: '[email protected]' }
    ]
  }
]

author is an array even when exactly one document matches, so add an $unwind stage when you want a plain object.

Embedded relationships

The other form puts the reviews inside the movie, each carrying the part of the user profile it displays.

db.movies.insertOne({
  _id: ObjectId("64fabcde1234567890abc002"),
  title: "Inception",
  genre: "Sci-Fi",
  releaseYear: 2010,
  reviews: [
    {
      user_name: "Bob Johnson",
      email: "[email protected]",
      subscription_plan: "Standard",
      rating: 4,
      comment: "Great movie, but hard to follow."
    }
  ]
})

One read now returns the movie and its reviews. The cost is duplication: Bob's name, email and plan are copied into every movie he reviewed, and a change of plan must update each of them.

The second cost is size. A document may not exceed 16 mebibytes, so an array that gains an entry on every event eventually breaks the write that appends to it. This loop pushes 1 MiB entries onto one document:

db.sessions.insertOne({ _id: 1, logins: [] })
const entry = "x".repeat(1024 * 1024)
for (let i = 0; i < 20; i++) { db.sessions.updateOne({ _id: 1 }, { $push: { logins: entry } }) }

The first 15 pushes succeed, and the 16th fails:

MongoServerError: Plan executor error during update :: caused by :: Resulting document after update is larger than 16777216

The reviews of one film have a natural ceiling, and a login history does not. Coming from SQL, embedding is denormalization, and referencing is the closest MongoDB gets to foreign-key modeling.

What embedding and referencing each cost

The same review stored two ways: on the left, the users, reviews and movies collections, with reviews.user_id and reviews.movie_id pointing at the _id of each; on the right, one movies document holding a reviews array

Embedding buys the read, and a write to one document is atomic, parent and children together. Referencing buys the write: the child changes on its own and can grow past what one document holds, at the price of a second read.

Choose embedding when...Choose references when...
child data is always read with the parentthe related data is reused elsewhere
the embedded data stays small and boundedthe embedded data would grow without a limit
you want fewer read operationschild documents change independently
the relationship is tightly coupledthe relationship is large or many-to-many

One-to-one, one-to-many and many-to-many

The cardinality usually decides between the two. A one-to-one child, such as a user's profile, goes inside as a nested object. A short one-to-many list, such as a user's two or three addresses, goes inside as an array. An order history grows without a limit, so orders go in their own collection, each holding its user's _id.

Many-to-many is where neither side can embed the other without copying it, so one side keeps an array of identifiers:

db.books.insertOne({ _id: 101, title: "MongoDB Basics", author_ids: [1, 2] })
db.authors.insertMany([ { _id: 1, name: "Alice" }, { _id: 2, name: "Bob" } ])
db.books.aggregate([
  { $lookup: { from: "authors", localField: "author_ids", foreignField: "_id", as: "authors" } }
])

$lookup matches each element of the array, with no $unwind needed:

[
  {
    _id: 101,
    title: 'MongoDB Basics',
    author_ids: [ 1, 2 ],
    authors: [ { _id: 2, name: 'Bob' }, { _id: 1, name: 'Alice' } ]
  }
]

Bob came back before Alice, in a different order from author_ids. The $lookup page promises no order for the joined array, so sort it yourself where order matters. When the pairing carries data of its own, such as a role or a date, give it a bridge collection with one document per pair instead. More join patterns are in MongoDB $lookup.

What MongoDB does not check

MongoDB has no foreign key constraint. For referential integrity, the manual tells you to keep references consistent in your application. So this insert, naming a user who does not exist, succeeds:

db.reviews.insertOne({
  _id: ObjectId("64fabcde1234567890abc004"),
  user_id: ObjectId("64fabcde1234567890abc999"),
  movie_id: ObjectId("64fabcde1234567890abc002"),
  rating: 2,
  comment: "Too long."
})

The $lookup above returns this review with author: []: a dangling reference is an empty array, not an error. Deleting Bob with deleteOne reports deletedCount: 1 and leaves his review behind, still pointing at him. Multi-document transactions keep several writes together, but the manual says they cost more than single-document writes and should not replace schema design.

So the relationships live in scattered code, and nobody can read them off the database. A diagram shows them in one place: which collections carry a user_id, and how many point at users.

Visualize the relationships in DbSchema

DbSchema connects to MongoDB and reads 100 documents from the start and 100 from the end of each collection. From them it infers field names, BSON types, nested objects and arrays. Schema Scan Depth in the connection settings raises the sample, up to every document. The result approximates the documents; MongoDB enforces none of it. For a collection with a $jsonSchema validation rule, DbSchema reads the fields from the validator instead, and a change confirmed in Edit Collection while connected reaches the database as well as the model.

MongoDB collections on a DbSchema diagram, with the embedded arrays assignees, comments, checklist and subtasks of the tasks collection expanded under their fields

Where a field holds an ObjectId that matches the _id of a document in another collection, as reviews.user_id does, DbSchema draws a line between the two collections. A reference stored as a string or a number gets no line: drag the field onto the field it refers to, or use Model → Find Virtual Relations, which matches field names. These virtual relations are saved in the model file, and MongoDB never sees them.

Virtual relations drawn on a DbSchema diagram from reviews.user_id to users._id and from reviews.movie_id to movies._id

The Data Explorer follows those lines. Right-click a collection header, choose Data Explorer, and open a related collection from the arrow next to its name. Selecting a review refilters the users and movies panes to the documents matching its user_id and movie_id, and each pane can open the next collection the same way. An empty pane is a dangling reference, found without a query.

The DbSchema Data Explorer with a review selected, the user who wrote it in the users pane, and an empty movies pane

File → Export As... → HTML5/PDF Documentation writes the HTML5 documentation: one file that opens in any browser, with the diagram as a vector image and collection and field comments as mouse-over tooltips.

Download DbSchema, connect it to your MongoDB database, and look at the lines it draws between your collections. Connecting, reading the collections and the interactive diagram are in the free Community Edition. Saving the model file, which keeps the virtual relations, browsing across relations in the Data Explorer, and the HTML5 documentation are in Pro.

FAQ

Should I use DBRef instead of storing the _id?

A DBRef stores the collection name, and optionally the database name, next to the _id. The MongoDB manual recommends a plain _id reference for almost every case. Drivers do not resolve a DBRef automatically, so your application still runs the second query.

See your MongoDB collections as a diagram

DbSchema samples your documents, infers each collection's fields and nested arrays, and draws the relations MongoDB does not declare. The Community Edition is free.