In a relational database, schema design and physical layout are separate concerns: you normalise into tables, then the planner and the storage engine decide how rows are fetched and joined. In MongoDB they are the same decision. A document is stored contiguously as BSON, and whatever you put inside it is retrieved in one read. Deciding to embed an array of order lines inside an order, instead of putting them in their own collection, is not a modelling preference. It is a choice about disk layout, index shape, and how many round trips a page render costs.
That is the single idea from which most of MongoDB’s behaviour follows, including its limits.
The document model and its hard constraint
The maximum BSON document size is 16 mebibytes. This is not tuneable. It exists so that a single document cannot consume an unbounded amount of RAM on the server or bandwidth in transit. Anything larger goes through the GridFS API instead.
Every embedding decision is bounded by that number, so the useful question is not “is this data related?” but “does this relationship have a bound?”.
| Relationship | Model as | Reason |
|---|---|---|
| One-to-one, always read together | Embedded subdocument | One read, no join |
| One-to-few, bounded, rarely updated independently | Embedded array | One read; update cost is rewriting the parent |
| One-to-many, unbounded or independently written | Separate collection + reference | Parent document size stays bounded |
| One-to-many, but only the newest N are displayed | Subset pattern: embed N, reference the rest | Bounded parent, single read for the common case |
The named anti-pattern is the unbounded array. The manual’s example is a books collection with
a reviews array: every new review pushes another subdocument into the parent. Nothing fails at
first. Then documents grow, index performance degrades, and eventually inserts start rejecting at
16 MB. By that point the fix is a migration, not a config change.
Two documented remedies. The subset pattern embeds the few reviews the page actually shows and puts the rest in their own collection; it duplicates data, so it suits data that is read often and updated rarely. Referencing stores only identifiers and joins at query time:
db.books.aggregate([
{ $match: { title: "Harry Potter" } },
{ $lookup: {
from: "reviews",
localField: "reviews",
foreignField: "review_id",
as: "reviewDetails"
} }
])
That costs latency, which is the trade: you have swapped a guaranteed-size read for a join.
Two further document rules affect design: _id is reserved, immutable, and may not be an array or
regex; and BSON field order is significant in comparisons, so {a: 1, b: 1} does not equal
{b: 1, a: 1}.
Replica sets
A replica set is one primary and some secondaries. Writes go to the primary, are applied there, and
are then recorded in the oplog (local.oplog.rs), a capped collection of idempotent operations
that secondaries copy and replay asynchronously. Replication is asynchronous by default; the
guarantees you get come from write concern, not from the topology.
Members heartbeat every two seconds. If a secondary cannot reach the primary for longer than
settings.electionTimeoutMillis (10 seconds by default) it calls an election. The set cannot
process writes until an election completes; the documented expectation is that the median time to
elect a new primary does not exceed 12 seconds with default settings. A member cannot become
primary unless it has the highest optime of any visible member. A replica set may have up to
50 members but at most 7 voting members.
Oplog sizing matters because the oplog window, the time span between its oldest and newest entries, is how long a disconnected secondary has to reconnect before it must be resynced from scratch. On Unix and Windows with WiredTiger the default is 5% of free disk space, floored at 990 MB and capped at 50 GB.
Write concern
writeConcern is { w: <value>, j: <boolean>, wtimeout: <number> }. The implicit default is
w: "majority", with one documented exception: if the set contains arbiters and the number of
non-arbiters is not greater than the majority of voting nodes, the implicit default drops to
{ w: 1 }. A P-S-A set therefore silently defaults to w: 1.
| Setting | Acknowledged when | Durability |
|---|---|---|
w: 0 |
Not acknowledged (socket/network errors may still surface) | None; rollback possible |
w: 1 |
Primary has the write | Rolls back if the primary steps down before replicating |
w: <n> |
Primary plus enough data-bearing members to total n | Survives loss of fewer than n members |
w: "majority" |
A calculated majority of data-bearing voting members have durably written it to their oplogs | Survives failover; this is the only level that does |
w: "<tag>" |
Tagged members satisfying a custom mode in settings.getLastErrorModes |
Depends on the mode; can be as weak as w: 1 |
j: true |
The required members have written to the on-disk journal | Survives process crash on those members |
wtimeout: <ms> |
Caps how long the primary waits for propagation | Does not undo the write on timeout |
Three details cause most incidents. First, j: true alone does not prevent rollback on failover: it
is a durability guarantee on individual nodes, not a replication guarantee. Second, wtimeout only
applies when w is greater than 1, and when it fires the write is not rolled back: you get a write
concern error for a write that may still commit. Third, with no wtimeout and an unachievable write
concern, the operation blocks indefinitely.
For a 3-voting-member P-S-S set, the calculated majority is 2, so w: "majority" returns once the
primary and one secondary have the write.
Read concern
| Level | Returns | Caveat |
|---|---|---|
"local" |
Whatever the queried member has | Default for reads on primary and secondaries; may be rolled back |
"available" |
Same, with no orphan filtering on sharded clusters | Lowest latency; can return orphaned documents. Not usable with transactions or causally consistent sessions |
"majority" |
Data at the majority-commit point | Durable; requires WiredTiger |
"linearizable" |
All majority-acknowledged writes completed before the read started | Primary only; only meaningful for a filter that uniquely identifies one document; no $out/$merge. Always pair with maxTimeMS |
"snapshot" |
Majority-committed data from a single point in the recent past | Guarantees hold only if the transaction commits with w: "majority" |
Causal consistency across a session requires both readConcern: "majority" and
writeConcern: "majority". One without the other buys nothing.
Read preference and the staleness it buys
Read preference decides which member serves a read: primary (default), primaryPreferred,
secondary, secondaryPreferred, nearest. Every mode except primary may return stale data,
because secondaries replicate asynchronously. Read preference does not affect visibility either. Clients can observe writes before they are majority-acknowledged.
maxStalenessSeconds bounds the damage: the client estimates each secondary’s lag against the
primary’s last write and skips secondaries beyond the limit. The minimum permitted value is 90
seconds and a smaller value raises an error, because clients sample member write times
infrequently and the estimate is coarse. It cannot be combined with mode primary. Transactions
containing reads must use primary.
So secondary reads are appropriate for analytics and reporting, and inappropriate for read-your-own- writes. There is no read preference that makes a secondary current.
Sharding
A sharded collection is partitioned by shard key: one indexed field, or several fields covered by a compound index. MongoDB splits the shard key value space into non-overlapping ranges, each associated with a chunk, and the balancer distributes chunks across shards.
Ranged sharding keeps close key values together, which makes range queries targetable to few
shards. Its failure mode is a monotonically increasing key such as a timestamp or ObjectId: the
chunk with upper bound MaxKey receives every insert, so all write load lands on one shard.
Hashed sharding hashes a single field and distributes evenly, at the cost of range queries
becoming broadcast operations; equality matches are still targeted. A compound hashed key lets you
keep a non-hashed prefix for zone ranges while a hashed component spreads the writes.
Hashed indexes truncate floating point values to 64-bit integers before hashing, so 2.2, 2.3 and
2.9 hash identically. Do not hash floats. Shard key indexes also cannot be descending, partial,
geospatial, or multikey.
Historically a bad shard key was terminal. Since MongoDB 5.0 you can reshardCollection to a new
key, and since 8.0 you can reshard onto the same key with forceRedistribution: true purely to
redistribute data onto new shards. It is a real escape hatch, but an expensive one:
- Writes to the collection block for a period of about two seconds.
- Each recipient shard needs
((collection_storage_size + index_size) * 2) / shard_countof free space. These requirements are not enforced by the server; under-provisioning can fill the disk. - I/O capacity should be below 50% and CPU below 80% before starting.
- Only one collection can be resharded at a time, and
createIndexes,drop,dropIndexes,renameCollection,collModandconvertToCappedare unsupported on it while it runs. - Until it completes, your queries must include both the old and the new shard key, or
updateOne,deleteOne,replaceOneand thefindAndModifyfamily error unless the filter contains a unique field such as_id.
Refining a key - appending suffix fields to the existing one - is the cheaper option when it is sufficient.
The balancer runs on the config server replica set primary and is enabled by default. A collection is considered balanced when the data difference between shards is less than three times the configured range size; at the default range size of 128 MB, two shards must differ by at least 384 MB before a migration starts. A shard participates in at most one migration at a time, so a cluster of n shards performs at most n/2 concurrent migrations.
Indexing, and the prefix rule
MongoDB supports single-field, compound, multikey, text, hashed, geospatial, TTL, partial, sparse, unique and wildcard indexes. A collection can have at most 64 indexes, and a compound index at most 32 fields.
The compound index prefix rule
An index prefix is a leading subset of the indexed fields. This is the part people get wrong: an index is not a set of fields, it is an ordered B-tree, and you can only enter it from the left.
Given:
db.inventory.createIndex({ item: 1, location: 1, stock: 1 })
the prefixes are { item: 1 } and { item: 1, location: 1 }. MongoDB can support queries on
item; on item and location; and on item, location and stock. It cannot use this index
for a query on location, on stock, or on location and stock together. None of those begin
with item.
The interesting case is the partial one. A query on item and stock, skipping location, does use
the index, but only for the item portion. The manual’s worked figure: if 10,000 documents match
item: "saccharomyces cerevisiae" and only 100 of those also match stock: 60, the IXSCAN stage
examines 10,000 keys and filters them down to 100. The index “works” and the query is a hundred times
more expensive than it looks.
// Uses the index fully
db.inventory.find({ item: "saccharomyces cerevisiae", location: "A1", stock: 60 })
// Uses only the item prefix; examines 10000 keys to return 100
db.inventory.find({ item: "saccharomyces cerevisiae", stock: 60 })
// Cannot use the index at all - collection scan
db.inventory.find({ location: "A1", stock: 60 })
Field order is chosen with the ESR guideline: Equality fields first, then Sort fields, then
Range fields. A corollary worth acting on: if you have both {a: 1, b: 1} and {a: 1}, and
neither is sparse or unique, drop {a: 1}. It is redundant, and it still costs write throughput.
Other index types
| Type | Created by | Constraint that bites |
|---|---|---|
| Multikey | Automatic on any indexed array field | At most one array-valued field per compound index; inserts violating this fail. Cannot be a shard key index. Not supported by $expr |
| Text | { field: "text" } |
Only one text index per collection, though it may span fields. Always sparse. Cannot cover a query |
| TTL | expireAfterSeconds on a single-field date index |
expireAfterSeconds must be 0-2147483647. Documents without a date in that field never expire |
| Hashed | { field: "hashed" } |
Cannot be multikey; truncates floats to 64-bit integers |
TTL deletion is a single-threaded background thread, not a guarantee of punctual removal. Per index
it deletes until it has removed 50,000 documents, or spent one second, then moves to the next index;
the loop also yields every 60 seconds. Under load, expired documents remain visible. Since MongoDB
6.1 deletions may be batched, which shows as a BATCHED_DELETE stage in explain. Since 5.1 you can
convert an existing single-field index to a TTL index with collMod rather than rebuilding it.
For full-text search MongoDB now points at Atlas Search, not $text indexes.
The aggregation pipeline
A pipeline is an ordered list of stages, each consuming a stream of documents and emitting another. Monthly revenue per region, top three regions:
db.orders.aggregate([
{ $match: {
status: "complete",
orderDate: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-07-01") }
} },
{ $unwind: "$lines" },
{ $group: {
_id: { region: "$region", month: { $dateTrunc: { date: "$orderDate", unit: "month" } } },
revenue: { $sum: { $multiply: ["$lines.qty", "$lines.unitPrice"] } },
orders: { $addToSet: "$_id" }
} },
{ $project: {
_id: 0,
region: "$_id.region",
month: "$_id.month",
revenue: 1,
orderCount: { $size: "$orders" }
} },
{ $sort: { revenue: -1 } },
{ $limit: 3 }
])
The $match is first deliberately. Only a $match at the start of a pipeline can use an index to
limit what enters it; once documents are in the pipeline, every later stage is a full pass over the
stream. The optimiser will move filters earlier where it safely can. It splits a $match into
per-field predicates and pushes each one before any projection stage it does not depend on, and it
moves $match before a preceding $sort. What it cannot do is move a filter that depends on a
computed field. Write the pipeline so it does not have to.
Limits: at most 1000 stages; each returned document is subject to the 16 MiB BSON limit, though
intermediate documents may exceed it. Since MongoDB 6.0 the allowDiskUseByDefault parameter decides
what happens when a blocking stage such as $group, $sort (unsupported by an index), $bucket or
$setWindowFields needs more than 100 MB of memory: spill to temporary files, or error. Per
command you can override with { allowDiskUse: true | false }. A usedDisk indicator appears in the
profiler and diagnostic log when a stage spilled. Treat it as a signal to add an index or a
$limit after $sort, since a $sort immediately followed by $limit is coalesced by the
optimiser.
Multi-document transactions
Single-document operations are atomic, which is why a well-embedded model needs transactions less often. When you do need them, MongoDB supports multi-document transactions across collections, databases and shards. Constraints, all of them practical:
| Constraint | Value |
|---|---|
| Deployment | Replica set or sharded cluster; standalone mongod is unsupported |
| Runtime limit | Under one minute by default (transactionLifetimeLimitSeconds); expired transactions are aborted by a cleanup process |
| Lock acquisition | Waits 5 ms for required locks by default, then aborts (maxTransactionLockRequestTimeoutMillis) |
| Oplog | No 16 MB total limit - entries are generated as needed - but each individual oplog entry is still bounded by the 16 MB BSON limit |
| Cache | A transaction too large for the WiredTiger cache aborts with TransactionTooLargeForCache; since 6.2 the server does not retry it |
| Read preference | Reads inside a transaction must use primary, and all operations route to one member |
| Sharded clusters | Cannot change a shard key in a transaction if the set has an arbiter; transactions spanning a shard containing an arbiter error and abort |
Pending DDL is the sharp edge. A DDL operation on a collection with an in-progress transaction waits
behind it; new transactions touching the same collection then cannot get their locks and abort after
maxTransactionLockRequestTimeoutMillis, and non-transactional operations block until maxTimeMS.
One long transaction plus one createIndex is enough to stall a collection.
Use the driver callback API (with_transaction, withTransaction) in preference to the core API unless
you need custom error handling: it retries TransientTransactionError and
UnknownTransactionCommitResult for you. Set write concern at the transaction level, never per
operation.
Diagnostics
explain() is the first tool. It returns the plan as a tree of stages; COLLSCAN means no index
was used, IXSCAN means one was, FETCH means documents were retrieved after the index scan, and
SORT in the tree means an in-memory sort the index could not satisfy. Run it with
"executionStats" to see keys examined against documents returned. A large ratio is the prefix
problem above.
db.inventory.find({ item: "saccharomyces cerevisiae", stock: 60 }).explain("executionStats")
Note that explain bypasses the plan cache: it generates candidate plans and picks a winner without
consulting or populating the cache, so it does not always reflect what a repeated query does.
Slow operations. The slow operation threshold is 100 ms by default and applies to the whole
mongod, feeding both the diagnostic log and the profiler. It is measured on workingMillis, the
time MongoDB spent working, so lock waits and flow control do not push an operation over the line.
Since 8.3 there is a separate slow in-progress operation threshold, defaulting to 5000 ms.
The profiler itself is off by default (level 0); level 1 records operations exceeding slowms or
matching a filter, level 2 records everything and ignores slowms. It writes to a capped
system.profile collection per database and degrades performance, so prefer the diagnostic log or
$queryStats where you can.
db.setProfilingLevel(1, { slowms: 20 })
db.currentOp() and the $currentOp aggregation stage show what is running now: the right tool
for a stuck operation, not a slow one. TTL deletions appear here too.
Where MongoDB is the wrong choice
Pick a relational database when the data is genuinely relational and the access patterns are not
known in advance. MongoDB’s performance comes from modelling around known queries; if analysts will
write arbitrary joins across a dozen entities tomorrow, a normalised schema and a mature cost-based
optimiser will serve you better, and $lookup is not a substitute for a real join planner.
Pick a relational database when correctness depends on constraints the database must enforce. MongoDB has no foreign keys, no cross-collection referential integrity, and no cross-collection uniqueness. Unique indexes on sharded collections are only supported when the index contains the full shard key as a prefix. Any invariant spanning documents becomes application code plus a transaction. That is possible, but you are paying transaction costs to get what a relational engine gives you by default.
Pick a relational database when multi-entity transactions are the normal path and not the exception. MongoDB’s transactions work, but the one-minute default runtime, the 5 ms lock timeout, the cache pressure and the DDL interaction all say the same thing: they are a safety net, not a foundation.
MongoDB is the right choice when documents match the aggregate you actually read and write, when those aggregates have bounded size, when the schema varies between records, and when horizontal write scaling justifies choosing a shard key carefully up front. Those conditions are common. They are not universal, and the failure mode of pretending they hold is a migration.
MongoDB release support
4 supported| Release | Latest | Released | End of life | Status |
|---|---|---|---|---|
9.0 | 9.0.2 | Sept 2026 | Oct 2031 | Supported |
8.3 | 8.3.11 | May 2026 | Oct 2029 | Supported |
8.2 | 8.2.12 | Sept 2025 | Jul 2026 | End of life |
8.1 | 8.1.3 | Jun 2025 | Sept 2025 | End of life |
8.0 | 8.0.32 | Oct 2024 | Oct 2029 | Supported |
7.3 | 7.3.4 | Mar 2024 | Oct 2024 | End of life |
7.2 | 7.2.2 | Jan 2024 | Mar 2024 | End of life |
7.1 | 7.1.1 | Oct 2023 | Jan 2024 | End of life |
7.0 | 7.0.43 | Aug 2023 | Aug 2027 | Supported |
6.0 | 6.0.29 | Jul 2022 | Jul 2025 | End of life |
5.0 | 5.0.34 | Jul 2021 | Oct 2024 | End of life |
4.4 | 4.4.31 | Jul 2020 | Feb 2024 | End of life |
Data as of 2026-10-01 from endoflife.date. Refresh with node scripts/fetch-versions.mjs.
Where MongoDB sits
How this component relates to the rest of the stack. Items without a link are not covered here yet.