MongoDB · cheat sheet

MongoDB

Documents, CRUD, schema design, indexes and ESR, explain, aggregation, transactions, replica sets and sharding: the MongoDB facts interviewers ask.

The MongoDB facts interviewers ask, from BSON to sharding. Shell examples use mongosh; defaults are for current releases (5.0 and later) unless a version is given.

Documents, BSON & _id

  • A document is a BSON object: ordered fields, plus types JSON lacks (ObjectId, Date, Int32, Long, Decimal128, binary).
  • mongosh numbers are doubles by default. Use NumberInt(5), NumberLong(5) or Decimal128("9.99") (money as Decimal128 or integer cents).
  • Every document in a standard collection needs a unique _id: the primary key, always indexed, immutable. If you omit it, the driver generates an ObjectId.
  • _id can be any BSON type except an array, regex or undefined, so a natural key such as an email works.
  • ObjectId = 12 bytes: 4-byte timestamp (seconds), 5-byte per-process random value, 3-byte counter. It is roughly time-ordered; ObjectId().getTimestamp() returns its creation time.
  • Max document size is 16 MiB and max nesting 100 levels; store larger files with GridFS.
  • Dot notation reaches into embedded documents and array positions: "address.city", "items.0.sku".
  • Collections are schemaless by default; enforce a shape with a $jsonSchema validator (validationLevel, validationAction).
SQL MongoDB
table / row / column collection / document / field
primary key _id
JOIN embedding, or $lookup
WHERE / SELECT a, b filter / projection ($match / $project)
GROUP BY $group
foreign key reference (never enforced)
materialized view $merge into a collection

CRUD in mongosh

Task Method Note
Insert insertOne(doc), insertMany(docs) insertMany is ordered by default: it stops at the first error
Read find(filter, projection), findOne() find returns a cursor
Update updateOne / updateMany(filter, update, { upsert: true }) update must use operators or a pipeline
Replace replaceOne(filter, doc) swaps the whole document, keeps _id
Delete deleteOne, deleteMany deleteMany({}) empties the collection
Read-modify-write findOneAndUpdate(f, u, { returnDocument: "after" }) returns the original document by default
Count countDocuments(filter), estimatedDocumentCount() the second reads metadata and takes no filter
Batch bulkWrite([...]) mixes inserts, updates and deletes
db.users.insertOne({ name: "Ann", email: "ann@x.io", tags: ["sql"], age: 30 });
db.users.find({ age: { $gte: 18 }, tags: "sql" }, { name: 1, _id: 0 })
  .sort({ age: -1 }).limit(10);
db.users.updateOne(
  { email: "ann@x.io" },
  { $set: { age: 31 }, $push: { tags: "mongo" },
    $setOnInsert: { createdAt: new Date() } },
  { upsert: true }
);
db.users.deleteMany({ lastLogin: { $lt: ISODate("2025-01-01") } });
JavaScript

Query & update operators

Query group Operators Remember
Comparison $eq $ne $gt $gte $lt $lte $in $nin $ne and $nin also match documents without the field
Logical $and $or $nor $not comma-separated conditions are already an AND
Element $exists, $type { f: null } matches null or missing
Array $all, $elemMatch, $size $size takes an exact number, not a range
Evaluation $regex, $expr, $text, $mod $expr compares fields: { $expr: { $gt: ["$spent", "$budget"] } }
Geospatial $near, $geoWithin, $geoIntersects need a 2dsphere or 2d index
  • { tags: "red" } matches arrays containing "red"; { tags: ["red", "blank"] } matches that exact array in that order; $all ignores order.
  • { dims: { $gt: 15, $lt: 20 } } can be satisfied by two different elements; { dims: { $elemMatch: { $gt: 15, $lt: 20 } } } needs one element to satisfy both.
Update group Operators
Fields $set, $unset, $inc, $mul, $min, $max, $rename, $currentDate, $setOnInsert
Arrays $push, $addToSet, $pull, $pullAll, $pop (1 last, -1 first)
$push modifiers $each, $position, $slice, $sort
Positional $ (first match), $[] (all elements), $[x] with arrayFilters
  • Keep the last 10: { $push: { recent: { $each: [item], $slice: -10 } } }.
  • Filtered elements: updateMany({}, { $set: { "grades.$[g].passed": true } }, { arrayFilters: [{ "g.score": { $gte: 50 } }] }).
  • Pipeline updates (4.2+) compute from other fields: updateOne(f, [{ $set: { total: { $add: ["$a", "$b"] } } }]).

Data modeling

“Data that is accessed together should be stored together”: design for the queries, not the entities.

Embed when Reference when
data is read and updated together the child side is large or grows without bound
a “has-a” / “contains” relationship (one-to-few) the child exists on its own, or many-to-many
you need one atomic write parts are written at different times
the combined size stays small duplication would be hard to keep in sync
  • One-to-few: embed an array. One-to-many: an array of child ids in the parent. One-to-huge: store the parent id in each child.
  • Duplicating a few fields is fine when they change rarely: cheaper than a $lookup on every read.
Pattern Idea Example
Attribute many similar fields → array of { k, v } with one index product specs
Bucket group many small records into one document readings per sensor per hour
Computed precompute on write review count, order total
Approximation write less often when exactness does not matter page views updated every 100 hits
Extended reference copy the fields you always read from the referenced document customer name on each order
Subset embed the hot part, keep the rest elsewhere 10 newest reviews on a product
Outlier overflow documents for rare huge cases a book with millions of reviews
Polymorphic different shapes in one collection type field per product kind
Schema versioning schemaVersion field, migrate lazily old and new documents coexist
Tree parent refs, child refs, ancestor arrays or paths category hierarchies

Indexes & the ESR rule

Index Syntax Notes
Single field { email: 1 } Direction does not matter
Compound { userId: 1, createdAt: -1 } Up to 32 fields; prefix rule
Multikey any index on an array field Automatic; a compound index allows at most one array field per document
Text { title: "text", body: "text" } One per collection; MongoDB now recommends its Search (Atlas Search) indexes
Wildcard { "$**": 1 } For unpredictable field names
Geospatial { loc: "2dsphere" } $near, $geoWithin
Hashed { userId: "hashed" } Hashed sharding; equality lookups only, no unique, no arrays
TTL createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }) Single date field; the delete task runs every 60 s
Unique { unique: true } A missing field indexes as null, so only one document may lack it
Partial { partialFilterExpression: { active: true } } Smaller; preferred over sparse
  • ESR: Equality fields first, then Sort fields, then Range fields. find({ status: "A", qty: { $gt: 10 } }).sort({ date: -1 }) wants { status: 1, date: -1, qty: 1 }.
  • $ne, $nin and $regex count as range predicates, not equality.
  • Prefix rule: { a: 1, b: 1, c: 1 } serves queries on a, a, b and a, b, c, not on b or c alone.
  • { a: 1, b: -1 } can sort by { a: 1, b: -1 } or { a: -1, b: 1 }, but not { a: 1, b: 1 }.
  • Covered query: every filtered and returned field is in one index and _id is excluded ({ _id: 0 }) unless indexed.
  • A collection allows 64 indexes; each costs RAM and write time. Since 4.2 builds lock the collection only at the start and end, and the background option is ignored.

explain()

  • db.orders.find({ status: "A" }).explain("executionStats"); for pipelines, db.orders.explain("executionStats").aggregate([...]). Modes: queryPlanner (default), executionStats, allPlansExecution.
Stage Meaning
COLLSCAN full collection scan: no usable index
IXSCAN index scan
FETCH loads documents after the index scan
SORT in-memory sort: no index supports the sort
IXSCAN with no FETCH above it covered query
SHARD_MERGE, SHARDING_FILTER merges shard results, filters orphan documents
  • Healthy: nReturned ≈ totalKeysExamined ≈ totalDocsExamined. totalDocsExamined far above nReturned means a weak index; 0 means covered.
  • Find slow queries with the profiler: db.setProfilingLevel(1, { slowms: 100 }), then db.system.profile.find(). Force a plan with .hint({ status: 1 }); check index use with $indexStats.

Aggregation pipeline

Stage Does SQL analogue
$match filters; put it first to use indexes WHERE / HAVING
$project, $set, $unset reshapes, adds or removes fields select list
$group groups by _id with $sum, $avg, $min, $max, $push, $addToSet, $first GROUP BY
$sort, $skip, $limit orders and pages ORDER BY, LIMIT
$lookup left outer join to a collection in the same database LEFT JOIN
$unwind one document per array element unnest
$facet several sub-pipelines over the same input several queries
$bucket, $sortByCount, $count histograms, group-and-count, count CASE + GROUP BY
$setWindowFields (5.0+) window functions OVER (…)
$graphLookup, $unionWith recursive search, append another collection recursive CTE, UNION ALL
$out, $merge write results; must be the last stage INSERT … SELECT
db.orders.aggregate([
  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-01-01") } } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" }, n: { $sum: 1 } } },
  { $sort: { total: -1 } },
  { $limit: 5 },
  { $lookup: { from: "customers", localField: "_id",
               foreignField: "_id", as: "customer" } },
  { $unwind: "$customer" },
  { $project: { _id: 0, name: "$customer.name", total: 1, n: 1 } }
]);
JavaScript
  • Each stage may use 100 MB of RAM; since 6.0 allowDiskUseByDefault is true, so big $sort and $group stages spill to disk.
  • A leading $match or $sort can use indexes (the optimizer moves filters forward where it can); $lookup uses indexes on the foreign collection. Each output document must fit in 16 MiB.

Transactions & consistency

  • Single-document writes are always atomic, embedded arrays included. Multi-document ACID transactions arrived in 4.0 (replica sets) and 4.2 (sharded clusters).
  • Transactions are aborted after 60 s by default (transactionLifetimeLimitSeconds). Keep them short; a schema that needs one per request should probably embed more.
  • The callback API (withTransaction) retries on TransientTransactionError and UnknownTransactionCommitResult; with the core API you write the retries. Pass the session to every operation.
const session = client.startSession();
try {
  await session.withTransaction(async () => {
    await accounts.updateOne({ _id: from }, { $inc: { balance: -100 } }, { session });
    await accounts.updateOne({ _id: to }, { $inc: { balance: 100 } }, { session });
  });
} finally {
  await session.endSession();
}
JavaScript
Setting Values Default
Write concern w: 0 (no ack), 1 (primary), "majority", a number; j: true waits for the journal; wtimeout w: "majority" (5.0+), but w: 1 in primary-secondary-arbiter sets
Read concern local, available, majority, linearizable, snapshot local
Read preference primary, primaryPreferred, secondary, secondaryPreferred, nearest primary
  • local can return writes that are later rolled back; majority returns only majority-committed data.
  • Secondary reads can be stale (replication lag). For read-your-own-writes use a causally consistent session.
  • Transactions that read must use read preference primary. Current drivers retry a failed write once by default (retryable writes).

Replica sets & sharding

  • A replica set has one primary, which takes all writes and logs them to the oplog; secondaries copy the oplog asynchronously and can serve reads.
  • Minimum recommended: 3 data-bearing members. Limits: 50 members, 7 voting.
  • Heartbeats every 2 s. If the primary is silent for electionTimeoutMillis (10 s), a secondary calls an election; median failover is about 12 s, with no writes until it ends.
  • Winning needs a majority of voting members, so use an odd number. Arbiters vote but hold no data. Special members: priority 0 (never primary), hidden (no client reads), delayed (a lagging copy to recover from mistakes).
  • Writes not yet on a majority can be rolled back after a failover: that is why w: "majority" is the default.
  • Sharded cluster: shards (each a replica set), mongos routers (clients connect here) and config servers (metadata).
  • Data is split into ranges (chunks) of shard key values, 128 MB by default. The balancer migrates data once two shards differ by three times the range size.
  • Queries that include the shard key are targeted; the rest scatter-gather to every shard.
  • Change the key with reshardCollection (5.0+), or add suffix fields with refineCollectionShardKey.
  • Unique indexes must be prefixed by the shard key, and _id is unique only per shard unless it is the shard key.
Shard key Result
{ createdAt: 1 } or { _id: 1 } with ObjectId monotonic: every insert hits one shard
{ country: 1 } low cardinality: few chunks that cannot split (jumbo)
{ userId: "hashed" } even writes, targeted equality; range queries broadcast
{ customerId: 1, orderDate: 1 } targeted per customer, spreads if there are many customers

Limits, pagination & change streams

Limit Value
Document size / nesting 16 MiB / 100 levels
Indexes per collection / fields per compound index 64 / 32
Replica set members / voting members 50 / 7
Aggregation stages / memory per stage 1000 / 100 MB before spilling
Transaction lifetime 60 s (default)
Idle cursor timeout 10 min
Namespace db.collection 255 bytes (235 when sharded)
  • skip(n) scans from the start of the results, so it slows down as the offset grows. Paginate by range, with a unique tiebreaker such as _id in the sort.
const q = db.posts.find().sort({ createdAt: -1, _id: -1 }).limit(20);
const last = q.toArray().at(-1);           // remember the last document
db.posts.find({ $or: [
  { createdAt: { $lt: last.createdAt } },
  { createdAt: last.createdAt, _id: { $lt: last._id } }
] }).sort({ createdAt: -1, _id: -1 }).limit(20);   // index { createdAt: -1, _id: -1 }
JavaScript
  • Change streams: watch() on a collection, a database (db.watch()) or a deployment (Mongo.watch()). They need a replica set or sharded cluster and read from the oplog.
  • Each event’s _id is a resume token: reopen with resumeAfter or startAfter (which also works after an invalidate event), as long as the oplog still covers that point.
  • fullDocument: "updateLookup" attaches the current document to update events; pre- and post-images need 6.0+ and changeStreamPreAndPostImages.

Mongoose at a glance

const userSchema = new Schema({
  email: { type: String, required: true, unique: true, lowercase: true },
  age: { type: Number, min: 0 },
  role: { type: String, enum: ["user", "admin"], default: "user" },
  team: { type: Schema.Types.ObjectId, ref: "Team" },
}, { timestamps: true });
userSchema.pre("save", function () { /* runs before save() */ });
const User = model("User", userSchema);
const adults = await User.find({ age: { $gte: 18 } }).populate("team").lean();
JavaScript
  • unique: true builds a unique index; it is not a validator.
  • populate() runs a separate query per populated path, not a $lookup.
  • lean() returns plain objects (the docs measure about 3× smaller): no getters, setters, virtuals, save() or validation.
  • strict (on by default) drops unknown fields on save; strictQuery has defaulted to false since Mongoose 7.
  • findOneAndUpdate returns the old document unless you pass { returnDocument: "after" } (new: true is deprecated). Update validators need runValidators: true.
  • timestamps: true adds createdAt and updatedAt; __v is the version key. autoIndex is on by default; turn it off in production.

Performance anti-patterns

  • Unbounded arrays: they approach 16 MiB and slow indexes; use buckets, subsets or child references.
  • Too many collections (one per customer): use one collection with a key field.
  • Unnecessary indexes: find them with $indexStats; { a: 1 } is usually redundant next to { a: 1, b: 1 }.
  • Bloated documents: move rarely used data out (subset pattern) and project only what you need.
  • Too many $lookups: embed or copy what is read together (extended reference).
  • Case-insensitive regex such as /^ann/i cannot use an index efficiently: use a collation index ({ collation: { locale: "en", strength: 2 } }, queried with the same collation) or store a lowercase copy.
  • Unanchored regexes, $where, large skip(), SORT stages without an index, and a working set (hot data plus indexes) bigger than RAM.

SQL vs MongoDB

Need PostgreSQL / SQL MongoDB
Schema fixed, migrations flexible per document, optional validation
Relationships joins, enforced foreign keys embedding, $lookup, no enforcement
Transactions the default model single document atomic; multi-document costs more
Horizontal write scaling not built in built-in sharding
Queries ad hoc SQL, complex reporting shaped by access patterns, aggregation pipeline
Good fit ledgers, strict integrity, analytics catalogs, content, user profiles, events, IoT

Quick answers

  • Is MongoDB ACID? Single-document operations always are; multi-document transactions since 4.0 (replica sets) and 4.2 (sharded).
  • Replication vs sharding? Replication copies the same data for availability and read scaling; sharding partitions data for write and storage scale.
  • What happens when the primary dies? An election (median about 12 s); writes wait, and drivers retry once.
  • What is the oplog? A capped collection of idempotent operations that secondaries replay; its size sets how far a member can lag or a change stream can resume.
  • How do joins work? $lookup does a left outer join; better, embed data that is read together.
  • Why ObjectId? Generated client-side with no coordination, 12 bytes, roughly time-ordered; that also makes it a poor ranged shard key.
  • countDocuments vs estimatedDocumentCount? Accurate count with a filter vs fast metadata count, which can drift (orphans, unclean shutdown).
  • Capped collection? Fixed size, insertion order, oldest documents overwritten. For expiry by age use a TTL index.
  • Time series collections (5.0+)? They store measurements bucketed automatically.
  • How do you enforce a schema? $jsonSchema validation on the collection, or Mongoose in the app.

Gotchas & traps

  • updateOne(filter, { name: "x" }) fails (“Update document requires atomic operators”): use $set, or replaceOne.
  • { f: null } matches missing fields too; { f: { $type: "null" } } matches only real nulls.
  • A unique index lets only one document lack the field; add partialFilterExpression: { f: { $exists: true } }.
  • Matching a whole embedded document is order-sensitive: { size: { h: 14, w: 21 } } ≠ { size: { w: 21, h: 14 } }. Use dot notation.
  • Array conditions without $elemMatch can be met by different elements.
  • TTL deletes are not instant: the monitor runs every 60 s, and documents without a date field never expire.
  • find() returns a cursor that fetches in batches (mongosh prints the first 20); idle cursors time out after 10 minutes.
esc