Skip to main content

Document Databases

Unsealing the Parcel

The last lesson ended with a question: what happens when the engine finally gets to see inside the value?

This isn't a thought experiment — it's literally how part of this family was born. DynamoDB and Aerospike started life as key-value stores with opaque blobs, then replaced the blob with something the engine could parse: a JSON document. Suddenly the store could index inside the value, query by inner fields, update one field without the application rewriting the whole thing. The parcel got unsealed, and a family appeared.

Here's the marketplace's order 501, the way a document database holds it:

{
  "order_id": 501,
  "status": "delivering",
  "customer": { "name": "Aryan", "addr": "14 Hill Rd" },
  "items": [
    { "dish_id": 99, "name": "Spicy Ramen", "qty": 2, "price": 450 },
    { "dish_id": 12, "name": "Gyoza", "qty": 1, "price": 220 }
  ]
}

One record. Nested, self-contained, shaped exactly like the object your code passes around — no translation layer, no five-table assembly. That shape has a name that will carry this whole lesson: an aggregate. The document model's bet, in one sentence: your data's natural unit is a self-contained tree, and the unit of storage should be the unit your application thinks in.

In this lesson: what that bet buys (locality — and the end of the impedance mismatch from Relational Databases: The Default for a Reason), what it costs (whole-document rewrites, drifting copies, a hard 16 MB wall), why this family already existed in 1970 under another name — and the one design decision that decides everything: where you draw the aggregate's boundary.

Scope: the modeling method — designing collections from access patterns, single-table tricks — is two lessons ahead (Data Modeling for Scale and NoSQL Modeling: Denormalization & Single-Table Design); MongoDB's internals and interview ammo live in MongoDB (Interview Mastery). This lesson is the model's physics.

One marketplace order drawn two ways. Left, the document: order 501 as a single nested JSON aggregate — status, embedded customer, an items array, and a gps_trail flagged red with grows? — with a violet arrow showing the index reaching INSIDE at items.dish_id, and the green focal card: 1 fetch, the page arrives whole. Right, the rows: the same order as four real tables with PK and FK badges connected by column-level foreign-key arrows — order_items.order onto orders.id in amber, orders.cust_id onto customers.id in emerald, gps_pings.order up to orders.id in sky — each fact lives once, arrows recombine it at read time, and the violet focal card: a 4-way join, one query recombining at read. In the gutter, two routing chips: read it whole? choose the document; shared or growing facts? choose the rows.

Locality: The Payoff, Then the Bill

Render that order's page from relational tables and you touch four of them — orders, order_items, customers, dishes — recombined by joins. Render it from the document and you make one fetch. Everything the page needs arrives together, because it was stored together. That's locality, and it's the document model's crown jewel: for aggregate-shaped reads, nothing is simpler or faster to reason about.

It also ends a quiet, decade-long argument. The relational lesson's bill included the impedance mismatch — your code thinks in nested object graphs, tables think flat, and ORMs paper over the gap. The document model dissolves the mismatch honestly: the stored shape is the code shape. There's a reason every modern API speaks nested JSON — the model won the wire even before it won any disks.

Now the bill, and it's the exact mirror image. Locality is a read optimization that you pay for on write: to update a document, the engine typically rewrites the whole document on disk. MongoDB's own engineering blog does the arithmetic: "for a 2 MB document, that's 2 MB written to disk each time" — every tiny status change, every appended array element, the full 2 MB again. Practitioners see performance sag when documents reach just 1–2 MB, long before the hard limit.

And there is a hard limit: BSON documents cap at 16 MB. The classic road to hitting it is the unbounded array — embed every comment on the post, every GPS ping on the delivery, every event on the device, and the aggregate grows without ceiling until physics intervenes. The working heuristic from production teams: mutable embedded arrays stay under a couple hundred elements; anything that grows forever lives outside the aggregate.

Keep both edges in view and the model stops being a preference and becomes an engineering decision: documents reward you exactly when the aggregate is read whole and stays small.

Locality's bill, drawn as one large scene. Left: touch one field — a dark document card with one amber field highlighted fires a bold UPDATE arrow into a large amber block reading 2 MB of I/O — the WHOLE document, rewritten; a 2 MB parcel costs 2 MB of I/O per touch. Right: and the parcel grows — a long size gauge runs green, then amber at the 1–2 MB degradation mark, to a heavy red line at 16 MB: the wall; unbounded embedded arrays are the classic wound, the gps_trail that just appends is marching toward the wall on a schedule. The rule band: KEEP AGGREGATES SMALL AND BOUNDED — embed what you read together, reference what grows.

Schema-on-Read, Mechanically

You've now met "schema-on-read" three times as a correction — the landscape lesson, the KV lesson — so let's finally watch the machinery turn.

Ship a new feature — say, scheduled deliveries — and with a document store you just… start writing orders that carry a scheduled_for field. No migration, no ALTER TABLE, no lock on a ten-million-row table (Relational Databases showed you what that lock costs). Old orders don't have the field; new ones do; the collection happily holds both. This is the flexibility that made the family famous, and at prototype speed it is genuinely wonderful.

Now watch where the schema went. It didn't vanish — it moved into every reader, forever. Each piece of code that touches an order now handles two shapes: if (order.scheduled_for === undefined) …. Add a third variant next quarter, a fourth after the acquisition — the collection accretes history like sediment, and every consumer carries the full archaeology. The engine checks nothing at the door (remember what constraints bought you — this store has no CHECK, no FK, no NOT NULL waiting to argue).

Neither policy is "right." Schema-on-write buys a shared contract and charges for changes; schema-on-read buys cheap change and charges every read, in code, indefinitely. Fast-moving aggregate data leans one way; a ledger shared by five teams leans the other. What you may not do is pretend the second option has no price.

You've Met This Family Before: 1970

Here's the part almost no course tells you, and it's the sharpest analytical tool this lesson can hand a senior engineer: the document model is not new. It isn't even second-generation. It's the model the database world started with.

The dominant database of the early 1970s — IBM's IMS, running the Apollo program's parts catalog — was hierarchical: records nested inside records, one big tree. Sound familiar? It had locality. It matched how programmers thought. And it had two failure modes so painful they reshaped the industry: many-to-many relationships didn't fit the tree (a dish appears in thousands of orders — whose subtree does it live in?), and it had no joins — if your question didn't follow the tree's shape, you wrote traversal code by hand.

Codd's relational model — the previous lesson's whole story — was invented specifically to fix this. Flatten the tree into tables, store each fact once, let any question recombine facts at read time. The industry fought about it for a decade; relational won so thoroughly that trees disappeared for thirty years.

Then they came back — with better ergonomics, JSON instead of punch-card segments, and indexes that reach inside the tree. Kleppmann's Designing Data-Intensive Applications frames today's document databases as a rerun of that 1970s debate, and the point of knowing this isn't trivia: the tree's old weakness is still the tree's weakness. Where your data is genuinely many-to-many — dishes across orders, students across courses — the document model strains today for the same reason IMS strained then. History already ran this experiment. Use its results.

IBM's IMS drawn as one large tree. A dark root node labeled the order branches into segment A and segment B, each branching into leaves — and two amber leaves on opposite branches both read dish 99, joined underneath by a red dashed arc and the verdict: the same fact, twice. The closing line: many-to-many broke the tree — the relational model was invented to fix exactly this; the tree's old weakness is still the tree's weakness.

Drive It: Draw the Boundary Yourself

Everything in this lesson funnels into one repeated design decision: what goes inside the aggregate, and what points out of it? Embed the items? Copy the customer's name into the order, or reference the customers collection? Keep the courier's GPS trail inside the document… as it grows forever?

Below, you make those three calls yourself, with the same order as five relational rows alongside for honesty. Then fire the five workloads. Try this arc: first, leave everything embedded and render the page — one read, locality at its best. Then make the customer rename themselves and watch 1,240 documents pay for it. Then hammer the GPS button and watch the size bar march toward the 16 MB wall while write-amplification compounds — and then flip the trail to a reference and hammer it again. Finally, try to find a boundary that stays green on all five workloads.

One Order, Two Shapes — draw the document's aggregate boundary yourself (embed or reference the items, the customer snapshot, the GPS trail), fire five real workloads, and watch reads, write-amplification, drift and the 16 MB bar respond live.

The Trade-off: The Same Coin, Flipped

Step back from the widget and notice what you were actually tuning. The relational lesson taught one fact, one place: store each fact once, pay a join to recombine. The document model is the same coin flipped: pre-combine the facts at write time, pay for it in duplication (the 1,240 rewrites), growth (the 16 MB march), and cross-aggregate questions (dish 99). Neither side abolished the trade-off; they just moved the toll booth from read to write.

So the decision framing, as you'd defend it in a review:

Documents win when your data is genuinely aggregate-shaped — read whole, updated as a unit, bounded in size, rarely queried across boundaries. Orders, carts, user profiles, product listings, CMS articles: the aggregate is the working set, locality pays on every request, and schema flexibility matches how these objects actually evolve.

Rows win when facts are shared or questions cut across — the many-to-many core (catalog, inventory, finances), anything five teams read through one contract, anywhere drift is unacceptable. And the boundary rule you derived in the widget is the everyday compass: embed what you read together; reference what grows or is shared. (Turning that compass into full designs — access-pattern-first modeling, single-table patterns — is exactly NoSQL Modeling: Denormalization & Single-Table Design.)

And the 2026 reality check, because this course keeps cashing the same twist: MongoDB added multi-document transactions (4.0) and SQL for Atlas; Postgres added JSONB with indexes inside documents; Uber's Docstore serves a document API on MySQL at tens of petabytes. The 2024 Stonebraker–Pavlo verdict — document stores and relational engines are "on a collision course... nearly indistinguishable in the future" — means the family's identity isn't JSON syntax. It's the aggregate bet. You can take that bet inside Postgres, inside Mongo, or inside a homegrown layer on MySQL; what matters is knowing when the bet pays.

One last fairness note, because folklore outlives fixes: early MongoDB's data-loss reputation (the 2010s, MMAPv1, unacknowledged writes) is a solved chapter — WiredTiger, majority write concern, and real transactions ended it years ago. Judge the model by its physics, not its 2012 headlines.

The 2024 collision course, drawn as one large scene. Two lanes converge: a MongoDB chip that grew transactions and SQL-ish queries, and a Postgres chip that grew JSONB and document indexes, their bold arrows meeting at a violet spark. Beside it, the proof and the verdict: Uber Docstore — a document API served ON MySQL at tens of petabytes — and identity = the AGGREGATE BET, not the JSON syntax; the bet survives any engine. Closing line: embed what you read together, reference what grows or is shared — on whichever engine you're holding.

🧪 Try It Yourself

Five marketplace data shapes. For each: document or rows — and if document, where's the boundary? Commit before scrolling.

  1. A restaurant's menu (sections, dishes, prices, photos) — read whole on every restaurant page, edited by one owner.
  2. Dish ↔ dietary-tag mapping ("vegan", "gluten-free") used by search filters across ALL restaurants.
  3. A support ticket with its message thread (usually 3–10 messages, occasionally 400).
  4. The courier's live location history (5 pings/second, all day).
  5. A user's notification preferences (a dozen toggles, read at every send, changed twice a year).

Answers. (1) Document — the textbook aggregate: read whole, one writer, bounded size; embed sections and dishes. (2) Rows — pure many-to-many queried across the entire catalog; the tree has no good home for it (IMS's ghost — this is the 1970 problem, verbatim). (3) Document with a boundary decision — embed the common 3–10 messages for locality, but that "occasionally 400" is the unbounded-array warning: cap embedded messages and overflow to a referenced collection (a hybrid boundary — production systems do this constantly). (4) Reference, always — 5/second forever is the 16 MB march from the widget; append-only data lives outside any aggregate (honestly, it's time-series shaped — that family is three lessons away). (5) Document, trivially — tiny, read whole, changes rarely; this is also fine as a JSONB column in Postgres, which is precisely the convergence point: the aggregate bet without a new engine.

Mental-Model Corrections

  • "Document databases are schemaless." Fourth lesson, same truth, now with machinery: the schema moved into every reader, at read time, forever. Adding a field is free on write; both shapes haunt your code until you backfill.
  • "Documents are just better developer experience." They are — exactly when your data is an aggregate you read whole. The mismatch dissolves because the shapes align. Share facts across aggregates and the DX inverts: you're chasing 1,240 copies or hand-writing joins.
  • "MongoDB loses data." That was 2012. WiredTiger, majority write concern, and multi-document transactions closed the chapter; the 2024 paper's real critique is convergence, not corruption. Retire the meme.
  • "Document stores can't join." $lookup exists. The honest statement: joins are foreign to the bet — the model assumes you designed aggregates so you rarely need them. If every query $lookups, you've stored relational data in a document store and kept both bills.
  • "Embed everything — locality is free." Locality is a read optimization paid on write: whole-document rewrites, degradation from 1–2 MB, a hard wall at 16 MB, and unbounded arrays as the classic self-inflicted wound. Embed what you read together; reference what grows or is shared.
  • "Document vs relational = JSON vs tables." Syntax is the costume. The real difference is where the aggregate boundary lives: rows store facts once and recombine at read (join tax); documents pre-combine at write (duplication and growth tax). Same trade-off, opposite toll booth — and multi-model engines let you pick per table.

Key Takeaways

  • A document database is a key-value store whose value the engine can finally see — index inside, query inside, update inside. The unit of storage becomes the unit your app thinks in: the aggregate.
  • Locality is the payoff (order page = one fetch; code shape = stored shape; impedance mismatch honestly solved) and its dark twin is the bill: whole-document rewrites (2 MB doc = 2 MB I/O per touch), degradation from 1–2 MB, a hard 16 MB wall, and unbounded arrays as the classic route there.
  • Schema-on-read buys cheap change and charges every reader forever; schema-on-write buys a shared contract and charges for migrations. A trade, not a free lunch.
  • This family lived before: IMS, 1970 — trees with locality, no joins, many-to-many agony — and the relational model was invented to fix exactly that. The tree's old weakness is still its weakness; know it before it finds you.
  • The everyday compass, derived in the widget: embed what you read together; reference what grows or is shared — and no boundary wins every workload.
  • The 2026 identity: the aggregate bet, not JSON syntax — Mongo grew transactions and SQL, Postgres grew JSONB, Docstore serves documents off MySQL. "Collision course."
  • Next: what if the aggregate's problem isn't shape but width and write volume — billions of sparse rows clustered by key, petabyte-wide? That's Wide-Column Databases.