database > collection > document
MongoDB stores JSON-like BSON documents, grouped into collections, inside a database.
{ "title": "Dune", "tags": ["sci-fi"] }
156 entries — one card per concept, for looking something up rather than learning it. Each links back to the full explanation.
database > collection > document
MongoDB stores JSON-like BSON documents, grouped into collections, inside a database.
{ "title": "Dune", "tags": ["sci-fi"] }
server > database > collection > document { field: value }
Four nested containers; a document's _id is its primary key, an ObjectId by default.
{ "_id": ObjectId("..."), "title": "Dune" }
relational: JOIN at query time vs. document: nest inline
Denormalizing trades update-in-one-place for read-in-one-place — a workload decision, not "which database is better."
lowercase db/collection · camelCase or snake_case fields
Naming is a convention MongoDB does not enforce — pick one and stay consistent; a collection is created implicitly on first insert.
insert · find · update · replace · delete
The five conceptual CRUD verbs — update changes fields, replace swaps the whole document.
db.orders.updateOne({ _id }, { $set: { status: "shipped" } })
mongosh "mongodb://localhost:27017"
The official JavaScript REPL shell — use switches database, every command is a real JS method call.
schema-flexible ≠ schema-free
No fixed table schema is enforced automatically — app code or a validator still can be.
db.createCollection("orders", { validator: { $jsonSchema: { required: ["status"] } } })
string · double · int32 · int64 · decimal128 · date · objectId · binary · regex
BSON's type list — more precise than JSON's one generic string/number/boolean/null/object/array set.
{ "price": NumberDecimal("19.99"), "createdAt": ISODate("2026-01-01") }
JSON (text, display) vs. BSON (binary, stored)
MongoDB stores and transmits BSON; the JSON-like text you type or read is a rendering of it.
{ "_id": ObjectId("...") } // displayed as text, stored as binary BSON
16 MB per document — field names + type byte stored per field
Every field carries per-document name overhead; unbounded arrays are the most common way to hit the 16 MB limit.
int32 · int64 · double · decimal128
MongoDB compares numbers by value across types; which type a literal lands in depends on the driver, not the number itself.
NumberDecimal("19.99")
Exact base-10 decimal, 34 significant digits — use for money; double only approximates decimal fractions.
ISODate("2026-01-01T00:00:00Z")
Milliseconds since the Unix epoch, UTC — no timezone stored; convert to local time only at display/input.
ObjectId = 4-byte timestamp + 5-byte random + 3-byte counter
The default _id value: unique without central coordination, roughly sortable by creation time.
ObjectId("65f1a2b3c4d5e6f7a8b9c0d1").getTimestamp()
embed: nest inline · reference: store an _id, look up separately
Embed data read together and bounded to one parent; reference data that grows independently or is shared.
{ "authorId": ObjectId("...") } // reference, resolved by a separate query or $lookup
"a.b" · "a.0.b"
Dot notation reaches a nested field or array element, in queries, projections and update operators alike.
db.users.updateOne({ _id: 1 }, { $set: { "address.city": "Pune" } })
["tags"] scalars vs. [{ sku, qty }] documents
Scalar arrays hold plain values; document arrays hold structured items worth naming and querying individually.
a write touches the whole document, not just the changed field
Large or unbounded arrays and deep nesting cost more to update and index — keep frequently-updated documents small.
createCollection(name, { capped, size, validator, timeseries })
Capped and time-series storage modes are chosen at creation; validators and indexes can be added at any time.
one collection, distinguished by a "type"/"method" field
Shape a collection around what is read/written together, not the application's class hierarchy.
insertOne/Many · find/findOne · updateOne/Many · replaceOne · deleteOne/Many
The nine CRUD methods — "One" stops at the first match, the plural/cursor form covers every match.
db.orders.updateMany({ status: "pending" }, { $set: { status: "shipped" } })
updateOne(filter, update, { upsert: true })
Updates the matching document, or inserts one from filter + update if nothing matches.
updateOne({ _id: "orders" }, { $inc: { seq: 1 } }, { upsert: true })
$set · $unset · $inc · $mul · $min · $max · $rename · $currentDate
Field update operators — atomic, in-place edits without reading the document first.
updateOne({ _id }, { $inc: { count: 1 }, $currentDate: { updatedAt: true } })
$push · $addToSet · $pop · $pull · $pullAll · $each · $slice · $sort
Add with $push/$addToSet, remove with $pop/$pull/$pullAll; $each/$slice/$sort are modifiers inside $push.
"field.$" · "field.$[]" · "field.$[id]" + arrayFilters
$ updates the query-matched element; $[] updates all elements; $[<id>] updates elements matching arrayFilters.
db.<collection>.find(filter, { field: 1 }) // or { field: 0 }
Inclusion and exclusion projections cannot normally mix — { _id: 0 } is the one exception.
find(filter).sort({ field: 1 }).skip(N).limit(M)
skip(N) still walks past N documents internally — deep pagination gets slower; cursor/range pagination does not.
db.<collection>.bulkWrite([...], { ordered: true|false })
Ordered (default) stops at the first failure; unordered keeps going and may run out of order.
{ field: value } · { field: { $gte, $lt } } · { arrayField: scalar }
Equality is a bare value; range needs an operator; a scalar against an array field matches any element.
db.products.find({ price: { $gte: 10, $lt: 50 } })
$eq · $ne · $gt · $gte · $lt · $lte · $in · $nin
The eight comparison operators — $in/$nin take an array of candidate values.
db.orders.find({ status: { $in: ["pending", "shipped"] } })
$and · $or · $nor · $not
{ a, b } is implicit $and. $or/$nor take a condition array; $not inverts one wrapped operator.
db.orders.find({ $or: [ { status: "pending" }, { total: { $gt: 500 } } ] })
$exists · $type
$exists tests field presence (not value); $type tests the BSON type of a present field.
db.products.find({ discount: { $exists: false } })
$all · $elemMatch · $size
$all: contains all values. $elemMatch: one element meets every condition. $size: exact array length.
db.students.find({ results: { $elemMatch: { subject: "math", score: { $gte: 90 } } } })
$regex · $mod · $expr · $text · $where
Evaluation operators run logic, not plain comparison — only an anchored $regex or $text can use an index.
db.users.find({ email: { $regex: "^alice", $options: "i" } })
"parent.child" · "array.N" · "array.field"
A quoted dotted path reaches an embedded field or array element — not real nested-object syntax.
db.users.find({ "address.city": "Austin" })
{ a: 1, b: 1, c: 1 } → prefixes: {a}, {a,b}, {a,b,c}
A compound index only serves queries matching one of its leading prefixes, in definition order.
db.inventory.find({ item: "widget", location: "east" }) // uses the { item, location } prefix
workload → access patterns → schema shape
MongoDB schema design starts from the application's read/write patterns, not from normalized entities.
Data accessed together should be stored together.
{ query, frequency, latency } list → schema
Design input is a list of real access patterns with frequency, not just an entity-relationship diagram.
"Get order with line items" (every page load) vs "sum revenue per product" (monthly) — same entities, different schema pressure.
read-heavy · write-heavy · high-cardinality · time-series · hierarchical · transactional
Six recognizable workload shapes, each pointing toward known-good modeling patterns.
Order placement: write-heavy + transactional. Order history: read-heavy. Same collection, different patterns.
data accessed together → stored together
MongoDB's core modeling principle — the reasoning behind "embed by default," applied by access pattern.
{ userId, layout, lastViewedFilters } co-located because a dashboard load always reads all three.
embed (default) vs. reference
Embed for one-read locality; reference for independent updates, wide sharing, or unbounded growth.
{ title, userId } + a separate users collection, when the user is shared across many movies.
embedding → locality + atomic updates + simple reads, costs document growth
Embedding's three benefits all come from one-document locality; its one cost is that document's size.
db.orders.updateOne({ _id, "items.sku": "X1" }, { $inc: { "items.$.qty": 1 } }) — atomic, but scales with document size.
referencing → independent growth + reuse + small documents, costs an extra query/$lookup
Referencing trades locality (embedding's benefit) for a per-read join cost.
$lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" }
bounded (safe to embed) vs. unbounded (reference instead)
Bounded means an enforced cap on array size; unbounded means nothing stops it from growing.
$push: { recentLogins: { $each: [event], $slice: -5 } } — actively enforces a bound.
16 MiB document limit
An unbounded embedded array degrades gradually, then fails writes outright at the 16 MiB document limit.
WriteError: BSONObj size ... Size must be between 0 and 16793600 (16MB)
embed: read-together + bounded + owned · reference: independent + shared + own lifecycle
Two three-signal checklists for the embed/reference decision, applied per relationship.
Order line items embed (bounded, owned); the customer record they belong to references (shared, own lifecycle).
1:1/1:few → embed · 1:many/N:N → reference · trees → parent ref or path
Cardinality narrows the strategy; boundedness (not the cardinality label) makes the final call.
db.orders.find({ customerId: 1 }) — reference on the "many" side, indexed lookup instead of a growing array.
subset · bucket · extended reference · computed · attribute · outlier
Six named schema design patterns, each a named answer to one recurring modeling symptom.
Extended reference: copy { name } onto an order at write time, to skip a $lookup on every order-list read.
denormalize on purpose: copy a rarely-changing field to remove a join
MongoDB denormalization is a deliberate, bounded trade — not the relational anti-pattern of the same name.
{ _id: 101, customerId: 1, customerName: "Joel M" } — a copied display name avoids a $lookup on the order-list read.
MongoDB schema = access-pattern decision, not a normal-form decision
A schema is justified by how cheaply it answers real queries — the same entities can validly model differently for different access patterns.
Comments embedded when read with their post; referenced and indexed by postId when queried independently.
subset · bucket · extended reference · computed · attribute · outlier
Six named patterns, each a targeted trade against one specific, measured schema symptom.
Bucket: group readings by sensor + hour into one document, instead of one document per reading.
bucket width ≈ dominant query range width
Wider buckets cost more per write; narrower buckets cost more per wide query — match width to the real query pattern.
Hourly buckets for an hourly dashboard load exactly the needed document; daily buckets would over-fetch.
events/history → own collection, indexed by { parentId, at }
Event and history data is unbounded by nature — a separate collection keeps the parent document small.
db.orderEvents.find({ orderId: 101 }).sort({ at: 1 }) — full history on demand, order document stays small.
precompute + $inc on write + periodic reconciliation
A read-heavy count/summary, maintained incrementally instead of recalculated, with a reconciliation job to catch drift.
db.posts.updateOne({ _id: 1 }, { $inc: { likeCount: 1 } }) instead of counting likes on every read.
db.createCollection(name, { validator: { $jsonSchema: {...} } })
Attaches structural rules to a collection — enforced on insert, and on update per validationLevel.
db.runCommand({ collMod: "orders", validator: {...}, validationLevel: "moderate" })
{ bsonType, required: [...], properties: { field: {...} } }
The $jsonSchema vocabulary for a MongoDB validator — nests for embedded documents and arrays.
{ status: { enum: ["pending", "shipped"] }, total: { bsonType: "decimal", minimum: 0 } }
validate fields with a real downstream dependency on their shape
Pick validation targets by asking what breaks if a field is missing or the wrong type — not every field needs a rule.
Validate "total" (feeds a $sum aggregation); leave "notes" (display-only) unconstrained.
validationLevel: strict|moderate|off · validationAction: error|warn
Level picks which writes are checked; action picks what happens on failure. moderate+warn is the safe rollout combination.
{ collMod: "orders", validator: {...}, validationLevel: "moderate", validationAction: "warn" }
validation strength is a per-collection decision
Strict for costly-to-get-wrong, stable collections; loose for fast-evolving ones — not one project-wide policy.
Strict validator on "payments" from day one; no validator yet on a still-iterating "featureEvents" collection.
app validation covers one path; a collection validator covers every write path
A collection validator is the backstop for write paths — scripts, other services, bugs — that skip application-side checks.
A migration script inserting a malformed order is rejected by the collection validator even though it never called the API.
no index → COLLSCAN · index → IXSCAN
An index is a sorted structure that lets a query jump to matches instead of scanning every document.
db.users.find({ email: "a@x.com" }).explain("executionStats") — check the stage name.
db.collection.createIndex({ field: 1 | -1 })
An index on one field — serves equality, range, and sort queries on that field, in either direction.
db.products.createIndex({ price: 1 }) serves both find({ price: 29.99 }) and sort({ price: -1 }).
db.collection.createIndex({ field1: 1, field2: 1, ... })
An index over several fields in a fixed order — serves queries matching its leading field(s), in that order.
db.orders.createIndex({ customerId: 1, createdAt: -1 }) serves "this customer, newest first."
index on an array field → automatically multikey
One index entry per array element; a query matches if any element matches.
db.products.createIndex({ tags: 1 }) matches find({ tags: "sale" }) via one element among several.
db.collection.createIndex({ field: 1 }, { unique: true })
Rejects a second document with the same field value — a missing field counts as null, and only one null is allowed.
A second user with no email address fails with E11000 unless the index is also sparse.
{ sparse: true } · { partialFilterExpression: {...} }
Both shrink an index by excluding some documents — partial is the more general, recommended tool.
db.orders.createIndex({ customerId: 1 }, { partialFilterExpression: { status: "pending" } })
createIndex({ dateField: 1 }, { expireAfterSeconds: N })
Auto-deletes documents N seconds after a Date field, via a background sweep every ~60 seconds — not instant.
db.sessions.createIndex({ lastActiveAt: 1 }, { expireAfterSeconds: 1800 }) — 30-minute session expiry.
createIndex({ field: "text" }) · find({ $text: { $search: "..." } })
Word-presence search with stemming — one per collection, no phrase/proximity data, no covered queries.
db.clothing.find({ $text: { $search: "silk" } }) matches a stemmed word anywhere in the indexed field.
compound index order: equality → sort → range (ESR)
Field role, not just field presence, determines whether an index can serve a query's sort without an extra in-memory step.
db.orders.createIndex({ status: 1, createdAt: -1, total: 1 }) serves filter + sort + range in one pass.
E (equality) → S (sort) → R (range)
A repeatable procedure for compound index field order: classify each field's role in the target query, then order by role.
find({ status, total: { $gte } }).sort({ createdAt }) → createIndex({ status: 1, createdAt: -1, total: 1 })
real query shape → ESR classify → build → verify with explain()
Index design starts from captured, real query patterns — a field with no real query use has no index-design reason to exist.
A slow-query log entry becomes the exact createIndex call, verified against it with explain() afterward.
{ a: 1, b: 1 } ≠ { b: 1, a: 1 }
Reordering a compound index's fields builds a genuinely different index, serving a different set of queries efficiently.
customerId-first serves customerId queries well; status-first serves status queries well — not both from one index.
prefixes of { a, b, c }: { a } · { a, b } · { a, b, c }
Only leading field subsets are efficient prefixes — a non-leading field alone generally cannot use the index.
find({ location, stock }) on createIndex({ item, location, stock }) cannot use the index — item is missing.
more indexes → more storage + more per-write update cost
An index speeds reads at the cost of extra storage and a write-time update on every insert/update touching its field(s).
Five single-field indexes mean six index updates (plus _id) on one insert populating all five fields.
indexes + hot data compete for the same working set (RAM)
Too many or unused indexes push the working set past available RAM, forcing disk access on reads too.
A collection accumulating one index per feature over a year ends up with 11 index updates per write.
together → compound index · independent → separate indexes
The choice follows from whether fields are actually queried together or independently — not from wanting fewer or more indexes in the abstract.
{ customerId, status } for storefront queries, plus a separate { trackingNumber } index for independent lookups.
multikey status is detected from data, not declared
The first array-valued document in the indexed field flips an index to multikey — MongoDB tracks this automatically.
Two scalar tags inserts, then one array-valued insert — the index becomes multikey on the third insert.
hashed index: no arrays · sort on multikey: often falls back to in-memory
Two consequences of the same "an array has no single position" fact — verify sort behavior with explain(), don't assume.
find({ category }).sort({ tags: 1 }) on a multikey tags index may still show an in-memory SORT stage.
compound index: at most one array field per document
Two fields both holding arrays on the same document in one compound index fails the write outright.
{ genres: ["Drama"], year: [2020, 2021] } fails on a { genres: 1, year: 1 } compound index — both are arrays.
multikey index size ≈ Σ(array length) across the collection
Index entry count scales with total array elements, not document count — large or unbounded arrays make for disproportionately large indexes.
100,000 documents with 200-element arrays produce ~20M index entries — the same document count as a 3-element-array collection, 65x the index size.
bound the array ($slice, or reference out) before indexing it
An unbounded indexed array compounds document-growth and index-growth costs together — cap it at the source.
$push: { recentSearches: { $each: [term], $slice: -20 } } keeps both the document and its multikey index bounded.
supported: equality, $exists, comparison, $type, $and, $or, $in, geospatial
partialFilterExpression supports a real but not unlimited operator subset — measure the actual size reduction, don't assume it.
db.orders.countDocuments({ status: { $in: [...] } }) vs. countDocuments({}) gives the real reduction percentage.
sparse excludes absence, not null
A field explicitly set to null is still "present" for sparse purposes — only true field absence is excluded.
{ phone: null } is included in a sparse index on phone; only an entirely omitted phone field is excluded.
unique: true, sparse: true — optional field, unique when present
Combining sparse with unique excludes missing-field documents from the uniqueness check entirely.
db.users.createIndex({ email: 1 }, { unique: true, sparse: true }) — two users with no email, no collision.
TTL expiry time ≠ TTL deletion time
A document can be logically expired but still physically present until the next background sweep — check timestamps explicitly for exact-time logic.
findOne({ _id: sessionId, lastActiveAt: { $gte: cutoffTime } }) instead of trusting the document's mere presence.
candidate plans → trial → winning plan → cached
The query planner discovers the best plan empirically via a short trial, then caches it for queries of the same shape.
explain() always evaluates fresh, bypassing the plan cache — the direct way to confirm the current real plan.
COLLSCAN (no index) vs. IXSCAN (walked an index)
The explain() stage names for the scan-vs-index distinction — usually nested (FETCH over IXSCAN), check the whole tree.
find({ email }).explain().queryPlanner.winningPlan.stage — "COLLSCAN" before an index exists, "FETCH" (over IXSCAN) after.
FETCH: retrieve documents · SORT: order in memory
Stages nest bottom-up — a filter can use IXSCAN while the query as a whole still needs an expensive SORT stage on top.
A { status: 1 }-indexed query sorted by an unindexed field shows SORT → FETCH → IXSCAN in its stage tree.
explain("allPlansExecution") shows candidates + winner
One candidate plan per usable index; the trial winner becomes the cached plan — rejectedPlans shows what else was tried.
db.orders.find({...}).explain("allPlansExecution").queryPlanner.rejectedPlans
explain() · explain("executionStats") · explain("allPlansExecution")
Three verbosity levels trading off how much the query actually executes — always bypasses the plan cache.
db.orders.find({ status: "pending" }).explain("executionStats") for real nReturned/totalDocsExamined numbers.
rejectedPlans — compare stats to the winner before assuming a problem
Only visible via allPlansExecution — most rejections are the trial correctly measuring more work for the same result.
A rejected { status }-only index examined 4,500 keys vs. the winner's 120 for the same 118 results — a correct rejection.
IXSCAN ≠ well-aligned — check totalKeysExamined ÷ nReturned
A ratio far from 1 means the index technically applies but does not match the query's real filter/sort needs.
40,000 keys examined for 60 results (ratio ≈ 667) despite a genuine IXSCAN — the index is misaligned to this query.
queryPlanner (free) → executionStats (real numbers) → allPlansExecution (all candidates)
Match verbosity to the actual question — allPlansExecution costs the most and is only needed for comparative questions.
"Why did index A lose to B?" needs allPlansExecution; "is this using an index?" needs only queryPlanner.
nReturned vs. totalKeysExamined vs. totalDocsExamined
Ratio near 1 means well-aligned; a large ratio means the index is not narrowing enough for this specific query.
{ nReturned: 118, totalKeysExamined: 120 } — well-aligned. { nReturned: 60, totalKeysExamined: 40000 } — poorly aligned.
small nReturned + large docsExamined/keysExamined = worth investigating
This pattern is visible directly in slow-query logs and the profiler, without running explain() first — a triage signal.
{ nreturned: 12, keysExamined: 85000 } in a log line flags a query worth confirming with explain().
capture before → change → capture after → compare
Confirm an index change worked with a real before/after explain() comparison — not a subjective impression.
A SORT stage disappearing from the stage tree after adding a compound index is direct, structural confirmation.
db.setProfilingLevel(1, { slowms: 100 }) → db.system.profile
The profiler logs real production operations exceeding a threshold — the evidence a local benchmark cannot reproduce.
db.system.profile.find({ millis: { $gt: 200 } }).sort({ ts: -1 }).limit(20) surfaces the worst recent real offenders.
db.collection.aggregate([ { $stage: {...} }, ... ])
A sequence of stages, each transforming the previous stage's output — MongoDB's tool for grouping, reshaping, and computing.
aggregate([{ $match: {...} }, { $group: { _id: "$customerId", total: { $sum: "$amount" } } }])
$match · $project · $set · $group · $sort · $limit · $facet · $count
The core stage vocabulary — each stage has one job, and pipelines compose them like a sentence describing the task.
$facet runs multiple sub-pipelines (e.g. grouped by status, grouped by region) against the same input in one call.
filter early, reshape only what's needed, $sort before $limit
Two logically identical pipelines can have very different real cost based purely on stage order.
$match before $group filters to relevant documents first; $group before $match wastes work grouping everything.
leading $match → can use an index, exactly like find()
A $match placed before any reshaping stage sees the real collection documents and can use an index — verify with explain().
aggregate([{ $match: { status: "pending" } }, ...]).explain() shows the same IXSCAN a find() call would.
100MB per in-memory stage, default — auto-spills to disk (6.0+)
Stages that must hold state (mainly $group, $sort, $bucket family) are capped per-stage — spilling avoids errors but is slower.
db.events.aggregate([...], { allowDiskUse: true }) — explicit override, though 6.0+ already defaults to allowing it.
summary-shaped result → aggregate, not fetch-and-loop
A pipeline computes a summary where the data lives, transferring only the final result — a loop transfers everything first.
aggregate([{ $match }, { $group: { _id: null, total: { $sum: "$amount" } } }]) instead of find().toArray().reduce().
{ $operator: <expr> | [<expr>, ...] }
Expression operators compute a value from document fields inside a stage — grouped by arithmetic, comparison, boolean, string, date, array, conditional, object, and conversion.
{ $set: { total: { $multiply: ["$price", "$qty"] } } }
$ifNull([expr, fallback]) · $cond({if,then,else}) · $switch({branches,default})
Three ways to branch inside an expression, from simplest (one fallback) to most general (ordered multi-way branch).
{ $switch: { branches: [{ case: {...}, then: "a" }], default: "b" } }
$map({input,as,in}) · $filter({input,as,cond}) · $reduce({input,initialValue,in})
Transform, keep, or fold an array field in place, without exploding the document with $unwind.
{ $reduce: { input: "$items", initialValue: 0, in: { $add: ["$$value", "$$this.price"] } } }
$dateToString({date,format,timezone}) · $dateTrunc({date,unit,timezone})
Round a timestamp down to the bucket you want to group by — day, hour, week — respecting timezone.
{ $group: { _id: { $dateToString: { date: "$at", format: "%Y-%m", timezone: "UTC" } } } }
$convert: { input, to, onError?, onNull? }
Converts a value's BSON type, with explicit fallback values on failure instead of aborting the pipeline.
{ $toInt: { $ifNull: ["$qty", "0"] } }
index bounds document count, not per-document expression cost
A well-indexed $match still hands a large or expression-heavy $group/$project stage real CPU work — that cost does not show up as a scan-to-return ratio.
{ $unwind: "$field" }
Explodes an array field into one document per element — a document count multiplier, usually followed by $match/$group on the now-scalar field.
{ $unwind: "$tags" }, { $group: { _id: "$tags", n: { $sum: 1 } } }
{ $unwind: { path, preserveNullAndEmptyArrays, includeArrayIndex } }
Object form of $unwind: keep documents with an empty/missing array (preserveNullAndEmptyArrays), and/or record each element's original index (includeArrayIndex).
{ $unwind: { path: "$tags", preserveNullAndEmptyArrays: true } }
output count = Σ array length across surviving documents
$unwind's multiplier applies to every stage that runs after it — filter first, or avoid $unwind when a scalar result is all that is needed.
same doc, narrowed array → $filter/$map · one doc per element → $unwind
Reach for $filter/$map when the output should still be one document per input — $unwind is for when the result genuinely needs one document per array element.
{ $lookup: { from, localField, foreignField, as } }
Joins documents from another collection into an array field — always a left outer join, always an array output even for a single match.
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }
{ $lookup: { from, let, pipeline, as } }
The general form of $lookup — let exposes local fields as $$vars for a nested pipeline to match on anything, not just equality.
{ $lookup: { from: "shipments", let: { id: "$_id" }, pipeline: [{ $match: { $expr: { $eq: ["$orderId", "$$id"] } } }], as: "shipment" } }
index foreignField before joining against it
A $lookup without an index on the foreign collection's matched field degrades toward a repeated collection scan.
hot path → denormalize · occasional report → $lookup
Choose based on read frequency and criticality, not on which approach is technically possible — both are valid schema tools for different access patterns.
low cardinality → small joined array · high cardinality → cap with $limit inside the pipeline form
The foreignField index makes matching fast; cardinality determines how large the joined array ends up, which is a separate cost.
{ $setWindowFields: { partitionBy, sortBy, output } }
Computes a value across a window of related documents while keeping every original document — unlike $group, which collapses them.
{ output: { rank: { $rank: {} } } }
$rank/$denseRank (order only) · $sum/$avg + window (bounded accumulator)
Rank operators use sortBy alone; accumulator operators need an explicit window to bound how many neighboring documents they include.
{ output: { rank: { $rank: {} }, runningTotal: { $sum: "$amt", window: { documents: ["unbounded", "current"] } } } }
known-shape report on live data → aggregation · ad hoc / huge / heavy joins → warehouse
Aggregation fits reporting colocated with operational data; a dedicated analytics system fits exploratory, large-scale, or resource-isolated workloads.
one updateOne() call = atomic, regardless of how many fields it changes
Single-document writes are always all-or-nothing — combine related field changes into one call to get this guarantee for them.
session.startTransaction() → writes with { session } → commitTransaction()/abortTransaction()
Extends all-or-nothing atomicity across multiple documents and collections — real cost, use when embedding cannot avoid the cross-document need.
await session.withTransaction(async () => { ...writes with { session }... })
startSession() → startTransaction() → { session } writes → commitTransaction()/abortTransaction() → endSession()
The full transaction lifecycle — a session is the container, every operation inside must explicitly carry it.
session.withTransaction(async () => { ...{ session } writes... })
The recommended default — wraps startTransaction/commit/abort and automatically retries on documented transient transaction errors.
transaction cost ∝ how long it stays open, not just data touched
Locks and snapshot resources are held for the whole open duration — keep slow work (network calls, external APIs) outside the boundary.
startTransaction({ readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } })
Set once for the whole transaction — snapshot reads see a consistent point in time, majority write concern makes the commit durable.
TransientTransactionError → retry whole transaction · UnknownTransactionCommitResult → retry commit only
Check err.hasErrorLabel(...) to distinguish an expected, retryable failure from a real error — withTransaction handles both correctly by default.
could this be one document? no → transaction · yes → embed instead
A transaction is justified for genuinely separate entities with independent lifecycles — not for data split into multiple documents out of habit.
one write call = atomic · read + decide + write = not atomic as a unit
Single-document atomicity covers one call, not a sequence of a read followed by a separate write — that gap is what the rest of this section addresses.
new value = f(current value) → use $inc/$push/$max/… directly, not findOne + updateOne
An atomic operator is one round trip with no race window — a read-then-write is two operations that can race under concurrency.
updateOne({ _id, stock: { $gte: n } }, { $inc: { stock: -n } })
updateOne({ _id, expected }, { $set: {...} }) → matchedCount === 0 means retry
Put the expected current value in the filter so the check-and-write happens as one atomic step — retry with fresh data on a failed match.
updateOne({ _id, version: v }, { $set: {...}, $inc: { version: 1 } })
A version field checked-and-incremented in one atomic call — catches any concurrent write, not just ones touching a specific business field.
read → [gap] → write, based on the read → stale-read race grows with the gap and concurrent traffic
Fix with a conditional write (optimistic concurrency) or an atomic operator — never by assuming the gap stays small enough not to matter.
createIndex({ field: 1 }, { unique: true }) → handle code 11000 as expected
A unique index enforces an invariant atomically at the database level — an application-side check-then-insert always has a race window the index does not.
{ w: 1 | "majority" | n, j: true|false, wtimeout: ms }
w controls how many members must confirm a write before it is acknowledged — stronger means safer against failover, at the cost of latency.
{ writeConcern: { w: "majority", j: true } }
{ w, j, wtimeout }
w = how many copies, j = how durable each one, wtimeout = how long to wait — independent settings, not one combined knob.
{ writeConcern: { w: "majority", j: true, wtimeout: 5000 } }
local (fast, can roll back) < majority (durable) < linearizable (durable + latest)
readConcern controls whether returned data could still be rolled back — stronger levels trade latency for that guarantee.
{ readConcern: { level: "majority" } }
stronger concern = more latency + lower availability under partition, in exchange for a stronger guarantee
Match concern strength to the real cost of the weaker guarantee being wrong — not a single project-wide default.
consistency ↔ latency ↔ availability — pick two, deliberately, per operation
Every stronger read/write concern is a real trade against latency and, under a partition, availability — never a free upgrade.
client.startSession() → operations with { session } → session.endSession()
A server-tracked logical identity for related operations, decoupled from any one connection — the basis for causal consistency, retryable writes, and transactions.
transaction → scoped to a session · retryable write → session ID + txnNumber identifies a retry
Both features rely on a session's identity — one to hold in-progress state, the other to let the server recognize a retried write safely.
idle session timeout: 30 min default · implicit session per operation without one
Most code benefits from an implicit, driver-managed session automatically — use explicit sessions specifically for transactions or causal consistency.
rs.initiate({ _id, members: [...] })
Several servers holding the same data — one primary accepts writes, secondaries replicate, and the group elects a new primary automatically on failure.
primary: accepts writes (1 at a time) · secondary: replicates, optionally serves reads
A role assigned to exactly one member at a time — writes always go to whichever member currently holds it.
local.oplog.rs — capped, idempotent, tailed continuously by secondaries
The ordered record of every write a secondary replays to stay in sync — its fixed size bounds how long a disconnect can last before a full resync is needed.
majority vote → most up-to-date candidate becomes primary → typically seconds
A deliberate or crash-triggered election picks a new primary automatically — writes are unavailable for a brief window while it completes.
.readPref("primary" | "secondary" | ...)
Controls which member(s) a read can reach — a separate setting from write concern and read concern.
primary · primaryPreferred · secondary · secondaryPreferred · nearest
The five read preference modes — "Preferred" always means try-then-fallback, never only-if-convenient.
lag = primary's latest oplog time − secondary's latest applied oplog time
Normally milliseconds, grows under load or network issues — directly bounds how stale a secondary-routed read can be.
secondary read can be stale by up to current replica lag
A secondary-routed read can miss a recent write — use readPref("primary") or a shared session (causal consistency) when read-your-own-writes matters.
majority = floor(N / 2) + 1
The same threshold governs write concern, read concern, and elections — guaranteeing any two majorities overlap by at least one member.
votes: 0 → replicates and can read, cannot vote or become primary
Voting rights and holding a data copy are separate — majority is computed against voting members only, capped at 7.
majority side → elects, keeps writing · no-majority side(s) → read-only, no primary
A partition can produce at most one side with a majority, which is exactly why split-brain (two primaries) cannot happen under normal majority-based elections.
concern setting + topology (voter count, placement) = actual real-world guarantee
The same write concern or read preference setting protects against different failures depending on the current topology — re-audit after any topology change.
hidden · secondaryDelaySecs · priority · votes
Configuration dials on a normal secondary — combine hidden + delayed + non-voting for a recovery-window safety net.
{ hidden: true, secondaryDelaySecs: 3600, priority: 0, votes: 0 }