Storing flexible records in document databases
Open in the interactive app: practice, quiz, mock interview →In one minute
- A document database stores each record as a self-describing tree of fields, arrays and nested objects (JSON in the API, BSON or similar on disk), in collections that do not force one schema.
- The unit of design is the aggregate, the data one request reads or writes together. In one document, a read is one lookup and a write is atomic without a transaction.
- Queries reach inside documents with dotted paths, and arrays get special treatment: a condition on an array field matches if any element matches, and indexes on arrays store one entry per element.
- Scaling is by sharding on a key inside the document, so the shard key decides which queries hit one shard and which fan out to all of them.
- Flexibility moves cost rather than removing it: schema rules live in code and validators, duplicated data is yours to keep in step, and unbounded documents get slow to rewrite.
How it works
Documents and aggregates
A relational row is flat, so anything one-to-many (an order's line items) lives in another table joined by key. A document keeps that shape: one order holds the customer snapshot, the address, an array of line items and the payment status, and reading it is one lookup by _id.
Martin Fowler calls this aggregate orientation. The SQL vs NoSQL lesson compares the models by access pattern; here we go inside the engine. The design rule: draw document boundaries around what is read and changed together, and around what must change atomically.
Embedding versus referencing
Embed when the child belongs to one parent, is read with it, and is bounded: line items in an order. Reference (store the other document's _id) when the child is shared, large, independently changing or unbounded: the product a line item points at, the comments on a viral post. The extended reference sits between: embed the few fields you display (product name and price at purchase) and keep the _id. That copy is denormalized data (see Denormalization), fine for a price frozen at purchase, dangerous for a customer's current email.
Flexible schema is still a schema
Collections accept documents of any shape, so two versions of an entity can coexist during a migration; readers check a schemaVersion field and upgrade documents lazily on write. MongoDB can enforce a JSON Schema validator per collection, and teams that skip it discover years later that price is a string in 3% of documents. The default strict level checks every insert and update but says nothing about documents written before the validator existed; MongoDB 9.0 (September 2026) added a constraint level that guarantees every document in the collection satisfies the rules (MongoDB docs).
Querying inside documents
Filters name fields by path: {"customer.city": "Lyon"} descends into an embedded object. Arrays are where the model differs most from SQL. {"tags": "gift"} matches a document whose tags array contains "gift"; {"items.sku": "B7"} walks every element of items. Multiple operators on one array path, such as {"items.qty": {"$gt": 5, "$lt": 3}}, are each satisfied by some element, not necessarily the same one, so an order with quantities 2 and 9 matches. To require one element to satisfy all conditions you need $elemMatch. The Practice section has you implement exactly this.
Atomicity, transactions and scale
A write to one document is atomic and isolated, nested arrays included (MongoDB docs). That is the multi-step consistency problem shape solved by layout instead of locking, so good schemas keep invariants (an order's total equals the sum of its items) inside one document. MongoDB has multi-document ACID transactions (since 4.0 for replica sets, 4.2 for sharded clusters), but they cost round trips, abort after 60 seconds by default (MongoDB docs), and often signal a boundary drawn in the wrong place. Since 9.0 the server also rejects new transactions once 10,000 are open at the same time by default (MongoDB 9.0 release notes).
To scale out, a collection is sharded on a field such as customerId. Queries that include the shard key go to one shard; queries without it are scatter-gather. The same model appears in Postgres as a jsonb column with a GIN index, and in Firestore and Elasticsearch.
Down the stack
Layer 1The contract
A single-document write is atomic: all of its field changes are visible or none are. Durability is tunable per operation. With write concern w: "majority" (the default since MongoDB 5.0) the server acknowledges only once a majority of replica set members have the write, so it survives a primary failover. One exception: with arbiters, if data-bearing voting members do not outnumber the voting majority (two data nodes plus an arbiter, say), the default drops to w: 1 (MongoDB docs); with w: 1 a failover can roll it back. Read concern local may return data a failover later undoes, majority returns only majority-acknowledged data, and linearizable also confirms the primary is still primary. Nothing is promised across documents unless you open a transaction.
Layer 2The mechanism
On the wire and on disk, MongoDB documents are BSON: a 4-byte little-endian total length, a sequence of elements, then a 0x00 terminator. Each element is a 1-byte type tag, the field name as a null-terminated string, and the value. Field names repeat in every document, which is the storage tax of flexible schema. BSON documents are capped at 16 MiB and 100 levels of nesting (MongoDB limits).
WiredTiger keeps each collection in a B-tree keyed by an internal 64-bit RecordId; the _id index and secondary indexes are separate B-trees mapping values to RecordId. An index on an array field is multikey: one entry per element, so 50 tags make 50 entries, and a compound index may not cover two array fields of one document ("parallel arrays", which would need a cross product).
Replication streams an oplog, a capped collection of idempotent operations (MongoDB docs). An $inc from the client is logged as a $set of the resulting value, so replaying an entry twice is harmless. Elections follow a Raft-derived protocol. Sharded clusters split each collection into chunk ranges of the shard key (128 MB by default, MongoDB docs); a balancer moves chunks between shards, and mongos routers use a cached routing table to send each query to one shard or all of them.
Layer 3The machine
WiredTiger's cache defaults to the larger of 50% of (RAM minus 1 GB) and 256 MB, and holds pages uncompressed; on disk, collection blocks are Snappy-compressed by default (zstd for time series collections) and indexes use prefix compression (MongoDB docs), so repeated field names shrink on disk but cost full size in cache. Writes go to a journal (a write-ahead log) that is flushed with fsync every 100 ms by default, or immediately when a write asks for j: true (MongoDB docs). Every 60 seconds a checkpoint writes a consistent snapshot of the B-trees to new blocks, so recovery replays only the journal since the last checkpoint.
A small change to a large document is not free. In memory WiredTiger can keep a small delta, but when the page is written out the whole new document goes into the new page image, so updating one counter in a 2 MB document rewrites 2 MB on disk, and every read of it pulls megabytes through the cache.
Why it's built this way
- Applications think in objects and joins cost random I/O, so the engine stores the object as one contiguous record.
- Entities change shape over time, so each document carries its own field names; that costs bytes, so blocks are compressed.
- Contiguous records make single-record atomicity cheap, so that is the promise and cross-record transactions are opt-in.
- Queries into arrays must be fast, so indexes hold one entry per element, which makes huge arrays expensive to write.
- One machine runs out of disk and write throughput, so collections split by shard key, and a query's cost depends on naming it.
Diagram
flowchart LR
App["App: find({customerId: 42})"] --> R["mongos router"]
R -->|"shard key in filter"| S2["Shard 2 primary"]
R -.->|"no shard key: all shards"| S1["Shard 1 primary"]
S2 --> IX["Index B-tree: customerId to RecordId"]
IX --> CB["Collection B-tree: RecordId to BSON"]
S2 --> J["Journal, fsync every 100 ms"]
S2 --> OP["Oplog to secondaries"]Worked example
A marketplace stores 200 million orders. An order has 4 line items of about 150 bytes of BSON each plus a 600-byte envelope (customer snapshot, address, status), roughly 1.2 KB, or 240 GB in total. Field names are about 25% of that: 30 fields at about 10 bytes each. Snappy brings it to around 100 GB on disk; the cache still holds documents uncompressed.
Loading one order by _id is two B-tree descents (index, then collection) with cached upper levels: one or two disk reads at worst. The relational version joins four tables.
Now product teams ask for "all orders containing SKU B7 in the last week". An index on {"items.sku": 1, "createdAt": -1} is multikey: 800 million entries for 200 million orders. At about 40 bytes per entry that is 32 GB of index, and every new order writes 4 entries. Sharded on customerId, this query cannot target one shard, so with 8 shards each query costs 8 index scans plus a merge in mongos. If it runs 500 times a second, that is 4,000 shard-level queries a second; a separate read model keyed by SKU, fed from a change stream, may be cheaper.
In the wild
- Jepsen, 2020: MongoDB 4.2.6 transactions and the defaults. Even with snapshot read concern and majority write concern, Jepsen observed read skew, cyclic information flow and duplicate writes. Transactions ignored database- and collection-level concerns and fell back to
localandw: 1, so acknowledged writes could be lost in a partition. MongoDB traced the anomalies to a transaction retry bug, with a patch scheduled for 4.2.8. The advice: set concerns explicitly on every transaction (Jepsen). - Stripe, 2017: moving an embedded aggregate into its own collection. Stripe moved hundreds of millions of Subscription objects out of Customer records into their own table without downtime: dual-write and backfill, switch reads, switch writes, then delete the old path. The backfill used an offline Hadoop job over database snapshots to find the objects without hammering production, and the Scientist library compared old and new read paths before each switch (Stripe).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Embed children | One read, atomic updates, data locality | Duplication, document growth, 16 MiB cap | Child is owned, bounded and read with the parent |
Reference by _id | No duplication, independent growth | Extra round trips or $lookup, no cross-document atomicity | Child is shared, large or unbounded |
| Extended reference | Fast display with a stable snapshot | Copies drift if the source changes | Values that should be frozen at write time |
| Bucket pattern (fixed-size child documents) | Bounded documents, fewer index entries | More complex reads and writes | High-volume child events such as readings or messages |
Relational table with jsonb | Joins, constraints and flexible fields together | Weaker horizontal scaling, GIN index write cost | Mostly relational data with some variable attributes |
Pitfalls in production
- Unbounded arrays. Comments or followers embedded in a parent grow until every update rewrites megabytes and the document hits the 16 MiB limit. Cap arrays, use the bucket pattern, or move children to their own collection.
- Monotonic shard keys. Sharding on a timestamp or an
ObjectId(whose first 4 bytes are seconds since the epoch) sends every insert to the last chunk, one hot shard. Use a hashed shard key or a compound key that leads with a high-cardinality field. - Scatter-gather by accident. A query without the shard key runs on every shard at the slowest shard's latency. Choose the key from access patterns and keep a read model for the rest.
- Silent schema drift. Without validators,
{"price": {"$gt": 10}}silently skips documents wherepricebecame a string, because comparisons only match the same type class (MongoDB docs). Add JSON Schema validation and versioned migrations. - Acknowledged writes that vanish. With
w: 1, a primary that crashes before replicating loses acknowledged writes to rollback. Usew: "majority"for anything a user was told succeeded.
Leadership lens
- Ask for the top ten access patterns with QPS before any schema is drawn; the schema derives from them, and changing aggregates later means migrating every document.
- Treat the shard key as a one-way door. Resharding is possible in recent versions but is a long, I/O-heavy operation: it runs at least 5 minutes, needs roughly twice the collection and index size in free space across the shards, and blocks writes for about two seconds at the end (MongoDB docs). Review the key choice the way you would a public API.
- Make one owner responsible for each duplicated field and its sync path (change stream, outbox or batch repair), or the copies will disagree within a quarter.
- Budget for schema governance: validators, a
schemaVersionconvention and lazy migrations are cheap to start with and expensive to retrofit.
Check yourself
Why is
{"scores": {"$gt": 80, "$lt": 85}}true for a document whosescoresare[70, 90], and how do you express "one score between 80 and 85"?Show answer
Each operator on an array path is satisfied independently by any element: 90 is above 80 and 70 is below 85.
$elemMatchapplies all conditions to the same element.A team stores a user's notification feed as an array inside the user document. What breaks first as the product grows?
Show answer
Write cost: each notification rewrites an ever larger document, long before the 16 MiB cap. Feeds belong in their own collection or bounded buckets.
Why does the oplog record
$inc: 1as a$setof the new value?Show answer
Oplog entries must be idempotent because a secondary may apply an entry more than once during recovery. Setting the final value twice is harmless; incrementing twice is not.
When does a document store give you atomicity without a transaction?
Show answer
When every field the operation changes lives in one document, because a single-document write is atomic and isolated. Designing aggregates around invariants is how you use that.
Sources and further study
Primary
- Schultz et al., Tunable Consistency in MongoDB (VLDB 2019): MongoDB's own account of write and read concerns, and what each combination costs in latency.
- Jepsen: MongoDB 4.2.6: an independent test of multi-document transactions and their defaults.
Systems docs
- MongoDB: specify a validation level:
strict,moderateand the 9.0constraintlevel. - MongoDB: reshard a collection: space, time and write-blocking costs of changing a shard key.
- MongoDB: journaling: when WiredTiger syncs the journal and what
j: truechanges.
Books
- Designing Data-Intensive Applications (2nd ed.), Martin Kleppmann and Chris Riccomini, Ch 3 "Data Models and Query Languages" and Ch 5 "Encoding and Evolution": document locality and schema-on-read, and how to evolve record formats safely.
Other curricula
- donnemartin/system-design-primer: frames a document store as "a key-value store with documents stored as values", queryable by the documents' internal structure.
- Azure Architecture Center: Understand data models: lists document size growth and narrow transaction scope as the main considerations, and growing ad hoc joins as the signal to add a relational read model.
- roadmap.sh: System Design: its "Document Store" node defines the model in one paragraph, with MongoDB as the example.
Watch
- Relational vs document databases, data locality, and p99 (Ben Dicken): how the two models trade locality against flexibility, with attention to tail latency.
- MongoDB Internals: From Storage Engine to Aggregation Framework (DevTernity Conference): Norberto Leite on the pluggable storage layer, WiredTiger and how queries execute.
- Large Scale MongoDB WiredTiger (Percona): operating WiredTiger at scale, including cache and checkpoint behaviour.
Free resources
- BSON specification: the exact byte layout of documents, element types and field names.
- MongoDB: WiredTiger storage engine: cache sizing, journaling, checkpoints and compression defaults.
- MongoDB: multikey indexes: how array fields are indexed and the parallel-array restriction.
- MongoDB: write concern: what each acknowledgement level guarantees and when writes can roll back.
- Aggregate Oriented Database (Martin Fowler): the modelling idea behind document stores, in one page.
- PostgreSQL: JSON types: how
jsonbstores documents inside a relational engine and how to index them.