The Document Data Model

A MongoDB document is the basic unit of data storage: a set of field-value pairs, similar to a JSON object, but stored and transmitted as BSON (Binary JSON) rather than plain text. Instead of splitting related data across many rigid tables and joining them at query time like a relational database does, MongoDB lets you group naturally-related data into a single, nested document. Understanding exactly what a document is — what types it can hold, how flexible its shape can be, and when that flexibility helps or hurts you — is the foundation for everything else you’ll do in MongoDB.

Overview: What a Document Really Is

In a relational database, a row is a fixed set of columns defined by a table schema, and related data (a customer’s orders, an order’s line items) lives in separate tables connected by foreign keys and reassembled with JOIN. In MongoDB, a document is a self-contained record that can hold nested objects and arrays directly, so an order and its line items can live in one document instead of two tables. Documents are grouped into collections (roughly analogous to tables), but a collection does not enforce that every document share the same fields or types — two documents in the same users collection can have completely different shapes unless you explicitly add validation.

This schema flexibility is a real feature: it lets you evolve your application without writing a migration every time you add a field, and it lets different document “varieties” coexist (a product with a color attribute next to one with a voltage attribute). But flexibility is not the same as chaos — production applications still need consistent shapes for the fields they actually query and sort on, which is why MongoDB offers optional $jsonSchema validation and why tools like Mongoose layer a schema on top in application code.

BSON: More Than JSON

When you type a document into mongosh, it looks like a JavaScript object literal, but on disk and over the wire MongoDB stores it as BSON. BSON is a binary-encoded superset of JSON that adds types JSON doesn’t have — and that’s important because JSON alone can’t tell a date from a string, or a 32-bit integer from a floating-point number.

BSON type Example Why JSON can’t do this
ObjectId ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") A compact 12-byte unique identifier with an embedded timestamp; JSON has no identifier type.
Date ISODate("2026-08-03T10:15:00Z") Stored as milliseconds since the Unix epoch, so range queries and sorting work correctly; JSON only has strings.
Int32 / Int64 / Double / Decimal128 29, NumberLong(1), 19.99 JSON has one generic “number”; BSON distinguishes integer precision from floating point and exact decimal.
Binary data BinData(0, "...") Raw bytes (e.g. a hash or image thumbnail) without base64 bloat.
Array ["premium", "beta-tester"] Same as JSON, but each element keeps its own BSON type.
Embedded Document { city: "Bengaluru", zip: "560001" } Nested objects, same as JSON, stored inline in the parent document.

This matters practically: if you insert the number 29 versus the string "29", MongoDB stores and queries them as genuinely different types, and a query for { age: 29 } will not match a document where age was accidentally inserted as the string "29".

Syntax

A document is written as a JavaScript object literal. There’s no fixed template — the shape depends entirely on your data — but every document follows the same rules:

{
  _id: ObjectId("..."),      // unique identifier; auto-generated if you omit it
  fieldName: value,          // any BSON type: string, number, boolean, date...
  nestedField: {             // an embedded document
    subField: value
  },
  arrayField: [ value1, value2 ] // an array of any BSON type, including documents
}
  • _id — every document must have one and it must be unique within its collection; if you don’t supply one, MongoDB generates an ObjectId automatically.
  • field names — strings; by convention camelCase, and they cannot start with $ or contain a . as a literal character (those are reserved for operators and dot-notation paths).
  • values — any BSON type, including nested embedded documents and arrays, to arbitrary depth (though deep nesting has real costs, covered below).
  • document size — a single document is capped at 16MB; this is a deliberate design constraint that pushes you toward referencing instead of embedding once data grows unbounded.

Examples

Example 1: Inserting a Document with Nested and Array Fields

db.users.insertOne({
  name: "Priya Sharma",
  email: "priya.sharma@example.com",
  age: 29,
  tags: ["premium", "beta-tester"],
  address: {
    city: "Bengaluru",
    state: "KA",
    zip: "560001"
  },
  createdAt: new Date()
});

Output:

{
  acknowledged: true,
  insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1")
}

MongoDB generated the _id automatically since we didn’t supply one. Notice address is a full embedded document and tags is an array — both stored inline as part of the same 16MB-capped document, retrievable in a single read with no join.

Example 2: Schema Flexibility Across Documents

db.users.insertMany([
  { name: "Arjun Mehta", email: "arjun.mehta@example.com" },
  { name: "Zoe Chen", email: "zoe.chen@example.com", loyaltyPoints: 450, tags: ["vip"] }
]);

db.users.find({}, { name: 1, loyaltyPoints: 1, _id: 0 });

Output:

[
  { name: "Priya Sharma" },
  { name: "Arjun Mehta" },
  { name: "Zoe Chen", loyaltyPoints: 450 }
]

Three documents live in the same users collection with three different shapes — no ALTER TABLE, no migration, no error. Documents that lack a field simply don’t return it in a projection. This is powerful for iterating quickly, but it also means your application code must defensively handle a field being absent (or you add validation, discussed under Best Practices).

Example 3: Querying Into an Embedded Array with Dot Notation

db.orders.insertOne({
  customer: "Zoe Chen",
  status: "processing",
  lineItems: [
    { sku: "SKU-1001", name: "Wireless Mouse", qty: 2, price: 19.99 },
    { sku: "SKU-2002", name: "USB-C Cable", qty: 1, price: 9.99 }
  ],
  placedAt: new Date()
});

db.orders.find({ "lineItems.sku": "SKU-2002" });

Output:

[
  {
    _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d4"),
    customer: "Zoe Chen",
    status: "processing",
    lineItems: [
      { sku: "SKU-1001", name: "Wireless Mouse", qty: 2, price: 19.99 },
      { sku: "SKU-2002", name: "USB-C Cable", qty: 1, price: 9.99 }
    ],
    placedAt: ISODate("2026-08-03T10:20:00.000Z")
  }
]

The dotted path "lineItems.sku" reaches inside every element of the embedded array to find a match, without any join. This is the payoff of embedding: the whole order, including its line items, is retrieved in one document read.

How MongoDB Stores and Processes Documents Internally

When you call insertOne(), the driver (or mongosh itself) does most of the work client-side before anything reaches the server:

  • ObjectId generation happens on the client. If you don’t supply _id, the driver generates an ObjectId before sending the insert — it encodes a 4-byte timestamp, a 5-byte random value, and a 3-byte incrementing counter, which is why ObjectIds are roughly sortable by creation time and don’t require a round-trip to the server to generate.
  • The JS object is serialized to BSON. Every field name and value is encoded with a type byte, so the server never has to guess whether 29 is an integer or a string — the type is explicit in the bytes.
  • The storage engine (WiredTiger, by default) writes the BSON document to disk in its collection’s data files, and updates any indexes that cover the inserted fields.
  • Field order is preserved within a document as stored, which matters for BSON comparison and for exact-match queries on embedded documents (where field order must match), but you should not design application logic that depends on field order.
  • Reads reverse the process: the server locates the document (via an index scan or a full collection scan), reads the raw BSON bytes, and the driver deserializes them back into native JavaScript types — ISODate becomes a real Date object, ObjectId becomes an ObjectId instance, and so on.

Common Mistakes

Mistake 1: Comparing an ObjectId to a Plain String

A URL param or request body always arrives as a string, but a document’s _id is stored as an ObjectId. Comparing them directly silently matches nothing:

function findUserById(idString) {
  // idString is "64f1a2b3c4d5e6f7a8b9c0d1" -- a string, not an ObjectId
  return db.users.find({ _id: idString }); // WRONG: matches zero documents
}

Fix it by converting the string to an ObjectId before querying:

import { ObjectId } from "mongodb";

function findUserById(idString) {
  return db.users.find({ _id: new ObjectId(idString) }); // correct
}

Mistake 2: Letting an Embedded Array Grow Unbounded

Embedding is great for bounded data, but embedding something that grows forever — like every comment ever left on a blog post — drives the parent document toward the 16MB limit and forces MongoDB to rewrite the whole document on every append:

// WRONG: comments embedded directly in the post, with no bound on growth
db.posts.updateOne(
  { _id: postId },
  { $push: { comments: { author: "reader123", text: "Great post!", postedAt: new Date() } } }
);

The fix is to reference instead of embed: store comments in their own collection, linked by postId, and query them separately (optionally joined back with $lookup when needed):

db.comments.insertOne({
  postId: postId,
  author: "reader123",
  text: "Great post!",
  postedAt: new Date()
});

db.comments.find({ postId: postId }).sort({ postedAt: -1 });

Mistake 3: Treating “Schema-Flexible” as “No Discipline Needed”

Because MongoDB doesn’t enforce a schema by default, it’s easy to accidentally insert the same logical field with two different types:

db.users.insertOne({ name: "Sam", age: "thirty" }); // age inserted as a string
db.users.insertOne({ name: "Lee", age: 30 });        // age inserted as a number

db.users.find({ age: { $gt: 25 } }); // only matches Lee -- Sam's string age is silently skipped

The comparison operators are type-aware, so a string never matches a numeric range query. The fix is to add a $jsonSchema validator on the collection (or a Mongoose schema in application code) that requires age to be a number, catching the bad insert at write time instead of producing a silent, hard-to-debug query gap.

Best Practices

  • Design your document shape around how your application reads data, not around normalization habits carried over from relational modeling.
  • Embed data that’s read together and naturally bounded (an order’s line items); reference data that’s large, shared across many parents, or grows without limit (a full comment thread, a product catalog).
  • Add $jsonSchema validation (or a Mongoose schema) to any collection backing real application logic, so a typo doesn’t silently insert a document with the wrong type or a missing required field.
  • Keep field names short and consistent across every document in a collection — unlike a table’s column names, field names are repeated in every single document, so bloated names cost real storage and index space.
  • Always store dates as native BSON Date, never as strings, so range queries, sorting, and TTL indexes work correctly.
  • Watch the 16MB document size limit; if a design could realistically exceed it, that’s a signal to reference rather than embed.
  • Convert string IDs to ObjectId at the boundary where external input enters your application, not scattered throughout query code.

Practice Exercises

  • Design a document (don’t insert it yet, just write the JS object literal) for a books collection that embeds an array of reviews, each with reviewer, rating, and text. Then explain in one sentence why this embedding choice is appropriate or risky depending on how many reviews a popular book might get.
  • Insert two documents into a new products collection where one document has a discountPercent field and the other doesn’t. Run a find() with a projection that includes discountPercent, and predict what the document missing that field will show before you run it.
  • Given the orders document from Example 3, write a query using dot notation to find every order that contains a line item with qty greater than 1. Expected result shape: an array containing the one order document whose lineItems array has an element matching that condition.

Summary

  • A document is a BSON record of field-value pairs; collections group documents but don’t enforce a single shape across them.
  • BSON extends JSON with real types — ObjectId, Date, distinct integer/double/decimal types, and binary data — that plain JSON can’t represent.
  • Every document has a unique _id, generated client-side as an ObjectId if you don’t supply one, and it encodes a creation timestamp.
  • Embed data that’s read together and bounded in size; reference data that’s large, shared, or grows unboundedly.
  • Schema flexibility is a tool, not an excuse — use $jsonSchema validation or an ODM schema for collections your application logic depends on.
  • Documents are capped at 16MB; design choices that could exceed that should reference instead of embed.