← Database Concepts

The Document Model: Documents, Collections, and ObjectIds

Published on 2026-08-20·v1.0

Objective

Understand MongoDB's core unit of storage — the document — and the containers around it (collections with dynamic schemas, databases, namespaces), why the design decisions behind it were made (ease of use, scaling out, rich features without sacrificing speed), and how embedded documents plus arrays become the schema-design primitive that stands in for the JOINs a relational database would use.

Use Cases

  • Reading an existing MongoDB codebase and knowing immediately what db.blog.posts refers to: the posts collection in the blog "subcollection" namespace of whatever database db currently points at — and knowing that blog itself does not have to exist.
  • Deciding whether a piece of related data (an address, a set of reviews) belongs inside a document as an embedded document or array, or in a separate collection — the first real schema-design decision on any MongoDB project.
  • Explaining to a team coming from PostgreSQL why "no schema" does not mean "no schema design", and why one collection per kind of document is still the right default even though MongoDB will happily let you mix them.
  • Debugging a query that returns nothing because a value was stored as the string "5" rather than the number 5, or because a date got stored as a string — MongoDB is type-sensitive, and dynamic schemas mean nothing stops the bad write.
  • Reading a document's _id to recover approximately when the document was created, without having added a createdAt field.

Deep Dive

The four design decisions (Chapter 1)

The book opens by naming the reasoning behind MongoDB's shape, and the rest of the model follows from it:

  • Ease of use. A document replaces the "row" with something more flexible. Embedded documents and arrays let a single record represent a complex hierarchical relationship, which "fits naturally into the way developers in modern object-oriented languages think about their data." There are no predefined schemas: a document's keys and values are not of fixed types or sizes, so fields can be added or removed as needed and dozens of data models can be tried before one is chosen.
  • Designed to scale. Scaling comes down to scaling up (a bigger machine — the path of least resistance, but expensive and eventually physically capped) or scaling out (partitioning across more machines — cheaper and more scalable, but "more difficult to administer a thousand machines than it is to care for one"). MongoDB was designed for scaling out: the document model makes data easier to split across servers, and MongoDB balances data and load across a cluster automatically. Critically, cluster topology — even whether there is a cluster at all behind a connection — is transparent to the application, so changing the deployment topology does not change application logic.
  • Rich with features. Secondary indexes (unique, compound, geospatial, full-text, and indexes on nested documents and arrays), an aggregation framework built on data-processing pipelines, TTL collections for expiring data such as sessions, capped (fixed-size) collections for recent data such as logs, partial indexes limited to documents matching a filter, and a protocol for storing large files with their metadata.
  • Without sacrificing speed. Opportunistic locking in the WiredTiger storage engine to maximize concurrency, RAM used aggressively as cache, automatic index selection. The book is explicit that this is a trade: MongoDB "is not intended to do everything that a relational database does" and deliberately offloads some processing and logic to the drivers or application code, and that streamlined design is part of why it is fast.

The most conspicuous omission is complex joins. The book notes joins are supported "in a very limited way" through the $lookup aggregation operator introduced in 3.2, with more complex joins (multiple join conditions, uncorrelated subqueries) added in 3.6 — and frames this as an architectural decision, because joins are hard to provide efficiently in a distributed system.

Documents

A document is an ordered set of keys with associated values. In JavaScript it maps to an object; in other languages to a map, hash, or dictionary:

plaintext
{"greeting" : "Hello, world!", "views" : 3}

Rules the book calls out explicitly:

  • Keys are strings, and any UTF-8 character is allowed, with exceptions: \0 (null) is forbidden because it signifies the end of a key, and . and $ should be treated as reserved (drivers will complain if used inappropriately).
  • MongoDB is type-sensitive and case-sensitive: {"count" : 5} and {"count" : "5"} are distinct documents, as are {"count" : 5} and {"Count" : 5}.
  • Documents cannot contain duplicate keys, so {"greeting" : "Hello, world!", "greeting" : "Hello, MongoDB!"} is not a legal document.

Collections and dynamic schemas

A collection is a group of documents — the analog of a table if a document is the analog of a row. Collections have dynamic schemas: documents in one collection can have any number of different shapes, different keys, different numbers of keys, and values of different types. Both of these can legally live in the same collection:

plaintext
{"greeting" : "Hello, world!", "views": 3} {"signoff": "Good night, and good luck"}

Which raises the obvious question the book poses directly — if any document can go in any collection, why have more than one? Four reasons:

  1. Developer and admin sanity. Mixing kinds means every query must either filter to one shape or handle several. "If we're querying for blog posts, it's a hassle to weed out documents containing author data."
  2. Speed of enumeration. Getting a list of collections is much faster than extracting the list of document types inside a collection. Three separate collections beat one collection with a discriminating "type" field holding "skim", "whole", or "chunky monkey".
  3. Data locality. Fetching several blog posts from a posts-only collection will likely require fewer disk seeks than fetching them from a collection that also holds author data.
  4. Indexing. Indexes are defined per collection, and creating one already imposes some structure (especially a unique index). One type per collection indexes more efficiently.

The book's conclusion is worth quoting against the "schemaless" marketing: "While not required by default, defining schemas for your application is good practice and can be enforced through the use of MongoDB's document validation functionality and object-document mapping libraries."

Naming restrictions: the empty string is invalid; \0 is forbidden; names starting with system. are reserved for internal collections; user collections should avoid $.

Subcollections are a naming convention, not a feature: blog.posts and blog.authors are two independent collections whose names happen to share a prefix. There is no relationship between them and a blog collection — which "doesn't even have to exist." The convention is load-bearing in practice, though: GridFS uses subcollections to separate file metadata from content chunks, and drivers give it syntactic sugar (db.blog.posts resolves to the blog.posts collection).

Databases and namespaces

One MongoDB instance hosts several databases, each grouping zero or more collections. The rule of thumb: all data for a single application in the same database; separate databases are for separate applications or users on one server.

Database name restrictions: not empty; may not contain /, \, ., ", *, <, >, :, |, ?, $, space, or \0 ("basically, stick with alphanumeric ASCII"); case-insensitive; maximum 64 bytes. The book explains why these exist: historically, before WiredTiger, database names became files on the filesystem.

Three reserved databases: admin (authentication, authorization, and some administrative operations), local (per-server data, including replica-set replication data — and it is never itself replicated), and config (used by sharded clusters to store shard information).

Concatenating a database name with a collection name gives the fully qualified name, called a namespace: the blog.posts collection in the cms database has namespace cms.blog.posts.

Data types

Documents are "JSON-like", but plain JSON has only six types — null, boolean, numeric, string, array, object — which the book calls out as insufficient for a database: no date type, only one number type (no float/integer or 32-bit/64-bit distinction), no regular expressions, no functions. MongoDB adds types while keeping the key/value nature:

Type Shell representation Note from the book
Null {"x" : null} Represents both a null value and a nonexistent field
Boolean {"x" : true}
Number {"x" : 3.14}, {"x" : 3} Shell defaults to 64-bit floating point; use NumberInt (4-byte) or NumberLong (8-byte) for real integers
String {"x" : "foobar"} Any UTF-8 string
Date {"x" : new Date()} 64-bit integer, milliseconds since the Unix epoch; time zone is not stored
Regular expression {"x" : /foobar/i} JavaScript regex syntax
Array {"x" : ["a", "b", "c"]}
Embedded document {"x" : {"foo" : "bar"}}
ObjectId {"x" : ObjectId()} 12-byte document ID
Binary data — Cannot be manipulated from the shell; the only way to store non-UTF-8 strings
Code {"x" : function() { }} Arbitrary JavaScript in queries and documents

The date gotcha the book flags is a JavaScript one, not a MongoDB one: always call new Date(), never Date() — the latter returns a string representation, and "strings do not match dates and vice versa," which quietly breaks removing, updating, and querying.

Embedded documents and arrays: the JOIN replacement

This is where the document model stops being a syntax difference and becomes a design difference. An address nested inside a person:

plaintext
{ "name" : "John Doe", "address" : { "street" : "123 Park Street", "city" : "Anytown", "state" : "NY" } }

In a relational database this would be two rows in two tables (people and addresses) joined at read time. In MongoDB it is one document, one read.

What makes this work rather than just being nested blobs is that MongoDB understands the structure: it can reach inside embedded documents and arrays to build indexes, run queries, and perform updates. For arrays, that means querying for documents where 3.14 is an element of the "things" array in {"things" : ["pie", 3.14]}, indexing the "things" key to speed that up, and performing atomic updates that modify array contents in place. Arrays can hold mixed types and nest, and are used interchangeably for ordered operations (lists, stacks, queues) and unordered ones (sets).

The book names the cost in the same breath: more data repetition. If addresses were a separate relational table, fixing a typo in one address fixes it for everyone who shares it via the join. With MongoDB, "we'd need to fix the typo in each person's document."

_id and ObjectIds

Every document stored in MongoDB must have an "_id" key, unique within its collection. The value can be any type but defaults to an ObjectId. Two different collections can each hold a document with _id of 123; neither can hold two.

Why not an autoincrementing primary key? Because MongoDB was designed as a distributed database, and "it is difficult and time-consuming to synchronize autoincrementing primary keys across multiple servers." An ObjectId is designed to be lightweight while still being generatable in a globally unique way across machines, with no coordination.

An ObjectId is 12 bytes, which renders as 24 hexadecimal digits (2 per byte) — the string looks twice as large as the data actually is. The layout:

Bytes Content
0-3 Timestamp — seconds since the Unix epoch
4-8 Random value
9-11 Counter, starting from a random value

Each piece earns its place:

  • The 4-byte timestamp comes first, which makes ObjectIds sort in rough insertion order — not a strong guarantee, but enough to make them efficient to index — and embeds an implicit document-creation time that most drivers expose a method to extract.
  • The 5 random bytes plus the timestamp give uniqueness across machines and processes for a given second. Note that servers do not need synchronized clocks for ObjectIds to work: the actual timestamp value does not matter, only that it is often new (once per second) and increasing.
  • The 3 counter bytes, starting at a random value to avoid colliding across machines, provide uniqueness within a second in a single process — allowing up to 256³ = 16,777,216 unique ObjectIds per process per second.

If no _id is present at insert time, one is added automatically. The book notes this "can be handled by the MongoDB server but will generally be done by the driver on the client side" — meaning the ID usually exists in your application process before the write ever leaves it.

The shell

MongoDB ships a JavaScript shell (mongo in the book) that is both a full JavaScript interpreter — Math.sin(Math.PI / 2), user-defined recursive functions, multi-line statements — and a standalone MongoDB client. On startup it connects to the test database and assigns that connection to the global db. use video is syntactic sugar borrowed from SQL shells that adds no functionality; db.movies returns the movies collection. CRUD is insertOne / find and findOne / updateOne / deleteOne and deleteMany, and inserting a document like {"title": "Star Wars: Episode IV - A New Hope", "director": "George Lucas", "year": 1977} returns the server-assigned insertedId as an ObjectId.

Two shell habits worth carrying forward: typing a function name without parentheses prints its JavaScript source (a fast way to recall parameter order), and .mongorc.js in the home directory runs at every startup, commonly used to no-op dangerous helpers like db.dropDatabase and DBCollection.prototype.drop — protection against fat-fingering, explicitly not against malicious users.

Book vs. today

Four things to correct or confirm when reading this chapter in 2026:

The mongo shell is gone — it is mongosh now. Everything the book shows in the shell ($ mongo, mongo --nodb, mongo script1.js, the MongoDB shell version: 4.2.0 banner) refers to the legacy shell, which was deprecated in MongoDB 5.0 and removed in 6.0. The replacement is mongosh, a Node.js-based REPL. The concepts and most method names carry over unchanged, but output formats differ — the book's updateOne result WriteResult({"nMatched": 1, "nUpserted": 0, "nModified": 1}) is legacy-shell output; mongosh returns { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }. This is a replacement, not a deprecation-in-place.

The ObjectId layout in this edition is still exactly right. Worth stating because older MongoDB material (and a lot of blog posts) describes ObjectId as timestamp + machine identifier + process ID + counter, which was the pre-3.4 layout. The 3rd edition documents the current one — 4-byte timestamp, 5-byte random, 3-byte counter — and current MongoDB documentation agrees byte for byte, adding one detail the book omits: timestamp and counter are stored big-endian, unlike other BSON values.

The 120-byte namespace limit is an MMAPv1-era number. The book states namespaces are "limited to 120 bytes in length and, in practice, should be fewer than 100 bytes long." Current MongoDB allows 255 bytes for unsharded collections and views, and 235 bytes for sharded collections. The database-name limit of 64 bytes the book gives is unchanged. Relatedly, the book lists system.namespaces as a live internal collection; that was an MMAPv1 artifact and does not exist under WiredTiger. The system. prefix is still reserved.

Joins are less limited than the book's "very limited way" suggests, but the architectural point stands. $lookup has kept gaining capability since 3.6 (it can now target sharded collections, and correlated subqueries are far more ergonomic), and schema validation matured into $jsonSchema-based validators with validationLevel/validationAction — MongoDB rejects invalid documents by default once a validator is attached, or can be set to merely log a warning. But the book's underlying reasoning has not changed: the model still expects you to embed rather than join for the common read path, and validators are opt-in per collection, not a schema the database imposes for you.

Trade-offs

  • "No JOINs" is a scaling win and a write-amplification bill. Embedding an address in each person makes the read one document fetch, no join — and makes correcting a shared address an N-document write instead of a one-row UPDATE. The book says this plainly. The decision is really "where do you want to pay?": relational pays on every read to keep writes cheap, MongoDB pays on fan-out writes to keep reads cheap. That trade is fine when the embedded data is genuinely owned by the parent (an order's line items) and turns hostile when it is shared and mutable (a company address on 50,000 employee documents).
  • No referential integrity means the database will not catch your dangling reference. There is no foreign key. If you do split data across collections (which the four "why separate collections" reasons push you toward), nothing prevents deleting a document that other documents point at, and nothing cascades. That check moves into application code or an ODM, where it is enforced only as consistently as your team is disciplined — and only for writes that go through your application, never for the ad-hoc mongosh fix during an incident.
  • Dynamic schemas accelerate week one and tax year two. Trying "dozens of models for the data" without migrations is a real advantage while the shape is unknown. Once the app matures, the schema exists whether or not the database knows about it — it is just implicit, and now spread across every version of every document ever written. You still need a migration story; it is just that yours has to handle documents in mixed shapes simultaneously, rather than a table that is atomically in one shape or the other. Schema validators ($jsonSchema) help going forward but do nothing about the documents already on disk unless you set validationLevel deliberately and backfill.
  • Type-sensitivity plus no schema enforcement is a silent-failure generator. {"count": 5} and {"count": "5"} are distinct, and nothing rejects the second one. A relational INTEGER column would have failed the write at the boundary; MongoDB accepts it and the query that filters on count: 5 just quietly returns fewer rows. This is the single most common source of "the data is there but the query returns nothing" — the same applies to dates stored as strings via Date() instead of new Date().
  • ObjectIds buy coordination-free ID generation and give up strict ordering. Client-side generation with no round trip to a sequence server is exactly what makes sharding practical, and the leading timestamp gives locality for the index. But sort order is only rough insertion order — two documents created in the same second on different machines sort by random bytes, not by which was actually first. If your application needs true insertion ordering, _id is not it.
  • One collection per document type is a convention, not a constraint — which means it is your job. MongoDB will not stop you from mixing blog posts and authors in one collection. Every one of the book's four reasons (query hygiene, enumeration speed, data locality, per-collection indexing) is a benefit you get by choosing to enforce a discipline the database is indifferent to.

Documentation Links