MongoDB Aggregation Pipeline Stage Order
Why $match before $group can change your answer, what the MongoDB optimizer reorders for you, and how to order stages for speed safely.
Document databases: schema design and embedding vs referencing, indexes, the aggregation pipeline, replication, sharding and when to choose NoSQL.
Official reference: MongoDB manual
Why $match before $group can change your answer, what the MongoDB optimizer reorders for you, and how to order stages for speed safely.
What the 12 bytes of an ObjectId contain, why its timestamp is not a reliable clock, and when to choose your own _id in MongoDB.
How field order in a MongoDB compound index decides which queries it serves, with the equality, sort, range rule worked through step by step.
Why one MongoDB update is all-or-nothing for a document, how conditional updates stop lost writes, and where single-document atomicity ends.
How to decide whether related MongoDB data goes inside one document or in its own collection, based on access patterns, growth and ownership.
How to read MongoDB explain output: the plan stages, keys and documents examined versus returned, and what each pattern tells you to fix.
What $lookup really does per input document, why an index on the foreign field matters, and when to reshape data instead of joining.
Why { field: null } in MongoDB also matches documents without the field, and how $exists and $type let you tell missing and null apart.
How MongoDB indexes every array element, why $elemMatch matters for arrays of subdocuments, and the parallel-array limit on compound indexes.
Why skip-based paging in MongoDB gets slower and drops rows, and how a range cursor on a unique sort key gives stable, fast pages.
How MongoDB partial indexes shrink an index to the documents you query, when the planner can use them, and how they give conditional uniqueness.
What local, majority, linearizable and snapshot read concerns promise in MongoDB, and how sessions give read-your-own-writes on secondaries.
How a MongoDB replica set notices a dead primary, elects a new one in seconds, and what your app sees: errors, retries and rolled-back writes.
How to add $jsonSchema validation to a MongoDB collection, roll it out safely with moderate and warn modes, and still allow flexible fields.
How to choose a MongoDB shard key with enough cardinality, even writes and targeted reads, and what to do when the first choice turns out wrong.
When a MongoDB multi-document transaction is the right tool, what it costs, how to retry it correctly, and when a better model removes the need.
How MongoDB's TTL monitor deletes expired documents, why they can linger past their deadline, and how to filter, tune and size expiry safely.
Why a MongoDB unique index beats a find-then-insert check, how missing fields and case break it, and how to handle E11000 errors cleanly.
How $unwind turns one MongoDB document into one per array element, why that inflates sums and counts, and how to avoid double counting.
What w: 1, w: "majority" and j: true actually promise in MongoDB, what a wtimeout error means, and how to pick a write concern per operation.
MongoDB is a document database. It stores records as BSON documents (JSON-like, with nested objects and arrays) grouped into collections, with a flexible schema, a rich query language, secondary indexes, an aggregation framework, built-in replication through replica sets and horizontal scaling through sharding.
I'd pick it when:
I'd lean relational when the domain is dominated by many-to-many relationships and ad hoc joins, or when most operations touch many entities transactionally. MongoDB does have ACID multi-document transactions and schema validation, so the real decision is about data shape and access patterns, not "no transactions".
Likely follow-up: What would make you move a MongoDB workload back to SQL?
The terminology maps fairly directly:
_id field$lookup in an aggregationGROUP BY → the $group stageThe bigger differences are in philosophy. SQL databases enforce a fixed schema and normalize data by entity, then join at query time. MongoDB lets documents in a collection differ (you can add $jsonSchema validation when you want rules), and you model around access patterns, storing data that's read together in one document.
MongoDB doesn't enforce foreign keys, so referential integrity is the application's job. On scaling, MongoDB has sharding built in, whereas many relational setups scale up first. Both offer ACID transactions; in MongoDB a single-document write is always atomic, and multi-document transactions exist but cost more.
_id map to table, row, column, PK$lookupLikely follow-up: What are the main categories of NoSQL databases?
BSON is a binary-encoded serialization of JSON-like documents. MongoDB uses it on disk and on the wire for two reasons.
First, richer types. JSON only has strings, numbers, booleans, null, objects and arrays. BSON adds ObjectId, Date, 32- and 64-bit integers, doubles, Decimal128 for exact money values, binary data, regular expressions and more, so a date stays a date and you can range-query and index it correctly.
Second, efficient traversal. Elements are type-tagged and length-prefixed, so the server can skip over fields without parsing everything.
A document is an ordered set of field/value pairs, up to 16 MiB and 100 levels of nesting. Documents live in collections, which live in databases. Both are created implicitly on first insert. When mongosh prints BSON it uses helpers like ObjectId(...) and ISODate(...); drivers exchange it as Extended JSON when text is needed.
db.orders.insertOne({
customerId: ObjectId("64f1c2a9e4b0a1b2c3d4e5f6"),
total: Decimal128("59.90"),
qty: NumberInt(3),
placedAt: new Date(),
items: [{ sku: "A1", price: Decimal128("19.96") }]
});Likely follow-up: Why use Decimal128 rather than a double for prices?
_id field, and what is inside an ObjectId?easyEvery document in a standard collection needs an _id that acts as its primary key. It must be unique within the collection, it's immutable once set, and it can be any BSON type except an array, a regex or undefined. MongoDB automatically creates a unique index on _id for every collection. If you insert a document without one, the driver generates an ObjectId.
An ObjectId is 12 bytes:
So IDs are generated client-side with no coordination, and getTimestamp() recovers the creation time. Because they roughly increase over time, sorting by _id approximates insertion order, but it isn't strictly monotonic across different clients. That same "always increasing" property makes a plain ranged ObjectId a poor shard key.
Likely follow-up: When would you use a natural key instead of an ObjectId for _id?
The guiding principle is data that's accessed together should be stored together, and I start from embedding unless there's a reason not to.
Embed when:
Reference when:
Often the answer is a hybrid: reference the full record but embed a few frequently read fields (the extended reference pattern). Always design from the queries the application actually runs.
Likely follow-up: How would you model blog posts and their comments?
Without a suitable index a query has to do a collection scan (COLLSCAN), reading every document. An index is an ordered B-tree of field values pointing to documents, so the server can jump to matching entries and even return results in index order without sorting.
Index types:
2dsphere and 2d{ "$**": 1 } for unpredictable field namesIndex properties include unique, partialFilterExpression, sparse, TTL (expireAfterSeconds), hidden and a collation for case-insensitive matching. Every collection gets a unique _id index.
The trade-off: each index costs RAM and disk, and slows every insert, update and delete that touches its fields.
_idLikely follow-up: How would you find indexes that are never used?
The aggregation pipeline is MongoDB's framework for transforming and analyzing data on the server. Documents flow through an ordered list of stages, and each stage's output is the next stage's input, much like a Unix pipe.
This one returns the top five customers by paid revenue this year:
$match filters to paid orders since January. Placed first, it can use an index on status and placedAt$group makes one document per customerId, summing amount and counting orders with $sum: 1$sort and $limit keep the five biggest totals; the optimizer merges them into a top-k sort$project reshapes the output, renaming _id to customerIdOther common stages are $lookup for joins, $unwind to flatten arrays, $addFields/$set, $facet, $count, and $out or $merge to write results to a collection.
db.orders.aggregate([
{ $match: { status: "paid", placedAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: "$customerId", total: { $sum: "$amount" }, orders: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 5 },
{ $project: { _id: 0, customerId: "$_id", total: 1, orders: 1 } }
]);$match early so it can use indexes$group with accumulators like $sum, $avg$sort plus $limit becomes a top-k sort$project reshapes; $out/$merge persist resultsLikely follow-up: What is the memory limit for a pipeline stage, and what happens when you exceed it?
A replica set is a group of mongod processes holding the same data. One member is the primary and accepts all writes; it records them in its oplog, a capped collection that secondaries copy and replay asynchronously. Members exchange heartbeats every two seconds.
If secondaries can't reach the primary for longer than electionTimeoutMillis (10 seconds by default), an eligible secondary calls an election, and the candidate that wins votes from a majority of voting members becomes primary. The manual says a new primary is typically elected in about 12 seconds or less with default settings. During the election no writes are accepted, but secondaries can keep serving reads if your read preference allows it. Drivers enable retryable writes by default, so many writes are retried once automatically.
When the old primary comes back it rejoins as a secondary. Any of its writes that never reached a majority are rolled back, which is why w: "majority" matters. A set can have up to 50 members, but at most 7 voting ones; use an odd number of voters.
Likely follow-up: What is an arbiter, and why is it often discouraged?
Sharding is horizontal partitioning: a collection's documents are spread across several shards so data size and write throughput can exceed what one server handles.
A sharded cluster has three parts:
mongos routers: the app connects to these; they look up metadata and route each operation to the right shardsThe shard key, one or more fields chosen per collection, decides where each document goes. Data is split into chunks (contiguous shard key ranges, 128 MB by default), and the balancer migrates chunks to keep data evenly spread. Queries that include the shard key are targeted to specific shards; queries without it become scatter-gather across all shards.
Sharding adds real operational complexity, so I'd first make sure indexes, schema and vertical scaling on a replica set are exhausted.
Likely follow-up: What happens to a query that does not include the shard key?
Yes, at two levels.
A write to a single document is always atomic, including all its embedded documents and arrays. With good modeling, that covers most use cases.
For changes across documents or collections, MongoDB has multi-document ACID transactions: on replica sets since 4.0 and on sharded clusters since 4.2. They don't work on a standalone server. Inside a transaction, reads come from a consistent snapshot (on sharded clusters, ask for read concern "snapshot" to guarantee it across shards), commit is all-or-nothing, and no other client sees the changes until commit.
In mongosh, session.withTransaction() runs the callback, commits, and retries the whole transaction or the commit on transient errors. Throwing inside the callback aborts and rolls everything back. That's why the snippet checks modifiedCount: the filter balance: { $gte: 100 } makes the debit conditional, and if it matched nothing we throw instead of committing half a transfer.
Transactions cost more than single-document writes, so the manual advises against using them as a substitute for good schema design.
const session = db.getMongo().startSession();
try {
session.withTransaction(() => {
const accounts = session.getDatabase("bank").accounts;
const debit = accounts.updateOne(
{ _id: "A", balance: { $gte: 100 } },
{ $inc: { balance: -100 } }
);
if (debit.modifiedCount !== 1) throw new Error("insufficient funds");
accounts.updateOne({ _id: "B" }, { $inc: { balance: 100 } });
});
} finally {
session.endSession();
}withTransaction retries transient errors; throw to abortLikely follow-up: What is a TransientTransactionError?
A compound index is sorted by its first field, then by the second within each value of the first, and so on. Two consequences:
{ a: 1, b: 1, c: 1 } supports queries on a, a + b and a + b + c, but not on b alonesort({ a: 1, b: 1 }) and the exact reverse, but not a mixed { a: 1, b: -1 }The ESR guideline says: Equality fields first, then Sort fields, then Range fields. Equality first means everything after it stays in sorted order. Sort next lets MongoDB read keys already in placedAt order and skip an in-memory sort. Range last, because a range on total placed before placedAt would break that ordering.
Watch out: $ne, $nin and $regex count as range operators. And if the range filter is very selective, putting it before the sort field (ERS) can examine fewer keys, so confirm with explain().
db.orders.find({ status: "shipped", total: { $gt: 100 } })
.sort({ placedAt: -1 });
// ESR: Equality, then Sort, then Range
db.orders.createIndex({ status: 1, placedAt: -1, total: 1 });$ne, $nin, $regex behave as range predicatesexplain()Likely follow-up: How does $in fit into ESR when the query also sorts?
explain() to check whether a query is efficient? What is the difference between COLLSCAN and IXSCAN?midexplain() shows the plan the query optimizer chose. It has three verbosity modes: queryPlanner (the default, plan only), executionStats (runs the winning plan and reports counters) and allPlansExecution (also partial stats for rejected plans).
In winningPlan, read the stage tree from the leaf up:
Then compare nReturned, totalKeysExamined and totalDocsExamined in executionStats. The ideal is close to 1:1:1. If you examine 50,000 keys to return 20 documents, the index is weakly selective or its field order is wrong. totalDocsExamined: 0 means a covered query. rejectedPlans shows which other indexes the planner considered.
db.orders.find({ customerId: 42, status: "shipped" })
.sort({ placedAt: -1 })
.explain("executionStats");
// For pipelines:
db.orders.explain("executionStats").aggregate([{ $match: { customerId: 42 } }]);Likely follow-up: How would you find slow queries in production in the first place?
I evaluate candidates on four properties:
continent caps you at seven chunks, and adding more shards won't helpmaxKey upper bound, so one shard takes all the writesmongos can target one shard instead of scatter-gatherA compound key often satisfies all four, for example { customerId: 1, orderDate: 1 }: customer gives spread and targeting, date adds cardinality. Hashing a monotonic field fixes write hot spots but makes range queries broadcast.
A bad key used to be permanent. Now you can refine it by adding suffix fields with refineCollectionShardKey, or fully reshard with reshardCollection since 5.0, though that's a heavy operation. From 7.0, analyzeShardKey reports cardinality, frequency and monotonicity before you commit.
Likely follow-up: Why is { createdAt: 1 } a poor shard key for an events collection?
Yes. Any write to one document is atomic, even if it changes several fields, embedded documents and arrays at once. Other readers see either the old document or the new one, never half of it.
The first pattern is a classic lost update: two clients both read stock: 5, both write 3, and one sale vanishes. The fix is to let the server do the math with update operators like $inc, $set, $push or $min, and to put the precondition in the filter. The second statement only matches if at least two units remain, so checking modifiedCount tells you whether the reservation succeeded.
This atomicity is per document. updateMany applies atomically to each matched document but not to the set as a whole; other operations can interleave. If several documents must change together, either model them into one document or use a multi-document transaction.
// Race-prone: read, compute in the app, write back
const p = db.products.findOne({ _id: 1 });
db.products.updateOne({ _id: 1 }, { $set: { stock: p.stock - 2 } });
// Atomic: the check and the change are one document write
db.products.updateOne(
{ _id: 1, stock: { $gte: 2 } },
{ $inc: { stock: -2 }, $set: { updatedAt: new Date() } }
);$inc/$set instead of read-modify-writemodifiedCountupdateMany is atomic per document, not overallLikely follow-up: How would you implement optimistic locking with a version field?
find()? Explain the common query operators used here.easyfind(filter, projection) returns a cursor over matching documents. Separate conditions in the filter are implicitly ANDed.
The main operator groups:
$eq, $ne, $gt, $gte, $lt, $lte, $in, $nin$and, $or, $nor, $not$exists checks presence, $type checks the BSON type$regex, and $expr to use aggregation expressions, such as comparing two fields of the same document$all (contains every value), $size, $elemMatchSo this finds working-age users in India or the US, on the pro plan or with more than 100 credits, not soft-deleted, tagged both beta and mobile, who have spent more than their budget.
For performance: equality and ranges use indexes well; $ne, $nin and unanchored regexes are rarely selective; a case-sensitive prefix regex like /^abc/ can use an index range.
db.users.find({
age: { $gte: 18, $lt: 65 },
country: { $in: ["IN", "US"] },
$or: [{ plan: "pro" }, { credits: { $gt: 100 } }],
deletedAt: { $exists: false },
tags: { $all: ["beta", "mobile"] },
$expr: { $gt: ["$spent", "$budget"] }
});$expr compares fields within a document$all needs every value; $in needs anyLikely follow-up: What is the difference between $in and $all?
There are three options.
First, avoid the join by embedding the data, or by copying a few fields you always need (the extended reference pattern).
Second, $lookup in an aggregation, which performs a left outer join. For each input document it finds matches in the from collection and puts them in an array field named by as. An order with no customer gets an empty array. $unwind then turns the one-element array into an embedded object, but it drops documents whose array is empty unless you set preserveNullAndEmptyArrays: true. The pipeline form, with let and pipeline, supports extra conditions and sub-queries on the foreign side.
Third, application-level joins: query orders, collect the IDs, then one $in query for customers. That's what Mongoose's populate() does.
Whichever you choose, index the foreign field. Without one, each lookup can scan the whole foreign collection. If nearly every read needs a join, that's a sign to revisit the schema.
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
} },
{ $unwind: "$customer" },
{ $project: { total: 1, "customer.name": 1, "customer.email": 1 } }
]);$lookup is a left outer join into an array$unwind drops non-matches unless preserveNullAndEmptyArraysforeignField$in also workLikely follow-up: Can the from collection of a $lookup be sharded?
w, j and wtimeout, and what the default is.midWrite concern is the level of acknowledgment you ask for before a write is reported as successful. It trades latency for durability.
w: 0: fire and forget, no acknowledgmentw: 1: the primary applied it. It can be rolled back if the primary fails before replicatingw: "majority": a majority of data-bearing voting members have it. It survives failoverw: <n> or a custom tag name for specific member counts or data centersj: true: wait until the write is in the on-disk journalwtimeout: how long to wait for the concern, in millisecondsSince MongoDB 5.0 the implicit default is w: "majority". The exception: with arbiters, if data-bearing voters don't exceed the voting majority, for example primary-secondary-arbiter, the default is w: 1.
Gotcha: a wtimeout error doesn't undo anything. The write may already be applied and may still replicate; you only learn the guarantee wasn't confirmed in time, so the retry logic must be idempotent.
w: 1 can roll back; majority survives failoverj: true waits for the on-disk journalw: "majority" since 5.0, except some arbiter setupswtimeout errors do not undo the writeLikely follow-up: Why does a primary-secondary-arbiter set default to w: 1?
They answer different questions.
Read preference decides which member serves the read:
primary (default), primaryPreferred, secondary, secondaryPreferred, nearestmaxStalenessSeconds to skip lagging secondariesRead concern decides which version of the data is returned, meaning its consistency and durability:
"local" (default): the member's latest data, which might later roll back"available": similar, lowest latency, with sharding caveats"majority": only data acknowledged by a majority, so it won't be rolled back"linearizable": reflects every majority write completed before the read began"snapshot": a consistent point-in-time view, used in transactionsReplication is asynchronous, so reading from secondaries can return stale data even with "majority". Use secondaries for analytics or geo-local reads that tolerate lag, not for read-your-own-writes. Transactions that read must use primary.
primary and "local"Likely follow-up: How can a client read its own writes when reading from secondaries?
MongoDB documents a catalog of reusable patterns. The ones I reach for most:
schemaVersion field lets old and new shapes coexist during a migrationOthers include approximation, tree, pre-allocation and document versioning. Each trades some write complexity or duplication for faster reads.
Likely follow-up: How would you model product reviews for a site where a few products have 100,000 reviews?
The size of the "N" drives the choice, along with whether the children stand alone.
hostId. An array would grow without bound and hit 16 MiBThe rules of thumb behind this: favor embedding unless there's a compelling reason not to; needing to access a child on its own is such a reason; arrays should not grow without bound; don't be afraid of application-level joins if you index properly; and weigh read-to-write ratio before denormalizing a field. Everything depends on the access patterns.
Likely follow-up: How would you get the 20 most recent log messages for a host efficiently?
A BSON document can be at most 16 MiB. The limit exists so a single document can't consume an excessive amount of RAM, or bandwidth when it's sent over the network.
In practice, a document getting anywhere near that size is a modeling smell, usually an array that keeps growing: comments, events, log lines. Every read and update of that document drags the whole thing through the cache, so performance suffers long before the hard limit. Fixes:
For genuinely large binary content like videos or PDFs, use GridFS, which splits a file into chunks (255 kB by default) stored in fs.chunks with metadata in fs.files. Many teams instead put files in object storage and keep only the URL and metadata in MongoDB.
Likely follow-up: When would you choose GridFS over object storage?
A covered query is answered entirely from an index, without reading any documents. That's fast because index keys are small and usually in RAM.
For a query to be covered:
nullThe classic trap is _id: it's returned by default, so unless it's part of the index, the projection must say _id: 0, as above. Other limits: a multikey index can't cover a query on its array field, and on a sharded collection the index must contain the shard key for a query through mongos to be covered.
To confirm, run explain("executionStats"). A covered plan has no FETCH stage, often shows PROJECTION_COVERED, and reports totalDocsExamined: 0 while totalKeysExamined is greater than zero.
db.users.createIndex({ email: 1, name: 1 });
db.users.find(
{ email: "ana@example.com" },
{ _id: 0, email: 1, name: 1 }
).explain("executionStats");
// PROJECTION_COVERED over IXSCAN, totalDocsExamined: 0_id unless it is indexedLikely follow-up: Why might adding one field to a projection make a fast query slow?
An upsert is "update if it exists, insert if it doesn't", enabled with { upsert: true } on updateOne, updateMany, replaceOne or findOneAndUpdate.
If a document matches, it just gets views incremented. If none matches, MongoDB builds a new document from the equality conditions in the filter (page and day), applies the update operators, so views becomes 1, and applies $setOnInsert, which only runs on insert. Range conditions like $gt are not copied into the new document, and an _id is generated if none is given. The result reports upsertedId.
The concurrency trap: two clients can both find no match and both insert, creating duplicates. The fix is a unique index on the filter fields. Then exactly one insert wins. When the filter is equality on exactly the unique index's fields, the server retries the losers as updates; otherwise they get a duplicate key error that the app should retry.
db.pageViews.createIndex({ page: 1, day: 1 }, { unique: true });
db.pageViews.updateOne(
{ page: "/pricing", day: "2026-09-28" },
{
$inc: { views: 1 },
$setOnInsert: { firstSeenAt: new Date() }
},
{ upsert: true }
);$setOnInsert applies only on insertLikely follow-up: What does upsertedId contain when the upsert updated an existing document?
$push, $addToSet and $pull. How would you keep an array capped at the 50 newest items?easyAll three change arrays atomically inside one document, without rewriting the array from the app.
$push appends a value, creating the array if the field is missing. Pushing an array appends it as one element; use $each to add several$addToSet adds a value only if it isn't already present, giving set semantics. For subdocuments, the comparison is on the whole value, including field order$pull removes every element equal to a value or matching a condition, such as all comments by spammer$push also takes modifiers that all require $each: $position to insert at an index, $sort to order the array, and $slice to trim it. They run in a fixed order: insert, then sort, then slice. The feed update above keeps only the 50 newest items, a simple way to stop an array from growing without bound. $pop removes just the first or last element.
db.posts.updateOne({ _id: 1 }, { $push: { comments: { user: "ana", text: "Nice" } } });
db.posts.updateOne({ _id: 1 }, { $addToSet: { tags: { $each: ["mongodb", "nosql"] } } });
db.posts.updateOne({ _id: 1 }, { $pull: { tags: "draft" } });
db.posts.updateOne({ _id: 1 }, { $pull: { comments: { user: "spammer" } } });
db.feeds.updateOne(
{ _id: 7 },
{ $push: { items: { $each: [{ at: new Date() }], $sort: { at: -1 }, $slice: 50 } } }
);$push appends; $each adds several values$addToSet skips values already present$pull removes all matching elements$slice with $each caps array lengthLikely follow-up: How do you update one specific element inside an array?
A projection is the second argument to find() (or a $project stage) that chooses which fields come back. db.users.find({}, { name: 1, email: 1 }) returns only name, email and _id.
The rules:
_id is included by default; add _id: 0 to drop it1) and exclusion (0) in one projection, except for _id"address.city": 1$slice for the first or last N elements, $elemMatch for the first matching element, and positional $ for the element matched by the filterProjections cut network transfer and client memory, and they're required for covered queries. One nuance: unless the query is covered, the server still loads the full document into its cache. Projection shrinks the response, not the disk read, so it isn't a fix for bloated documents.
_id included unless _id: 0_id$slice, $elemMatch, $ project array elementsLikely follow-up: How would you return only the last 5 comments of a post?
$elemMatch?midWhen you query array fields with separate conditions, each condition can be satisfied by a different element. In the first query, the element with product: "xyz" satisfies the first condition and the element with score: 10 satisfies the second, so the document matches, even though no single result is "xyz with a score of at least 8".
$elemMatch requires one element to satisfy all conditions together. The xyz element has a score of 5, so the second query correctly finds nothing.
The same thing happens with scalar arrays: { scores: { $gt: 80, $lt: 85 } } matches [90, 70], because 90 is above 80 and 70 is below 85, while $elemMatch: { $gt: 80, $lt: 85 } needs a value actually between them.
So I use $elemMatch whenever I have two or more conditions on the same array element. For one condition, plain dot notation is enough. There's also a projection $elemMatch that returns only the first matching element.
// { _id: 1, results: [ { product: "abc", score: 10 }, { product: "xyz", score: 5 } ] }
db.survey.find({ "results.product": "xyz", "results.score": { $gte: 8 } });
// matches _id 1
db.survey.find({ results: { $elemMatch: { product: "xyz", score: { $gte: 8 } } } });
// no match$elemMatch needs one element to match all$elemMatch returns first matching elementLikely follow-up: How does a multikey index handle an $elemMatch query with a range?
A TTL index is a single-field index with expireAfterSeconds. A background task deletes documents once that many seconds have passed since the date in the indexed field. It's ideal for sessions, tokens, caches and temporary logs.
Two styles: a fixed lifetime (lastSeen plus 30 minutes), or expireAfterSeconds: 0 with a per-document expireAt date, so each document expires at its own time.
Things to know:
expireAfterSeconds, and _id can't be a TTL index// Delete sessions 30 minutes after lastSeen
db.sessions.createIndex({ lastSeen: 1 }, { expireAfterSeconds: 1800 });
// Or expire each document at its own time
db.invites.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
db.invites.insertOne({ code: "X1", expireAt: new Date("2026-10-01T00:00:00Z") });expireAfterSecondsexpireAfterSeconds: 0 for per-document expiry timesLikely follow-up: How do you change the TTL of an existing index?
skip() a problem on large collections?midThe simple approach is skip((page - 1) * size).limit(size). It works for small data, but the server still has to walk past every skipped entry, so page 5,000 costs far more than page 1. Results can also shift or repeat while users page, because inserts and deletes move the offsets.
For large or infinite-scroll lists I use range-based (keyset or cursor) pagination. Sort on an indexed key, remember the last item returned, and ask for items after it. Each page is an index seek plus limit, so it costs the same at any depth, and concurrent inserts don't cause duplicates.
The sort must be unique to be stable. MongoDB's sort isn't stable for equal keys, so add _id as a tie-breaker, as in the snippet, backed by an index like { status: 1, createdAt: -1, _id: -1 }. The trade-off: you can't jump straight to page 37, which most UIs don't need anyway. The client gets an opaque cursor encoding createdAt and _id.
// First page
db.posts.find({ status: "published" })
.sort({ createdAt: -1, _id: -1 }).limit(20);
// Next page: continue after the last post you returned
db.posts.find({
status: "published",
$or: [
{ createdAt: { $lt: last.createdAt } },
{ createdAt: last.createdAt, _id: { $lt: last._id } }
]
}).sort({ createdAt: -1, _id: -1 }).limit(20);skip walks past skipped entries: cost grows with depth_id tie-breakerLikely follow-up: How would you show a total page count without an expensive count on every request?
A unique index stores a null key for documents where the field is missing, and uniqueness applies to that null like any other value. The first user without an email takes the null slot, and the second collides with it.
A partial index only indexes documents that match partialFilterExpression. Combined with unique: true, uniqueness is enforced only among documents that have an email, so any number of users can omit it. The filter supports equality, $exists: true, $gt/$lt-style comparisons, $type, $and, $or and $in.
Partial indexes also shrink index size, for example indexing only { status: "active" } orders. The catch: the planner uses one only when the query's filter implies the partial filter. A query on email alone may not qualify; include the condition, such as email: { $exists: true }.
Sparse indexes do something similar, skipping documents missing the field, but the manual recommends partial indexes because they're more expressive. Note that creating a unique index fails if existing data already has duplicates.
db.users.createIndex({ email: 1 }, { unique: true });
db.users.insertOne({ name: "A" }); // ok
db.users.insertOne({ name: "B" }); // E11000 duplicate key error
// Better: drop that index and enforce uniqueness only where email exists
db.users.createIndex(
{ email: 1 },
{ unique: true, partialFilterExpression: { email: { $exists: true } } }
);Likely follow-up: How would you enforce case-insensitive uniqueness of emails?
$unwind and $group work together? Explain this best-sellers report.mid$unwind deconstructs an array: an order with three items becomes three documents, each holding one item in items. Documents where the array is missing or empty are dropped unless you pass preserveNullAndEmptyArrays: true.
$group then collapses documents that share a group key, given as _id. Here each SKU becomes one output document, using accumulators: $sum of quantities, $sum of an expression for revenue, and $avg. Others include $min, $max, $first, $last, $push and $addToSet. Grouping by _id: null aggregates the whole input into one document.
Things interviewers look for:
$group output order is not guaranteed, hence the explicit $sort$group is blocking and each stage is limited to 100 MB of RAM; beyond that it spills to disk when disk use is allowed, which is the default since 6.0, or errors if it isn't$unwind multiplies document counts, so $match first to shrink the input// orders: { _id, status, items: [ { sku, qty, price } ] }
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{ $group: {
_id: "$items.sku",
unitsSold: { $sum: "$items.qty" },
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } },
avgQty: { $avg: "$items.qty" }
} },
{ $sort: { revenue: -1 } }
]);$unwind emits one document per array element$group key is _id; null groups everything$sum, $avg, $push, $addToSetLikely follow-up: How could you get the top-selling SKU per category in one pipeline?
Mongoose is an ODM (object data modeling library) for Node.js that sits on top of the official MongoDB driver. It adds structure to MongoDB's flexible documents from the application side.
timestamps, which adds createdAt and updatedAtmongoose.model(). It's the class you query with (User.find, User.create), and it maps to a collection, by default the lowercased plural of the name, so userssave()Mongoose casts values (the string "42" becomes a number), validates before saving, and adds middleware, virtuals and populate().
Gotchas: unique: true is not a validator; it only asks Mongoose to build a unique index. Also, update validators don't run on updateOne and similar unless you pass runValidators: true. Schemas are app-side only; other clients can still write anything unless you also add server-side $jsonSchema validation.
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true },
name: String,
age: { type: Number, min: 0 },
roles: { type: [String], default: ["user"] }
}, { timestamps: true });
const User = mongoose.model("User", userSchema); // collection "users"
await mongoose.connect(process.env.MONGODB_URI);
await User.create({ email: "Ana@Example.com", name: "Ana" });unique builds an index; it is not a validatorLikely follow-up: When would you skip Mongoose and use the native driver?
populate() work, and how is it different from $lookup? When would you use lean()?midYou store a reference as { type: Schema.Types.ObjectId, ref: "User" }, then Post.find().populate("author", "name") replaces each author ID with the referenced user document.
Under the hood, populate is not a server-side join. Mongoose runs the main query, collects the referenced IDs, and runs a separate query per populated path, essentially User.find({ _id: { $in: ids } }), then stitches the results together in Node. That's one extra round trip per path, not one per document, so it avoids N+1. But deep or nested populates add up, and you can't filter the parent by a populated field in the same query. For that, use an aggregation with $lookup, which does the join inside the database.
Virtual populate covers the reverse side, like a user's posts, via localField and foreignField, without storing an array of IDs on the parent.
lean() returns plain JavaScript objects instead of full Mongoose documents: no change tracking, getters, virtuals or save(), but much less memory and faster. I use it for read-only endpoints.
ref plus ObjectId declares the relationship$in query per path$lookup joins inside the serverlean() returns plain objects for fast readsLikely follow-up: How would you sort posts by their author's name?
I work from evidence, not guesses.
slowms, 100 ms by default, and the profiler (db.setProfilingLevel(1)) stores them in system.profile. db.currentOp() shows what's running now. On Atlas, the Query Profiler and Performance Advisor do this for you.$lookup on every read are design problems that indexes can't fix.bulkWrite.$indexStats; they slow writes.Only after that would I scale hardware or shard.
explain(): COLLSCAN, SORT, keys vs docs examinedLikely follow-up: What does a high ratio of documents examined to returned tell you?
The ones MongoDB itself warns about, and I see most:
{ a: 1 } is redundant if { a: 1, b: 1 } exists$lookup on every request/^ana$/i, which can't use an index efficiently. Use a collation-based case-insensitive index insteadAlso watch for deep skip() pagination and read-modify-write updates instead of atomic operators.
Likely follow-up: How do you find indexes that are never used?
Transactions work, but they aren't free:
transactionLifetimeLimitSeconds), or it's abortedTransientTransactionError label and must be retried from the startSafe usage:
withTransaction) so transient errors and UnknownTransactionCommitResult are retriedw: "majority" for commits you can't loseA standalone server doesn't support transactions, and they can't write to capped collections or the admin, config and local databases.
withTransaction retriesLikely follow-up: What should you do when a commit fails with UnknownTransactionCommitResult?
Ranged sharding splits data into contiguous ranges of the shard key. Documents with nearby keys live together, so range queries are targeted to few shards, and zones can pin ranges to regions. The weakness is a monotonically increasing key like a timestamp or ObjectId: every new document falls in the top range, so one shard absorbs all inserts while the balancer tries to catch up.
Hashed sharding shards on a hash of one field. Nearby values scatter, so writes spread evenly even for monotonic keys, and equality queries are still targeted. The cost: range queries become broadcast to all shards, and hashed fields can't be arrays.
For events keyed by time, I'd avoid a plain ranged { ts: 1 }. Options:
{ deviceId: 1, ts: 1 } if most queries are per device: spread plus targeted, time-ordered reads{ _id: "hashed" } if writes dominate and queries rarely filter by time range{ tenantId: 1, ts: "hashed" }, to keep tenant-targeted queries while spreading insertsLikely follow-up: How do zones work with ranged sharding?
MongoDB is really flexible-schema: by default documents in a collection can differ, but you can attach a validator that the server checks on every insert and update, whichever client writes.
The usual tool is $jsonSchema, based on JSON Schema draft 4 with MongoDB extensions. bsonType checks BSON types like int, date or objectId; required lists mandatory fields; properties sets per-field rules like minimum, pattern and enum. You can also use query operators in the validator. Add or change rules on an existing collection with collMod.
Two settings control strictness:
validationLevel: strict (default) checks all inserts and updates; moderate doesn't enforce rules on updates to existing documents that were already invalid, which helps when retrofitting rules onto old datavalidationAction: error (default) rejects the write; warn allows it and logs the violation; recent versions add errorAndLogExisting documents aren't re-checked when you add a validator. Users with the right privilege can pass bypassDocumentValidation for migrations.
db.createCollection("users", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["email", "createdAt"],
properties: {
email: { bsonType: "string", pattern: "^.+@.+$" },
age: { bsonType: "int", minimum: 0 },
createdAt: { bsonType: "date" }
}
} },
validationLevel: "moderate",
validationAction: "error"
});$jsonSchema with bsonType, required, propertiescollModLikely follow-up: How would you roll out a new required field without breaking old documents?
Change streams give applications a real-time feed of data changes, built on the oplog, without tailing it by hand. You call watch() on a collection, a database or the whole deployment, and can pass an aggregation pipeline to filter or reshape events. They require a replica set or sharded cluster, and they only report changes committed to a majority, so you never see events that later roll back.
Each event has an operationType (insert, update, replace, delete, invalidate and others) and documentKey. Updates carry only the changed fields unless you set fullDocument: "updateLookup", which fetches the current version of the document; that may include later changes. Since 6.0 you can enable pre- and post-images on a collection instead.
For restarts, every event's _id is a resume token. Persist it after processing, and reopen with resumeAfter (or startAfter, which also works after an invalidate). Resuming only works while that point is still in the oplog, so size the oplog for your longest expected outage.
Typical uses: cache invalidation, syncing a search index, notifications and event-driven microservices.
const cs = db.orders.watch(
[{ $match: { operationType: { $in: ["insert", "update"] } } }],
{ fullDocument: "updateLookup" }
);
while (!cs.isClosed()) {
if (cs.hasNext()) {
const ev = cs.next();
printjson({ op: ev.operationType, id: ev.documentKey._id });
saveResumeToken(ev._id); // persist it with your own storage
}
}resumeAfterLikely follow-up: How would you guarantee exactly-once processing of change events?
A capped collection is a fixed-size collection that behaves like a circular buffer. You create it with db.createCollection("log", { capped: true, size: 100000 }), where size is in bytes and an optional max limits the document count. When it's full, the oldest documents are removed automatically to make room.
It keeps documents in insertion order, so reading the newest entries is cheap, and it supports tailable cursors that stay open and stream new documents as they arrive, like tail -f. MongoDB's own replication oplog is a capped collection.
Restrictions: capped collections can't be sharded, can't be written inside transactions, can't be the target of $out, and shouldn't be updated in ways that grow documents. With concurrent writers, insertion order isn't guaranteed.
These days the manual generally recommends TTL indexes instead. Capped collections serialize writes, so they perform worse under concurrency, and TTL lets you expire by age rather than by total size. I'd choose capped only when "keep the last N megabytes" is exactly the requirement.
Likely follow-up: What is a tailable cursor?
On self-managed MongoDB, create a text index, for example createIndex({ title: "text", body: "text" }, { weights: { title: 5 } }), then query with { $text: { $search: "coffee grinder" } }.
The text index tokenizes strings, removes stop words and applies stemming for the chosen language, so "grinders" matches "grinder". The search string supports quoted phrases and -term to exclude. You can project and sort by relevance with { $meta: "textScore" }.
Limits:
"$**"$regex isn't an alternative; it can't use an index efficientlyFor real search features, MongoDB recommends MongoDB Search (Atlas Search, built on Apache Lucene) with the $search stage: fuzzy matching, autocomplete, facets and custom analyzers, plus Vector Search for semantic similarity.
$text with $search$meta: "textScore"Likely follow-up: How would you build search-as-you-type?
When you index a field that holds an array in any document, MongoDB automatically makes the index multikey: it stores one index entry per array element, all pointing to the same document. That's what makes { tags: "mongodb" } fast.
Restrictions:
{ tags: 1, categories: 1 } is fine until one document has both as arrays, and then that insert failsPerformance traps:
{ scores: { $gt: 80, $lt: 85 } } without $elemMatch, different elements may satisfy each condition, so MongoDB can't simply intersect the bounds; it may scan a wider range. With $elemMatch, it can combine them into one tight range$elemMatch allows tighter index boundsLikely follow-up: How can you tell from explain() that an index is multikey?
The most important rule: only the start of a pipeline can use indexes, so put $match, and $sort when possible, first, and filter as early as you can. On a sharded collection, a leading $match on the shard key also targets fewer shards.
The optimizer rewrites some things automatically:
$match filters ahead of $project, $addFields/$set or $sort when they don't depend on computed fields$sort + $limit into a top-k sort, merges adjacent $match, $limit and $skip stages$unwind (and a following $match) into a preceding $lookupWhat I still do by hand:
$unwind + $group just to rebuild an array; use array operators like $filter, $map and $reduceforeignField of every $lookup$merge into a summary collectionexplain() on the aggregate$match and $sort as early as possible$merge for precomputed resultsLikely follow-up: When can a $sort followed by $group with $first use an index?
$facet do? How would you return search results, a total count and filter counts in one query?mid$facet runs several sub-pipelines over the same input documents within a single stage. Each sub-pipeline's output becomes an array field in one result document. It's the natural fit for faceted search pages: one round trip returns the first page of results, the total count, counts per brand and price bands.
Things to know:
$facet to combine them$match before $facet, which can use an index; a pipeline that starts with $facet does a collection scan$facet or use stages like $out, $merge or $geoNear inside itFor very large result sets, separate queries (or Atlas Search's $searchMeta for facet counts) may be cheaper.
db.products.aggregate([
{ $match: { category: "laptops", price: { $lte: 2000 } } },
{ $facet: {
results: [{ $sort: { price: 1, _id: 1 } }, { $limit: 20 }],
total: [{ $count: "count" }],
byBrand: [{ $group: { _id: "$brand", n: { $sum: 1 } } }, { $sort: { n: -1 } }],
priceBands: [{ $bucket: {
groupBy: "$price", boundaries: [0, 500, 1000, 2001], default: "other"
} }]
} }
]);$match first$facet, $out or $merge insideLikely follow-up: Why might a $facet count be slow on a large collection?
Member options shape elections and roles:
priority: 0 means the member can never become primaryw: 1Elections use a Raft-like protocol: a candidate needs votes from a majority of voting members, and a primary that loses contact with a majority steps down.
A rollback happens when the old primary rejoins holding writes that never replicated to the new primary's side. Those writes are undone and saved as BSON files under dbpath/rollback for manual review. Writes acknowledged with w: "majority" are never rolled back.
Likely follow-up: How would you lay out a replica set across three data centers?
A chunk is a contiguous range of shard key values owned by one shard. The config servers store the mapping, and mongos uses it to route queries. The default range size is 128 MB.
The balancer runs on the config server primary. In current versions it balances by data size per collection, not chunk count: a round starts when the gap between the shards with the most and least data for a collection reaches the migration threshold, about three times the range size (384 MB by default). Chunks are split when they need to be moved.
A migration copies the documents to the recipient, catches up on changes made meanwhile, commits the new ownership on the config servers, then deletes the range from the donor asynchronously. Until cleanup, the donor's copies are orphaned documents.
A jumbo chunk exceeds the range size but can't be split, because it holds a single shard-key value. It signals low cardinality or high frequency; refining the shard key is the real fix. A balancing window can restrict migrations to off-peak hours.
Likely follow-up: Why should you stop the balancer during a filesystem snapshot backup?
I go through the manual's security checklist:
security.authorization: enabled. SCRAM is the default mechanism, with x.509 as an option, and LDAP and Kerberos in Enterpriseread or readWrite on one database, or custom roles. No shared root accountsmongod binds to localhost by default; if you widen bindIp, restrict it with firewalls, security groups or private networking. Never expose port 27017 to the internetmongod as a dedicated OS userIn application code, guard against operator injection: if a login handler passes req.body.password straight into a filter, an attacker can send { "$ne": null }. Cast inputs to expected types, or enable Mongoose's sanitizeFilter.
Likely follow-up: What is the difference between CSFLE and Queryable Encryption?
First, replication isn't a backup: an accidental deleteMany replicates to every secondary within seconds.
The main approaches:
mongodump / mongorestore: a logical BSON export. Simple and portable, and the manual positions it for small deployments: it's slow on big datasets, indexes are rebuilt on restore, and it adds load. On a replica set, --oplog captures writes during the dump so mongorestore --oplogReplay restores a consistent pointcp / rsync of data files: only with writes stoppedWhatever you choose, define your RPO and RTO, and regularly test restores. A delayed hidden member can help you recover from human error, but it doesn't replace real backups.
mongodump for small deployments; --oplog for consistencyLikely follow-up: How would you restore a single collection that was dropped an hour ago?
Atlas is MongoDB's fully managed cloud database service, running on AWS, Google Cloud and Azure. You choose a cluster tier, cloud and region, and Atlas deploys a replica set or sharded cluster for you.
What you stop doing yourself:
It also bundles platform features that don't exist in a plain mongod: Atlas Search (Lucene-based full-text search), Vector Search, triggers, Charts, multi-region and multi-cloud clusters, and Online Archive for cold data.
There's a free tier for learning (M0) and paid tiers from small shared clusters up to large dedicated ones. The trade-offs are cost at scale and less low-level control over the servers.
Likely follow-up: How does an application connect to Atlas securely?
pre("save") hook to hash passwords?midMiddleware (hooks) are functions Mongoose runs before (pre) or after (post) an operation. There are four kinds:
validate, save, init and others, where this is the documentfind, findOne, findOneAndUpdate, updateOne, deleteMany and so on, where this is the query, not a documentaggregate()insertMany and bulkWriteWith an async function you don't need to call next(). Hooks must be registered before mongoose.model() compiles the schema.
The bug: save hooks don't run for updateOne, findOneAndUpdate and other query-based updates. That update goes straight to the database, so the password is stored in plain text. Fixes: load the document, set the field and call save(); or add a matching pre("updateOne") or pre("findOneAndUpdate") hook that hashes the value in the update; or route all password changes through one service method.
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 12);
});
// Elsewhere in the codebase:
await User.updateOne({ _id: id }, { password: req.body.password });this is the query in query middlewareupdateOne or findOneAndUpdateLikely follow-up: How would you implement soft delete with query middleware?
The bucket pattern groups many small, related records into one document per bucket, usually per entity per time window, instead of one document per event. A sensor writing every second produces 86,400 tiny documents a day; bucketing turns that into a few hundred documents, with far fewer index entries, better compression and faster range reads.
This update appends a reading to the sensor's current bucket for the day, but only while count is under 200. When the bucket is full, the filter matches nothing and the upsert creates a new bucket. Its sensorId and day come from the filter's equality fields, while the $lt condition isn't copied. The bucket also keeps precomputed summary fields (count, sumTemp, first, last), so averages and ranges don't need to unwind the array. That's the computed pattern layered on top.
The count cap keeps arrays bounded. For time-series workloads, time series collections, available since 5.0, apply this bucketing internally, so today I'd try one of those first.
db.readings.updateOne(
{ sensorId: "s-17", day: ISODate("2026-09-28"), count: { $lt: 200 } },
{
$push: { samples: { t: new Date(), temp: 21.4 } },
$inc: { count: 1, sumTemp: 21.4 },
$min: { first: new Date() },
$max: { last: new Date() }
},
{ upsert: true }
);Likely follow-up: How would you query the average temperature for one sensor over a week?
Both reduce what a read has to fetch, at the cost of some duplication.
Subset pattern. A product page shows the 10 newest reviews, but popular products have thousands. Embedding them all makes the product document huge, and every product read drags them into RAM. Instead, keep all reviews in a reviews collection and embed only the latest 10 in the product. Adding a review is two writes: insert into reviews, then $push onto the product with $each, $sort and $slice: 10. The working set shrinks; "see all reviews" is a second query.
Extended reference pattern. An order references its customer by _id, but every order view needs the name and shipping address. Rather than a $lookup on each read, copy just those fields into the order: customer: { _id, name, shippingAddress }.
The trade-off is keeping duplicated data in sync. Pick fields that rarely change, or where a historical snapshot is actually correct: the address an order shipped to shouldn't change later. If updates must propagate, use a background job or a change stream.
$lookup on frequent readsLikely follow-up: How would you propagate a customer name change to existing orders?
Computed pattern: when the same value is calculated over and over on reads, store the result instead. A movie page shows the average rating and number of ratings; rather than aggregating millions of rating documents each time, every new rating also runs $inc: { ratingCount: 1, ratingSum: 5 } on the movie, and the average is derived from those two fields. For expensive but less urgent figures, a scheduled pipeline with $merge can refresh a summary collection. It suits read-heavy workloads, and the cost is extra write work plus deciding how fresh the numbers need to be.
Outlier pattern: design for the typical document and handle the rare extreme one separately. Most books have a few hundred buyers in their embedded array, but a bestseller has millions, which would break the 16 MiB limit. Keep the embedded array for everyone, cap it, and set a flag such as hasExtras: true on outliers, with overflow in separate documents. The app checks the flag and fetches the extras only when needed. The common path stays fast; the price is extra application logic.
$inc, or batch with $mergeLikely follow-up: How would you keep a computed average correct if ratings can be edited?
$, $[] and $[<identifier>].midMongoDB has three positional operators for updating array elements in place:
$: refers to the first element that matched the array condition in the query filter. The array field must appear in the filter, as items.sku does here, so this increments B's quantity$[]: the all-positional operator, which updates every element of the array$[<identifier>]: the filtered positional operator, which updates every element matching a condition you give in the arrayFilters option. The identifier must start with a lowercase letter, and each identifier needs exactly one array filter$[<identifier>] also nests, for example "grades.$[g].scores.$[s]", for arrays inside arrays, which plain $ can't handle.
All of these are atomic single-document updates, so there's no need to read the array, change it in the app and write it back. If you're matching elements by a unique key like sku, $ is simplest; use arrayFilters when several elements should change.
// { _id: 1, items: [ { sku: "A", qty: 1 }, { sku: "B", qty: 2 } ] }
// $ : the first element matched by the filter
db.carts.updateOne({ _id: 1, "items.sku": "B" }, { $inc: { "items.$.qty": 1 } });
// $[] : every element
db.carts.updateOne({ _id: 1 }, { $set: { "items.$[].reserved": false } });
// $[<identifier>] : every element matching arrayFilters
db.carts.updateOne(
{ _id: 1 },
{ $set: { "items.$[low].flag": "restock" } },
{ arrayFilters: [{ "low.qty": { $lt: 2 } }] }
);$: first element matched by the query filter$[]: all elements$[id] plus arrayFilters: all matching elementsLikely follow-up: What happens with $ if the filter matches two array elements?
Both rely on the fact that a single-document update is atomic, and that the filter is evaluated as part of that atomic write.
For the queue, findOneAndUpdate finds, modifies and returns one document in one operation. Two workers can race for the same pending job, but only one update can flip it from pending to running; the other matches the next job instead. sort picks the oldest one, and an index on { status: 1, createdAt: 1 } keeps it fast. By default it returns the document before the update; returnDocument: "after" (or returnNewDocument: true in mongosh) returns the new version. Add a lease timeout so jobs from crashed workers can be reclaimed.
For optimistic locking, keep a version field. Include the version you read in the filter and increment it in the update. If someone else saved first, matchedCount is 0, and you reload and retry. No locks are held between read and write. Mongoose offers the same behavior with its optimisticConcurrency schema option.
// Claim the oldest pending job atomically
const job = db.jobs.findOneAndUpdate(
{ status: "pending" },
{ $set: { status: "running", workerId: "w-3", startedAt: new Date() } },
{ sort: { createdAt: 1 }, returnDocument: "after" }
);
// Optimistic locking: write only if nobody changed it since we read it
const res = db.docs.updateOne(
{ _id: doc._id, version: doc.version },
{ $set: { body: newBody }, $inc: { version: 1 } }
);
if (res.matchedCount === 0) { /* conflict: reload and retry */ }findOneAndUpdate claims and returns a documentmatchedCount of 0 means a conflictLikely follow-up: How would you generate a sequential invoice number safely?
WiredTiger is MongoDB's default storage engine, the layer that actually stores documents and indexes on disk.
When sizing a server, I size RAM so indexes and hot data fit in the cache.
Likely follow-up: What happens to performance when the working set exceeds the cache?
countDocuments() and estimatedDocumentCount()?easyThey trade accuracy for speed.
countDocuments(filter) takes a query filter and actually counts matching documents, running an aggregation under the hood. It's accurate and can use indexes, but on a big result it still has to walk every matching index entry or document, so counting millions of rows isn't free.
estimatedDocumentCount() takes no filter and reads the collection's metadata, so it returns almost instantly even for huge collections. It can be off after an unclean shutdown, and on a sharded cluster it doesn't filter out orphaned documents.
The older count() helper is best avoided: without a filter it also relies on metadata and can be approximate, which confused many people. Drivers replaced it with these two explicit methods.
In practice I use estimatedDocumentCount() for dashboards like "about 12 million users", and countDocuments() with an indexed filter when the number must be exact. For paginated UIs, I avoid exact totals on every request, caching them or showing "more results" instead.
countDocuments is exact and accepts a filterestimatedDocumentCount uses metadata, no filter, fastcount() helperLikely follow-up: How would you show the total number of results for a search page cheaply?
insertMany()? How do ordered and unordered bulk writes differ?easyinsertMany() is not atomic across documents; each insert succeeds or fails on its own.
ordered: true, the server inserts in order and stops at the first error, say a duplicate key on document 50. Documents 1 to 49 stay inserted, and nothing after 50 is attemptedordered: false, it keeps going past errors and reports all failures at the end. It can also be faster, especially on sharded clusters, because operations don't wait for the previous onebulkWrite() works the same way but mixes operation types in one call: insertOne, updateOne, updateMany, replaceOne, deleteOne and deleteMany. That's much faster than individual calls because it saves round trips. The driver splits very large requests into batches (the server's maximum is 100,000 operations per batch).
On failure you get a bulk write error listing each failed operation's index and error, plus counts of what succeeded. If the whole set must be all-or-nothing, run it inside a transaction. Otherwise make the operations idempotent, for example upserts, so a retry is safe.
bulkWrite mixes operation types, fewer round tripsLikely follow-up: Why is ordered: false often faster on a sharded cluster?
A wildcard index indexes fields whose names you don't know in advance. { "$**": 1 } indexes every field in the document, and { "attributes.$**": 1 } indexes every subfield under attributes. You can include or exclude specific paths with wildcardProjection when using $**.
The typical use case is user-defined or highly variable data: product attributes that differ by category, custom fields in a SaaS app, or event payloads. One wildcard index serves queries like { "attributes.color": "red" } and { "attributes.voltage": 220 } without creating one index per attribute. Since 7.0, compound wildcard indexes such as { tenantId: 1, "attributes.$**": 1 } combine a known field with the wildcard part.
Limitations:
unique or TTL, and it can't be a shard key$exists: falseIt isn't a replacement for designed indexes on known query patterns. The attribute pattern, an array of key/value pairs with a compound index, is the older alternative.
$** for all fields or under a pathLikely follow-up: How does the attribute pattern compare to a wildcard index?
Replication is asynchronous, so a read routed to a secondary can arrive before that secondary has applied the user's write. That breaks read-your-own-writes.
Options, simplest first:
"majority" read concern and writes use "majority" write concern, MongoDB guarantees read-your-writes, monotonic reads, monotonic writes and writes-follow-reads, even when operations hit different members. The driver tracks the session's latest operation time and sends it with the next read, so the secondary waits until it has caught up to that point before answering. Drivers enable causal consistency on explicit sessions by default; the app must pass the session to every related operationfindOneAndUpdate, and show that, instead of re-readingThe guarantees apply only within one session, so across different devices or services you still need one of the other approaches.
Likely follow-up: What does maxStalenessSeconds do, and why is it not enough here?
No questions match that filter.
Prefer multiple choice? All 22 MongoDB MCQs with answers →