MongoDB Interview Questions and Answers
Last updated:
Check out 60 of the most common MongoDB interview questions, then take an AI-powered practice interview
Q1How does MongoDB's document model actually differ from relational tables, and what is BSON?
BasicFundamentals
Answer
MongoDB stores data as documents inside collections instead of rows inside tables. A document is a nested structure of field-value pairs, serialized on disk and over the wire as BSON (Binary JSON). BSON extends JSON with types JSON lacks: ObjectId, Date (64-bit millisecond timestamps), NumberLong, NumberDecimal (Decimal128), BinData, and regular expressions, and it is length-prefixed so the server can traverse documents without parsing every byte.
The practical difference from relational tables is not 'no schema', it is that the unit of atomicity and locality is the document: a single document write is always atomic, related data can live together physically, and a read can fetch an entire order with its line items in one round trip instead of a four-table join. The cost is that you design for your query patterns up front. In an RDBMS you normalize first and optimize queries later; in MongoDB you decide what the application reads and writes together, and shape documents around that.
Collections do not enforce structure by default (you can opt in with $jsonSchema validation), so two documents in the same collection may have different fields. Interviewers usually push on the trade-off: flexible schema speeds iteration but moves the enforcement burden into application code and validators, and documents that grow without bound (unbounded arrays) are a classic modeling failure. Also worth stating: MongoDB has supported multi-document ACID transactions since 4.0, so 'MongoDB is not ACID' is an outdated claim that will actively hurt you in an interview.
Key Points
- BSON adds ObjectId, Date, Decimal128, BinData, and long integers to JSON
- Single-document writes are always atomic, regardless of nesting depth
- Model around query patterns, not around normal forms
- Schema flexibility is opt-out via $jsonSchema validators
- Multi-document transactions have existed since 4.0
Q2What is an ObjectId made of, and when is it the wrong choice for _id?
BasicFundamentals
Answer
Every document must have an _id field, and if you do not supply one the driver generates an ObjectId. An ObjectId is 12 bytes: a 4-byte Unix timestamp in seconds, a 5-byte random value generated once per process, and a 3-byte counter that starts at a random value and increments per ObjectId. Two useful consequences follow.
First, ObjectIds are roughly time-ordered, so sorting on _id approximates insertion order and you can extract the creation time with objectId.getTimestamp(), which is why many teams skip a separate createdAt field (be careful: the timestamp has only second precision and comes from the client clock). Second, they are generated client-side by the driver, so inserts never wait on the server to mint a key. When is ObjectId the wrong _id?
When the document already has a natural unique key you always query by: use the SKU, the email hash, or a composite string instead, because _id is automatically indexed and reusing it saves a secondary index. In sharded clusters, a plain ObjectId used as a ranged shard key is a famous anti-pattern: because values increase monotonically, every insert lands on the same 'hot' chunk on one shard. You would hash the shard key or pick a different one. Interviewers also like the follow-up 'is ObjectId globally unique?': it is unique in practice (random machine bytes plus counter) but not cryptographically guaranteed, and it leaks creation time, so avoid exposing raw ObjectIds where enumeration or timing metadata matters.
// mongosh
const id = ObjectId();
id.getTimestamp(); // ISODate with second precision
// Natural key instead of ObjectId: saves one index
db.products.insertOne({ _id: 'SKU-BLR-10421', name: 'Filter Coffee 500g' });
// Time-range query using ObjectId's embedded timestamp
const from = ObjectId.createFromTime(
new Date('2026-01-01').getTime() / 1000
);
db.orders.find({ _id: { $gte: from } });
Q3insertOne vs insertMany: what does ordered: false change, and how do you handle partial failures?
BasicCRUD
Answer
insertOne writes a single document and returns the generated _id in insertedId. insertMany takes an array and, by default, runs ordered: with ordered inserts the server stops at the first error, so if document 3 of 100 violates a unique index, documents 1-2 are inserted, 3 fails, and 4-100 are never attempted. With ordered: false the server continues past failures and attempts every document, reporting all errors at the end in a MongoBulkWriteError whose writeErrors array lists the index and cause of each failure. Unordered inserts are also faster, because the server can batch and parallelize them without preserving sequence.
The production pattern for bulk loads is therefore: ordered: false, catch the bulk write error, inspect result.insertedCount and the writeErrors array, and decide per-error whether to retry, upsert, or log. A classic use is idempotent event ingestion: give events a deterministic _id (say, the upstream event id), insert with ordered: false, and treat E11000 duplicate key errors as 'already processed' rather than failures. Two more details interviewers check: insertMany has a 100,000 document per-batch ceiling in the server (drivers split larger arrays automatically), and inserts, like all writes, are atomic per document, so there is no 'partial document' state to worry about, only partially-applied batches.
// Node.js driver: idempotent bulk ingestion
try {
const result = await db.collection('events').insertMany(
events.map(e => ({ _id: e.eventId, ...e })),
{ ordered: false }
);
console.log('inserted:', result.insertedCount);
} catch (err) {
// MongoBulkWriteError: some inserted, some failed
const dupes = err.writeErrors.filter(w => w.code === 11000);
const real = err.writeErrors.filter(w => w.code !== 11000);
console.log('already processed:', dupes.length);
if (real.length) throw new Error(`ingest failed: ${real.length}`);
}
Q4Walk through the query operators you use daily: $gt, $in, $regex, $exists, $elemMatch, and projections.
BasicCRUD
Answer
find(filter, options) takes a filter document where top-level fields are implicitly ANDed. Comparison operators ($gt, $gte, $lt, $lte, $ne, $in, $nin) nest under the field name: { price: { $gte: 100, $lt: 500 } }. $in matches any value in a list and is index-friendly; $ne and $nin are usually not selective and tend to scan. $exists: true matches documents where the field is present (including null); to require a real value combine it with $ne: null. $regex does pattern matching, but only a case-sensitive anchored prefix like /^raz/ can use an index; an unanchored or case-insensitive regex walks every index key or document, which is a common hidden COLLSCAN in production. For arrays of subdocuments, $elemMatch forces multiple conditions to match the same array element: { items: { $elemMatch: { sku: 'X', qty: { $gt: 2 } } } } is different from two separate conditions, which could match across different elements, a distinction interviewers love.
Logical operators $or, $and, $nor take arrays of clauses; $or can use a different index per branch. The second argument controls projection: { name: 1, price: 1 } returns only those fields plus _id (suppress it with _id: 0); you cannot mix inclusion and exclusion except for _id. Projections cut network payload and can enable covered queries when all projected fields live in the index. Finally, know the difference between find (returns a cursor) and findOne (returns the document or null).
// Active listings in a price band, tags from a set,
// same-element array match, trimmed projection
db.listings.find(
{
status: 'active',
price: { $gte: 500, $lt: 2000 },
tags: { $in: ['remote', 'hybrid'] },
applicants: {
$elemMatch: { stage: 'offer', score: { $gt: 80 } }
},
},
{ _id: 0, title: 1, price: 1, 'company.name': 1 }
).sort({ price: 1 }).limit(20);
// Prefix regex can use an index; this one cannot:
db.users.find({ email: { $regex: /gmail/i } }); // scans
Q5Which update operators should you reach for ($set, $inc, $push, $addToSet, $pull), and how does upsert behave?
BasicCRUD
Answer
Updates in MongoDB should almost always use update operators rather than replacing the whole document, because operators are atomic on the server and immune to read-modify-write races. $set writes specific fields (including nested paths like 'address.city'), $unset removes them, $inc atomically adds to a number (the canonical counter and inventory-decrement primitive), $mul multiplies, $min/$max write only if the new value is smaller/larger, and $currentDate stamps a timestamp. For arrays: $push appends (with $each for multiple values, $slice to cap array length, $sort to keep it ordered), $addToSet appends only if the value is not already present, $pull removes matching elements, and $pop removes from either end. The positional operator $ updates the first array element matched by the query, arrayFilters with $[elem] updates all elements matching a condition. upsert: true turns the update into 'insert if no document matched': the new document is built from the filter's equality conditions plus the update operators, and $setOnInsert lets you set fields only when the upsert actually inserts (perfect for createdAt). Two gotchas worth volunteering: with upserts under concurrency, two racing upserts on the same key can both fail to find a document and both insert unless there is a unique index on the filter field, so upsert correctness depends on that index; and replaceOne (or passing a document without operators) silently drops every field you did not include, a bug class that has destroyed real production data.
// Atomic inventory decrement, guarded against oversell
db.inventory.updateOne(
{ sku: 'NB5-42', qty: { $gte: 1 } },
{ $inc: { qty: -1, sold: 1 }, $currentDate: { updatedAt: true } }
);
// Upsert a daily counter; createdAt only on first insert
db.metrics.updateOne(
{ page: '/jobs', day: '2026-08-11' },
{
$inc: { views: 1 },
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true }
);
// Update one element inside an array
db.orders.updateOne(
{ _id: orderId, 'items.sku': 'NB5-42' },
{ $set: { 'items.$.status': 'shipped' } }
);
Q6When do you need findOneAndUpdate instead of updateOne, and what does returnDocument: 'after' do?
BasicCRUD
Answer
updateOne applies the change and returns only counters (matchedCount, modifiedCount); it never returns the document. findOneAndUpdate atomically finds one document, applies the update, and returns the document itself, either the version before the update (default) or after it when you pass returnDocument: 'after' in the modern driver (Mongoose exposes the same thing as new: true). The atomicity is the entire point: find-then-update as two separate calls has a race window where another client can modify or claim the document in between, while findOneAndUpdate is a single server-side operation. That makes it the standard building block for job queues and lease patterns: claim the oldest unclaimed job by filtering { status: 'pending' }, setting { status: 'processing', workerId, lockedAt }, sorting by createdAt, and returning the claimed document, and no two workers can claim the same job.
Other legitimate uses: generating sequence numbers with $inc on a counters collection, and any 'update and immediately show the result' API where a second read would be wasteful or racy. The family includes findOneAndReplace and findOneAndDelete with the same semantics. Options that matter: sort (which document wins when several match), projection (trim the returned document), upsert (combine with returnDocument: 'after' to get the inserted document back). Know the cost too: findOneAndUpdate returns the full document over the wire, so for fire-and-forget updates updateOne is lighter, and for many documents at once you must use updateMany because the findAndModify family touches exactly one document.
// Worker claims the oldest pending job, atomically
const job = await db.collection('jobs').findOneAndUpdate(
{ status: 'pending', runAt: { $lte: new Date() } },
{
$set: {
status: 'processing',
workerId: process.pid,
lockedAt: new Date(),
},
},
{ sort: { runAt: 1 }, returnDocument: 'after' }
);
if (!job) {
// queue empty
}
// Monotonic sequence without gaps racing
const seq = await db.collection('counters').findOneAndUpdate(
{ _id: 'invoice' },
{ $inc: { value: 1 } },
{ upsert: true, returnDocument: 'after' }
);
Q7How do you expire and remove data: deleteMany, drop, and TTL indexes?
BasicCRUD
Answer
deleteOne and deleteMany remove documents matching a filter, document by document, writing each removal to the oplog so it replicates. db.collection.drop() removes the entire collection including its indexes in one cheap metadata operation. The distinction matters operationally: deleteMany({}) on a 100-million-document collection grinds through every document, bloats the oplog, causes cache churn and replication lag, and can take hours; drop() is near-instant. So for 'clear this table' the answer is drop and recreate indexes, not an empty-filter delete.
For data that should age out automatically, use a TTL index: db.sessions.createIndex({ lastSeen: 1 }, { expireAfterSeconds: 3600 }) tells a background thread (the TTL monitor, which wakes roughly every 60 seconds) to delete documents whose indexed date field is older than the threshold. Important behaviors to state in an interview: expiry is not instantaneous, documents linger until the next TTL pass and deletions happen in batches, so never use TTL as a security boundary; the indexed field must contain a BSON Date (a numeric timestamp will never expire); expireAfterSeconds: 0 combined with an explicit expiresAt date field gives per-document expiry times; and TTL deletes are ordinary deletes, replicated through the oplog and subject to the same write load. To change the TTL window on an existing index, use the collMod command rather than dropping and rebuilding. For very large-scale time-based retention, time series collections or a bucket-per-period design (drop last month's collection) are cheaper than millions of TTL deletes.
// Sessions vanish ~1 hour after last activity
db.sessions.createIndex(
{ lastSeen: 1 },
{ expireAfterSeconds: 3600 }
);
// Per-document expiry: index a future date, TTL of 0
db.otps.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.otps.insertOne({
phone: '98xxxxxx01',
code: '482913',
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
// Change TTL without rebuilding the index
db.runCommand({
collMod: 'sessions',
index: { keyPattern: { lastSeen: 1 }, expireAfterSeconds: 7200 },
});
Q8Embedding vs referencing: what rules do you actually use to decide?
BasicData Modeling
Answer
This is the single most common MongoDB design question, and the mature answer is a set of heuristics, not a slogan. Embed when the child data is read and written together with the parent, is bounded in size, and does not need to be queried independently: an order's line items, a user's addresses, a job posting's screening questions. Embedding buys you one-round-trip reads, single-document atomicity, and data locality in the WiredTiger cache.
Reference (store an ObjectId or key and resolve it with a second query or $lookup) when the child is unbounded, shared across parents, large, or queried on its own: a company referenced by thousands of job postings, comments on a viral post, audit logs. The failure mode interviewers probe is the unbounded embedded array: 'followers' embedded in a user document works in the demo, then a popular account hits thousands of entries, every append rewrites a huge document, the multikey index on it bloats, and eventually you slam into the 16MB document cap. The working heuristics: one-to-few (tens) embed; one-to-many (hundreds to thousands) reference from the child side (child holds parentId); one-to-squillions (unbounded) definitely child-side references.
'Data that is accessed together should be stored together' is the guiding principle, but bound it with 'documents must have a knowable maximum size'. Also mention hybrids: the extended reference pattern embeds a small, stable snapshot of the referenced entity (company name and logo inside the job document) so list pages render without a join, accepting eventual staleness that a background job or change stream reconciles.
Key Points
- Embed: read/written together, bounded, not independently queried
- Reference: unbounded, shared, large, or independently queried data
- One-to-squillions always references from the child side
- Unbounded embedded arrays are the classic 16MB time bomb
- Extended reference pattern: embed a small snapshot, reconcile async
Q9How do you create, inspect, and remove indexes, and what index types exist beyond the default?
BasicIndexing
Answer
db.collection.createIndex({ field: 1 }) builds an ascending single-field index; -1 builds descending (direction matters only for compound sort patterns, not single fields). db.collection.getIndexes() lists what exists, dropIndex(nameOrSpec) removes one, and every collection always has the mandatory _id index you cannot drop. Beyond single-field: compound indexes ({ status: 1, createdAt: -1 }) support queries on prefixes of the key pattern; multikey indexes are created automatically when you index an array field, generating one key per element; text indexes power $text search; 2dsphere indexes support geospatial queries like $near and $geoWithin; hashed indexes support hashed sharding; wildcard indexes ({ 'attrs.$**': 1 }) index arbitrary subfields for schemaless attribute bags. Index options every candidate should know: unique enforces uniqueness (E11000 on violation), partialFilterExpression indexes only documents matching a filter, expireAfterSeconds makes it a TTL index, collation enables case-insensitive uniqueness or sorting, and sparse is the legacy version of partial.
Name your indexes explicitly ({ name: 'status_created' }) so migrations and dropIndex calls are unambiguous. Operational points that show maturity: each additional index taxes every write (the server must update every index containing the changed fields), so audit with $indexStats and drop unused ones; indexes should fit in RAM alongside the working set; and hidden indexes (hideIndex/unhideIndex) let you test 'what happens if I drop this' safely, because a hidden index is still maintained but ignored by the planner, and unhiding is instant if latency spikes.
// Compound, named, partial: only index active docs
db.jobs.createIndex(
{ status: 1, postedAt: -1 },
{
name: 'active_jobs_by_date',
partialFilterExpression: { status: 'active' },
}
);
db.jobs.getIndexes();
// Usage stats since last restart: find dead indexes
db.jobs.aggregate([{ $indexStats: {} }]);
// Safely test dropping an index
db.jobs.hideIndex('active_jobs_by_date');
// ...watch latency dashboards, then either:
db.jobs.unhideIndex('active_jobs_by_date');
// or db.jobs.dropIndex('active_jobs_by_date');
Q10How do you read explain() output, and what separates COLLSCAN from IXSCAN in practice?
BasicPerformance
Answer
explain() reveals the query plan. Three verbosity modes: 'queryPlanner' (default) shows the chosen plan without running it; 'executionStats' runs the query and reports documents examined, keys examined, and time; 'allPlansExecution' additionally shows the trial runs of rejected candidate plans. The stages you read from the innermost out: COLLSCAN means a full collection scan (no usable index); IXSCAN means an index scan, with indexName and keyPattern telling you which one; FETCH means documents were loaded from the collection to check residual predicates or produce fields the index lacks; SORT means an in-memory sort because no index delivered the requested order; PROJECTION_COVERED signals a covered query.
The three numbers that matter in executionStats: nReturned, totalKeysExamined, and totalDocsExamined. A healthy selective query has all three close together; totalDocsExamined vastly larger than nReturned means the index is weakly selective or the residual filter discards most fetched documents, and a SORT stage on a hot path is a standing invitation to add a compound index matching the sort. Interviewers often present an explain where an index exists yet a COLLSCAN was chosen: valid reasons include the query not matching a prefix of a compound index, a case-insensitive $regex, a $ne/$nin predicate, type mismatch (querying a string field with a number), or the planner estimating the scan as cheaper for a low-selectivity filter. Also know that the plan cache stores winning plans per query shape, and a plan chosen for unrepresentative parameters can persist; db.collection.getPlanCache().clear() resets it while you investigate.
db.jobs.find({ status: 'active', city: 'Bengaluru' })
.sort({ postedAt: -1 })
.explain('executionStats');
// Healthy output fragment:
// winningPlan: IXSCAN { status: 1, city: 1, postedAt: -1 }
// executionStats: {
// nReturned: 240,
// totalKeysExamined: 240,
// totalDocsExamined: 240, <- 1:1:1 ratio, no SORT stage
// }
// Red flags: 'stage: COLLSCAN', 'stage: SORT',
// totalDocsExamined: 1,800,000 for nReturned: 240
Q11How do unique indexes behave, and how should application code handle the E11000 duplicate key error?
BasicIndexing
Answer
createIndex({ email: 1 }, { unique: true }) makes the server reject any insert or update that would produce two documents with the same email, failing with error code 11000 and a message like 'E11000 duplicate key error collection: app.users index: email_1 dup key'. Several behaviors trip people up. Null counts as a value: two documents both missing the field (or both null) collide, because the missing field is indexed as null; the fix is a partial unique index with partialFilterExpression: { email: { $type: 'string' } } so uniqueness applies only to documents that actually have the field.
Uniqueness is case-sensitive by default: 'Saksham@x.com' and 'saksham@x.com' are different keys unless you create the index with a case-insensitive collation ({ collation: { locale: 'en', strength: 2 } }) or normalize on write, and normalizing on write is the more robust habit. Compound unique indexes enforce uniqueness on the combination, which is how you model 'one application per user per job'. On sharded collections, a unique index is only enforceable if the shard key is a prefix of it, since each shard can only police its own range.
In application code, do not pre-check with a find (racy); attempt the insert and catch code 11000, translating it into a domain response like HTTP 409. The correct concurrency-safe 'create if absent' is a unique index plus an upsert or an insert-with-catch, never find-then-insert.
// Unique only where the field exists, case-insensitive
db.users.createIndex(
{ email: 1 },
{
unique: true,
partialFilterExpression: { email: { $type: 'string' } },
collation: { locale: 'en', strength: 2 },
}
);
// Node.js: race-safe signup
try {
await users.insertOne({ email, name });
} catch (err) {
if (err.code === 11000) {
throw new ConflictError('Account already exists');
}
throw err;
}
Q12Why do large sorts fail with 'Sort exceeded memory limit', and how do you fix it properly?
BasicPerformance
Answer
When no index can supply the requested order, the server performs a blocking in-memory sort, and that sort is capped at 100MB of RAM per query. Blow past it and the query fails with 'Sort exceeded memory limit' (error code names have varied across versions, the QueryExceededMemoryLimitNoDiskUseAllowed flavor is the modern one). There are two fixes and only one of them is usually right.
The correct fix is an index whose key pattern matches the sort: for find({ status: 'active' }).sort({ postedAt: -1 }), a compound index { status: 1, postedAt: -1 } lets the server walk index keys already in order, returning the first results immediately with bounded memory, which is why it is called a non-blocking sort. The escape hatch is allowDiskUse(true) (on find since 4.4, and via { allowDiskUse: true } on aggregate), which lets the sort spill to temporary files in _tmp; it turns a failure into a slow success, appropriate for offline analytics but a smell on request paths since disk-spilling sorts are orders of magnitude slower and still consume server resources. Related traps worth naming: sort direction matters in compound indexes ({ a: 1, b: -1 } serves sort {a:1,b:-1} and {a:-1,b:1} but not {a:1,b:1}); a sort plus limit(n) can use a bounded top-k in memory, which often keeps small paginated queries under the limit even without an index, hiding the problem until a bigger page size arrives; and aggregation $sort stages that cannot use an index count toward the same 100MB ceiling per stage.
// Failing pattern on a large collection:
db.applications.find({ jobId })
.sort({ score: -1 }) // no index on (jobId, score)
.limit(500); // may exceed 100MB sort
// Right fix: index shaped like filter + sort
db.applications.createIndex({ jobId: 1, score: -1 });
// Analytics escape hatch, not for request paths:
db.applications.aggregate(
[
{ $match: { createdAt: { $gte: ISODate('2026-01-01') } } },
{ $sort: { score: -1 } },
],
{ allowDiskUse: true }
);
Q13How do cursors work: batching, the 10-minute timeout, and iterating safely in drivers?
BasicFundamentals
Answer
find() does not return data; it returns a cursor, and documents flow to the client in batches. The first batch arrives with the initial query reply (up to 101 documents or 16MB by default), then the driver issues getMore commands to pull subsequent batches, tunable with batchSize(). This matters because behavior you might attribute to the query actually happens per batch: a document deleted after your first batch may still be yielded, and a very slow consumer holds server resources between getMores.
Idle cursors are killed by the server after 10 minutes by default, producing CursorNotFound errors in long-running jobs that fetch a batch, spend 15 minutes processing it, then ask for more. Fixes, in order of preference: process faster or in smaller batches; paginate with range queries on an indexed field so each iteration is a fresh short-lived query; or as a last resort noCursorTimeout(), which you must pair with a guaranteed cursor.close() in a finally block, because leaked immortal cursors pin resources until a server restart. In Node.js, iterate with for await (const doc of cursor) rather than toArray() for large result sets, since toArray() materializes everything in application memory and is the classic cause of Node heap crashes against big collections.
Also know count-related semantics: cursor.hasNext()/next() in mongosh, that limit(0) means no limit, and that a cursor is exhausted after iteration, so re-running requires a new find. Sharded clusters add a wrinkle: mongos merges per-shard cursors, so sort-order and getMore behavior involve all shards.
// Node.js: stream a big collection without exploding heap
const cursor = db.collection('candidates')
.find({ status: 2 })
.batchSize(1000);
for await (const doc of cursor) {
await processCandidate(doc); // keep this fast
}
// Long-running export done safely: range pagination,
// each loop is a fresh, short-lived query
let lastId = ObjectId('000000000000000000000000');
for (;;) {
const page = await db.collection('candidates')
.find({ _id: { $gt: lastId } })
.sort({ _id: 1 })
.limit(1000)
.toArray();
if (page.length === 0) break;
await exportBatch(page);
lastId = page[page.length - 1]._id;
}
Q14countDocuments vs estimatedDocumentCount vs $count: which one when?
BasicCRUD
Answer
countDocuments(filter) returns an accurate count of documents matching the filter. Internally it runs an aggregation ($match plus a counting group), so it can use indexes for the filter but must actually examine index keys or documents, meaning cost scales with the number of matches: counting 40 million matching documents is genuinely expensive no matter how good the index is. estimatedDocumentCount() takes no filter and returns the collection's total from cached metadata in constant time; it can drift after unclean shutdowns (and on sharded clusters can include orphaned documents), so treat it as an estimate, but it is the right call for dashboards showing 'total users' where being off by a handful is irrelevant. Inside aggregation pipelines, $count is a stage that outputs the number of documents reaching it, equivalent to $group with $sum: 1; use it when the count is part of a larger pipeline, or $facet when you need one page of results and the total in a single round trip.
The deprecated count() method should not appear in new code: it could return inaccurate results on sharded clusters during chunk migrations and is removed from modern drivers. The production insight interviewers fish for: exact counts over large result sets are a design smell on hot paths. Patterns that avoid them include maintaining a counter document updated with $inc alongside writes, accepting estimates, or showing '10,000+' style capped counts by querying limit(10001) and displaying overflow, which is exactly what most large listing sites do instead of paying for a real count on every page load.
// Accurate, filterable, costs O(matches)
await jobs.countDocuments({ status: 'active', city: 'Pune' });
// Instant metadata total, no filter allowed
await jobs.estimatedDocumentCount();
// Page + total in one round trip
await jobs.aggregate([
{ $match: { status: 'active' } },
{
$facet: {
page: [{ $sort: { postedAt: -1 } }, { $skip: 40 }, { $limit: 20 }],
total: [{ $count: 'n' }],
},
},
]).toArray();
// Capped count for UI: 'showing 10,000+ results'
const sample = await jobs.find(filter).limit(10001).count; // conceptually
Q15Which BSON types matter in real systems: Date, Decimal128, NumberLong, and why doubles corrupt money?
BasicData Modeling
Answer
Every number you write from mongosh or JavaScript is a 64-bit IEEE double by default, and doubles cannot exactly represent most decimal fractions: 0.1 + 0.2 !== 0.3, and a few million currency operations later your ledger is off by paise. For money, use Decimal128 (NumberDecimal('499.99') in mongosh, Decimal128.fromString in the Node driver), a 128-bit decimal floating point type that represents decimal fractions exactly and is what payment-adjacent teams are expected to name in interviews; the alternative is storing integer paise in a long. NumberLong is a true 64-bit integer, needed because JavaScript numbers lose integer precision past 2^53 - 1 (Number.MAX_SAFE_INTEGER), which bites real systems that store external IDs like Aadhaar-length numerics or Twitter-style snowflake IDs; drivers surface them as Long or BigInt depending on configuration.
Dates should be BSON Date objects (new Date() / ISODate()), which store UTC milliseconds since the epoch as a 64-bit integer: they sort correctly, work with TTL indexes, and support $gte range queries; storing ISO strings mostly works for sorting (lexicographic order matches chronological for full ISO-8601 UTC strings) but breaks with mixed timezones, wastes space, and disqualifies TTL. Also know: BinData for binary payloads and UUIDs (use the standard subtype 4 UUID representation, and be careful with the legacy subtype 3 encoding that differs across old language drivers), and that field order and type affect equality, so a query for 42 (double) will still match NumberLong(42) since numeric comparisons cross types, but string '42' matches neither.
// Money: Decimal128, never doubles
db.invoices.insertOne({
invoiceNo: 'INV-2026-04412',
amount: NumberDecimal('12499.50'),
gst: NumberDecimal('2249.91'),
issuedAt: new Date(), // BSON Date, UTC ms
});
// Aggregate money safely
db.invoices.aggregate([
{ $group: { _id: null, total: { $sum: '$amount' } } },
]);
// Node.js driver
import { Decimal128, Long } from 'mongodb';
await col.insertOne({
amount: Decimal128.fromString('12499.50'),
externalId: Long.fromString('9223372036854775001'),
});
Q16Build a basic aggregation: what do $match, $group, $sort, $project, and $limit each do?
BasicAggregation
Answer
The aggregation pipeline passes documents through an ordered array of stages, each transforming the stream. $match filters documents using the same query syntax as find and should sit as early as possible, both to shrink the stream and because a leading $match can use indexes. $group collapses documents by a key expression (_id) and computes accumulators: $sum, $avg, $min, $max, $first, $last, $push (collect values into an array), $addToSet (unique values), and $count-style { $sum: 1 }. After $group, only _id and your accumulator fields exist; everything else is gone, which surprises beginners who expect other fields to survive. $sort orders the stream (index-backed only if it precedes any transformation that changes order, effectively when it can be pushed to the front), $limit and $skip window it, and $project (or the friendlier $addFields/$set) reshapes documents: include fields with 1, compute new ones with expressions like { year: { $year: '$createdAt' } }, and reference existing fields with the '$fieldName' string syntax, a piece of syntax interviewers verify you actually know. Expressions compose: $cond, $ifNull, $concat, $multiply, $dateToString all run per document.
Pipelines execute on the server, shipping only final results to the client, which is the whole point: an aggregation replacing 'fetch everything and reduce it in Node' can cut both latency and memory by orders of magnitude. Stage order is your first optimization lever: $match then $sort then $group, with $project last unless it materially shrinks documents feeding an expensive stage.
// Average CTC and posting count per city, top 10 cities,
// active jobs from 2026 only
db.jobs.aggregate([
{
$match: {
status: 'active',
postedAt: { $gte: ISODate('2026-01-01') },
},
},
{
$group: {
_id: '$location.city',
openings: { $sum: 1 },
avgCtc: { $avg: '$ctcLpa' },
companies: { $addToSet: '$company.name' },
},
},
{ $sort: { openings: -1 } },
{ $limit: 10 },
{
$project: {
_id: 0,
city: '$_id',
openings: 1,
avgCtc: { $round: ['$avgCtc', 1] },
companyCount: { $size: '$companies' },
},
},
]);
Q17How does $lookup join collections, and what shape does its output take?
BasicAggregation
Answer
$lookup performs a left outer join inside an aggregation pipeline. The basic form takes from (the foreign collection, which must be in the same database), localField, foreignField, and as: for each input document it finds foreign documents where foreignField equals the local value and attaches them all as an array under the as name. Two output details define most follow-up questions.
First, the result is always an array, even for one-to-one relationships, so you typically follow with { $unwind: '$company' } or $addFields with $arrayElemAt/$first to flatten it; $unwind with preserveNullAndEmptyArrays: true keeps documents that matched nothing, preserving the left-join semantics that a bare $unwind would silently destroy by dropping unmatched documents, an extremely common bug. Second, if localField holds an array, equality matches against any element, which enables tag-style joins for free. Performance rules: the foreign collection needs an index on foreignField or every input document triggers a scan of the foreign collection, turning a 10K-document pipeline into a 10K-scan disaster; and $lookup runs per document after previous stages, so $match early to shrink the input before joining.
Also be able to say when not to use it: on hot request paths at high QPS, the extended reference pattern (embed the few foreign fields you render) beats a runtime join, and $lookup on sharded from collections has historically carried restrictions and performance caveats (supported in modern versions, but still a thing to test rather than assume). The more powerful pipeline form of $lookup with let/$expr is its own topic.
db.applications.aggregate([
{ $match: { status: 'shortlisted' } },
{
$lookup: {
from: 'candidates',
localField: 'candidateId',
foreignField: '_id',
as: 'candidate',
},
},
// one-to-one: flatten, but keep unmatched rows
{
$unwind: {
path: '$candidate',
preserveNullAndEmptyArrays: true,
},
},
{
$project: {
jobId: 1,
stage: 1,
'candidate.name': 1,
'candidate.experienceYears': 1,
},
},
]);
// Required for sane performance:
// db.candidates.createIndex({ _id: 1 }) exists by default;
// for non-_id joins: db.candidates.createIndex({ email: 1 })
Q18How do you enforce structure with $jsonSchema validation, and what do validationLevel and validationAction control?
BasicData Modeling
Answer
Schema flexibility does not mean schema anarchy. Collections accept a validator, usually written with $jsonSchema, that the server checks on every insert and update. You attach it at creation (db.createCollection with a validator option) or later with collMod.
The schema lists bsonType per field, required fields, enum constraints, numeric ranges (minimum/maximum), string patterns, and nested object/array schemas (items, minItems). Two knobs govern enforcement. validationAction: 'error' (default) rejects violating writes with a DocumentValidationFailure; 'warn' allows the write but logs the violation, which is the sane migration mode when adding validation to an existing dirty collection, watch logs, clean data, then flip to error. validationLevel: 'strict' (default) validates every insert and update; 'moderate' validates inserts and only those updates that touch documents which already satisfy the schema, leaving legacy documents editable without forcing an immediate cleanup. Things interviewers poke at: validation runs only on writes, so existing non-conforming documents sit untouched until modified (find offenders with a $nor query against the schema using $jsonSchema in a find filter, a neat trick worth demonstrating); validators do not apply to writes with bypassDocumentValidation from privileged roles; and validation is not a substitute for application-level validation, it is the last line of defence that keeps six microservices and one intern's script from corrupting shared collections. Since MongoDB 5.0 the error response details exactly which rule failed, which made debugging validators dramatically less painful than the old opaque failures.
db.runCommand({
collMod: 'users',
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['email', 'status', 'createdAt'],
properties: {
email: { bsonType: 'string', pattern: '^.+@.+$' },
status: { enum: [0, 1, 2, 3] },
ctcLpa: { bsonType: ['double', 'decimal'], minimum: 0 },
createdAt: { bsonType: 'date' },
},
additionalProperties: true,
},
},
validationLevel: 'moderate',
validationAction: 'warn', // flip to 'error' after cleanup
});
// Find documents that violate the schema today
db.users.find({ $nor: [{ $jsonSchema: { required: ['email'] } }] });
Q19What is a replica set, and why does every serious deployment run one even without scale problems?
BasicReplication
Answer
A replica set is a group of mongod processes holding the same data: one primary that accepts all writes, and secondaries that continuously replay the primary's oplog (a capped collection of every write operation, in order) to stay current. If the primary dies, the healthy members hold an election and promote a secondary, typically within a few seconds, and drivers discover the new primary automatically via the topology monitoring built into the connection string (mongodb://h1,h2,h3/?replicaSet=rs0). The standard production topology is three data-bearing members across failure domains; a two-member set cannot elect a primary alone (no majority), which is why an arbiter, a voting member with no data, exists, though data-bearing thirds are strongly preferred because arbiters cannot acknowledge majority writes.
The reason replica sets are non-negotiable even at small scale is not throughput, it is that half the database's feature set quietly depends on them: majority write concern (durability across node loss), retryable writes, multi-document transactions, and change streams all require a replica set; a standalone mongod supports none of that. Secondaries also serve operational roles: dedicated analytics reads via read preference tags, hidden members for backups, delayed members as a fat-finger time machine. Two things candidates get wrong: replication is asynchronous by default (a w:1 write acknowledged by the primary can be lost if it fails before replication, which is exactly what w:'majority' prevents), and secondaries are not free read scaling, they serve potentially stale data and their capacity is consumed by replaying the same writes as the primary.
Key Points
- One primary takes writes; secondaries replay the oplog
- Automatic elections + driver failover, typically seconds
- Three members across failure domains is the baseline
- Transactions, change streams, retryable writes require a replica set
- w:1 acknowledged writes can vanish on failover; w:'majority' cannot
Q20How do you connect from Node.js properly: connection string options, pooling, and the one-client rule?
BasicDrivers
Answer
The Node.js driver's MongoClient manages a monitored connection pool per server; you create one client for the whole process, call connect() once at startup, and share it everywhere. The classic beginner disaster is instantiating a new MongoClient per request (or per Lambda invocation without caching), which triggers connection storms: every client opens its own pool, handshakes, and TLS negotiation, and a modest traffic spike multiplies into thousands of connections that can starve the server (each connection costs server memory). Key URI options to know cold: maxPoolSize (default 100 per host; the real concurrency cap for that process), minPoolSize, serverSelectionTimeoutMS (default 30000; how long the driver waits to find a suitable server before erroring, the timeout you see during failovers as 'Server selection timed out'), connectTimeoutMS, socketTimeoutMS (idle socket kill; do not confuse it with query timeouts), retryWrites (default true), w for default write concern, and readPreference.
Timeouts for individual operations belong on the operation (maxTimeMS) or via the newer client-level timeoutMS, not socketTimeoutMS. In serverless environments, cache the client in module scope so warm invocations reuse it, and keep maxPoolSize small (5-10) because concurrency per instance is low while instance counts are high. For Atlas, the mongodb+srv:// scheme resolves the member list and options from DNS SRV/TXT records, so topology changes do not require config redeploys. Finally, always handle the initial connect failure explicitly and fail health checks until connected, so orchestrators do not route traffic to a pod that cannot reach the database.
// db.js: one client per process, shared everywhere
import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGO_URI, {
maxPoolSize: 50,
minPoolSize: 5,
serverSelectionTimeoutMS: 10000,
retryWrites: true,
w: 'majority',
});
let db;
export async function getDb() {
if (!db) {
await client.connect();
db = client.db('goodspace');
}
return db;
}
// Per-operation timeout, the right way
await db.collection('jobs')
.find({ status: 'active' })
.maxTimeMS(2000)
.toArray();
Q21What does Mongoose add on top of the native driver, and when is it the wrong tool?
BasicDrivers
Answer
Mongoose is an ODM (Object Document Mapper) layered over the native driver. It adds schemas with types, defaults and validators; model classes with statics and instance methods; middleware hooks (pre('save'), post('findOneAndUpdate')) for cross-cutting logic; populate() for reference resolution; virtuals; and query casting, where '42' becomes 42 and string ids become ObjectIds automatically because the schema knows the types. For CRUD-heavy product backends with many contributors, that structure earns its keep, and most Node.js MongoDB code in Indian companies is Mongoose code, so interviews assume familiarity.
The costs are real, though. Every returned document is hydrated into a Mongoose Document instance carrying change tracking and methods, which is dramatically slower and heavier than plain objects on large reads; .lean() opts out and is the single most impactful Mongoose performance habit. Query casting can mask bugs (a filter that casts to something you did not intend runs happily instead of failing).
Middleware hides behavior: a pre('save') hook that fires on save() but not on updateOne() creates split-brain business logic, a notorious source of 'the password hash hook did not run' bugs. Schema defaults and validators run in the application, not the database, so scripts using mongosh or another service bypass them entirely, which is why serious teams pair Mongoose with server-side $jsonSchema validation. Skip Mongoose when you need raw throughput, heavy aggregation work (you drop to Model.aggregate() anyway, unvalidated and uncasted), or a service so small that a schema layer is ceremony. Knowing precisely where Mongoose ends and the driver begins is a mid-level interview differentiator.
import mongoose from 'mongoose';
const jobSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true },
status: { type: String, enum: ['draft', 'active', 'closed'],
default: 'draft', index: true },
company: { type: mongoose.Schema.Types.ObjectId, ref: 'Company' },
ctcLpa: Number,
},
{ timestamps: true } // createdAt / updatedAt managed for you
);
jobSchema.pre('save', function () {
if (this.isModified('title')) this.slug = slugify(this.title);
});
const Job = mongoose.model('Job', jobSchema);
// .lean() returns plain objects: use it for all read-only paths
const jobs = await Job.find({ status: 'active' })
.select('title ctcLpa company')
.populate('company', 'name logo')
.lean();
Q22Which mongosh commands and server commands are you expected to know in an ops-flavoured round?
BasicTooling
Answer
mongosh is the modern shell (the legacy mongo shell is gone from current tooling); it is a Node.js REPL, so real JavaScript works: loops, helper functions, await. The daily vocabulary: show dbs, use appdb, show collections, db.stats() and db.collection.stats() for sizes (storageSize vs size shows compression at work; totalIndexSize tells you whether indexes fit in memory), db.collection.find().pretty() (pretty is default in mongosh), and db.hello() to see whether you are on a primary and what the topology looks like. Replica set operators live under rs.: rs.status() for member states and replication health, rs.conf() for configuration, rs.printSecondaryReplicationInfo() for lag per secondary.
Diagnostics: db.currentOp() shows running operations (filter for secs_running to catch runaways), db.killOp(opid) terminates one, db.serverStatus() dumps counters (connections, opcounters, wiredTiger cache stats), and db.getProfilingStatus()/db.setProfilingLevel() control the profiler. Administrative commands worth naming: collMod (change validators or TTL), compact, dbStats, connPoolStats, and hostInfo. For scripting, mongosh --eval 'db.jobs.countDocuments({status:1})' --quiet runs one-liners in cron jobs, and load('script.js') executes files.
Also mention the ecosystem tools around the shell since ops rounds blur into them: mongodump/mongorestore for BSON backups, mongoexport/mongoimport for JSON/CSV (never for backups, they lose type fidelity: dates and longs become strings), mongostat for live op rates, and mongotop for per-collection read/write time. Being fluent here signals you have actually operated the database, not just called it from an ORM.
// Health and sizing in 30 seconds
db.hello().isWritablePrimary;
rs.status().members.map(m => [m.name, m.stateStr, m.health]);
db.stats(1024 * 1024); // sizes in MB
db.jobs.stats().totalIndexSize;
// Find and kill a runaway query
db.currentOp({ active: true, secs_running: { $gt: 30 } })
.inprog.forEach(op =>
print(op.opid, op.secs_running, JSON.stringify(op.command)));
db.killOp(12345);
// One-liner for cron
// mongosh "$URI" --quiet --eval \
// 'print(db.jobs.countDocuments({ status: "active" }))'
Q23Atlas vs self-hosted MongoDB: what does each cost you, and what do interviewers want you to weigh?
BasicDeployment
Answer
MongoDB Atlas is the vendor's managed service on AWS, GCP and Azure (all three offer Indian regions, including AWS Mumbai ap-south-1, so data residency is usually satisfiable). Atlas gives you provisioning, patching, automated backups with point-in-time restore, monitoring and alerting, autoscaling of tier and storage, and the Atlas-only product features: Atlas Search (Lucene full-text), Atlas Vector Search, Data Federation, Online Archive, and Triggers. The free M0 tier is how most candidates should prep.
Self-hosting on EC2 or your own metal means you own version upgrades, replica set topology, backup and restore drills, oplog sizing, monitoring, security hardening, and 3 AM pages; the reward is control (instance types, kernel and filesystem tuning like XFS and disabled transparent huge pages, no per-hour managed premium) and sometimes compliance constraints that mandate it. The mature interview answer is a decision framework, not a preference: team size and ops maturity (a two-engineer startup has no business running its own replica sets), cost at scale (Atlas margins are real; very large steady workloads can be cheaper self-run if you already employ the ops capability), feature dependency (if the roadmap needs $vectorSearch or Atlas Search, self-hosting means running and syncing a separate search engine), and blast radius (Atlas gives you tested restores; your own mongodump cron that nobody has ever restored from is not a backup strategy, it is a hope). Also worth knowing: the Community edition is free and covers the core engine, while Enterprise Server adds things like in-memory storage, auditing, LDAP/Kerberos, and encryption-at-rest tooling under a commercial license.
Key Points
- Atlas: managed ops, PITR backups, autoscaling, Search and Vector Search
- Self-hosted: control and potentially lower cost, but you own every failure
- Indian regions exist on all three clouds; residency is rarely a blocker
- Search features are the strongest Atlas lock-in in 2026
- An untested backup is not a backup; managed PITR is what you pay for
Q24What are capped collections, how do they behave, and when are they the wrong answer?
BasicFundamentals
Answer
A capped collection is created with a fixed maximum size (and optionally a max document count): db.createCollection('recentEvents', { capped: true, size: 268435456, max: 100000 }). Once full, new inserts overwrite the oldest documents automatically, like a circular buffer. They guarantee insertion order on reads without an index (natural order is insertion order), support tailable cursors that behave like tail -f, staying open and yielding new documents as they arrive, and have historically powered the most important capped collection in MongoDB: the replication oplog itself.
Restrictions you must be able to list: you cannot delete individual documents; updates must not grow a document beyond its original size (they fail); you cannot shard a capped collection; and resizing requires the collMod cappedSize option in modern versions (older versions required recreating). When are they the wrong answer? Almost every place people historically used them.
For application event streams and queues, change streams plus a normal collection, or an actual queue (Kafka, SQS, Redis streams), are better: tailable cursors have awkward failure semantics and no acknowledgement model. For 'keep the last N days of logs', a TTL index expresses time-based retention directly, whereas capped collections evict by size, so a traffic spike silently shortens your retention window, which is a nasty property for audit-ish data. For high-volume metrics, time series collections are purpose-built and compress far better. The honest modern summary interviewers accept: capped collections are a legacy primitive you should recognize and can occasionally justify for a bounded, loss-tolerant, insertion-ordered buffer, but new designs rarely start there.
// 256MB circular buffer of recent events
db.createCollection('recentEvents', {
capped: true,
size: 256 * 1024 * 1024,
max: 100000,
});
db.recentEvents.isCapped(); // true
// Natural order == insertion order; newest last
db.recentEvents.find().sort({ $natural: -1 }).limit(10);
// Tailable cursor (mongosh): blocks awaiting new docs
const cur = db.recentEvents.find().tailable({ awaitData: true });
// Usually better in 2026:
// - TTL index for time-based retention
// - time series collection for metrics
// - change streams for reactive consumers
Q25Explain the ESR rule for compound index field order, and show a query it saves.
IntermediateIndexing
Answer
ESR stands for Equality, Sort, Range: in a compound index, put fields your queries match by equality first, then fields you sort by, then fields you filter with ranges. The reasoning falls out of how B-tree index keys are ordered. Equality prefixes pin the scan to one contiguous region of the index.
Within that region, keys are already ordered by the next field, so if that field matches your sort, the server streams results in order with no blocking SORT stage. A range predicate, however, selects a band of keys, and everything after the range field in the key pattern is no longer globally ordered within that band, so a sort field placed after a range field cannot avoid an in-memory sort, and a range placed before the sort poisons the ordering. Concretely, for find({ status: 'active', ctcLpa: { $gte: 10 } }).sort({ postedAt: -1 }), the right index is { status: 1, postedAt: -1, ctcLpa: 1 } (E: status, S: postedAt, R: ctcLpa), not the 'intuitive' { status: 1, ctcLpa: 1, postedAt: -1 }, which forces a SORT.
Verify with explain(): the good index shows IXSCAN with no SORT stage; totalKeysExamined will exceed nReturned (it walks some keys that fail the range) and that trade is intentional, index keys are far cheaper to examine than an in-memory sort of the result set. Corollaries interviewers expect: queries can use any prefix of a compound index, so { status: 1, postedAt: -1 } also serves find({status}) alone, and a separate single-field index on status becomes redundant; $in with few values behaves like equality, but a large $in starts behaving like a range.
// Query shape:
db.jobs.find({
status: 'active', // Equality
ctcLpa: { $gte: 10 }, // Range
}).sort({ postedAt: -1 }); // Sort
// ESR-correct index: Equality, Sort, Range
db.jobs.createIndex({ status: 1, postedAt: -1, ctcLpa: 1 });
// Wrong (range before sort => blocking SORT stage):
// db.jobs.createIndex({ status: 1, ctcLpa: 1, postedAt: -1 })
// Proof:
db.jobs.find({ status: 'active', ctcLpa: { $gte: 10 } })
.sort({ postedAt: -1 })
.explain('executionStats').queryPlanner.winningPlan;
// -> IXSCAN, no SORT stage
Q26What is a covered query, and how do you confirm you are actually getting one?
IntermediateIndexing
Answer
A covered query is answered entirely from index keys: every field in the filter, the sort, and the projection lives in the index, so the server never fetches the underlying documents. The win is substantial because it eliminates the FETCH stage, which is where random document access and cache misses live; a covered scan touches only the (dense, memory-friendly) index. Requirements are strict and mostly bitten in the projection: you must project only indexed fields and explicitly exclude _id (unless _id is part of the index), because _id is returned by default and is not in your secondary index.
Arrays spoil coverage: if any indexed field is an array (multikey index), queries on it cannot be covered. Fields that are missing in some documents are represented as null keys in the index, and coverage can behave subtly with those, so covered queries suit dense, always-present fields. Verification is explain(): in modern versions look for a PROJECTION_COVERED stage above the IXSCAN, and totalDocsExamined: 0 in executionStats, which is the definitive signal.
The practical application is designing indexes for hot list endpoints: a jobs list rendering title, city, and ctcLpa filtered by status can be served by { status: 1, postedAt: -1, title: 1, city: 1, ctcLpa: 1 } with projection { _id: 0, title: 1, city: 1, ctcLpa: 1 }, turning the highest-QPS query in the system into pure index reads. The trade-off to volunteer: fat covering indexes cost write amplification and RAM, so cover only the one or two endpoints where profiling shows FETCH dominating, not every query in the app.
db.jobs.createIndex(
{ status: 1, postedAt: -1, title: 1, city: 1, ctcLpa: 1 },
{ name: 'jobs_list_covering' }
);
const plan = db.jobs
.find(
{ status: 'active' },
{ _id: 0, title: 1, city: 1, ctcLpa: 1 } // _id: 0 is mandatory
)
.sort({ postedAt: -1 })
.limit(20)
.explain('executionStats');
// Covered when:
// plan.executionStats.totalDocsExamined === 0
// and winningPlan contains PROJECTION_COVERED -> IXSCAN
Q27How do multikey indexes work on array fields, and what limits do they impose?
IntermediateIndexing
Answer
When you index a field that holds arrays, MongoDB silently makes the index multikey: it stores one index entry per array element, so a candidate document with skills: ['node', 'mongodb', 'react'] contributes three keys to an index on skills. Queries like { skills: 'mongodb' } then resolve via the index even though the stored value is an array, which is what makes tag and skill matching cheap. The costs and limits are what interviews probe.
First, index size and write cost multiply: a 50-element array means 50 keys per document, and pushing one element rewrites index structure; huge arrays under multikey indexes are a known write-amplification trap. Second, in a compound index, at most one field may be an array. If you try to index { skills: 1, languages: 1 } and any document has arrays in both, index creation or the offending insert fails with 'cannot index parallel arrays', because the server refuses to build the cartesian product of two arrays' elements.
Third, multikey indexes cannot cover queries (the index cannot prove the full array from its scattered keys), so covered-query designs must avoid array fields. Fourth, bounds behave unintuitively: { scores: { $gt: 5, $lt: 3 } } can match a document with scores: [2, 8] because different elements satisfy different predicates; forcing both predicates onto the same element requires $elemMatch, and understanding that distinction (per-element vs per-document satisfaction) is exactly what an interviewer is testing with those puzzle questions. Check whether a plan used a multikey index in explain(): the IXSCAN stage reports isMultiKey: true and, in modern versions, multiKeyPaths naming the array fields.
db.candidates.createIndex({ skills: 1 }); // becomes multikey
db.candidates.insertOne({
name: 'Asha',
skills: ['node', 'mongodb', 'kafka'],
scores: [2, 8],
});
db.candidates.find({ skills: 'mongodb' }); // IXSCAN, isMultiKey: true
// Gotcha: matches because DIFFERENT elements satisfy each bound
db.candidates.find({ scores: { $gt: 5, $lt: 3 } });
// Same element must satisfy both -> no match for [2, 8]
db.candidates.find({ scores: { $elemMatch: { $gt: 5, $lt: 3 } } });
// Fails if both fields hold arrays in one doc:
// db.candidates.createIndex({ skills: 1, languages: 1 })
// -> 'cannot index parallel arrays [languages] [skills]'
Q28Partial indexes vs sparse indexes: what is the difference and which should you use?
IntermediateIndexing
Answer
Both shrink an index by leaving documents out, but partial indexes are the modern, general mechanism and sparse indexes are the legacy special case. A sparse index simply skips documents where the indexed field is absent. A partial index takes an arbitrary partialFilterExpression (equality, $exists, $gt, $type, $and combinations) and indexes only matching documents, which subsumes sparse ({ field: { $exists: true } }) and goes far beyond it.
The canonical wins: a partial unique index enforcing uniqueness only on non-null values (unique email, but many users without one), and hot-subset indexes like indexing only { status: 'active' } documents when 95% of a collection is archived, cutting index size and write cost dramatically because updates to archived documents never touch the index. The crucial correctness rule: the planner uses a partial index only when the query predicate provably implies the filter expression. find({ status: 'active', city: 'Pune' }) can use an index with partialFilterExpression { status: 'active' }; find({ city: 'Pune' }) cannot, and silently falls back to other indexes or a COLLSCAN, no error, just a slow query, which makes partial indexes a footgun when query builders construct predicates dynamically. Same with hint(): hinting a partial index for a non-implying query returns incomplete results in old versions or errors in newer ones, so test.
Sparse indexes have a matching trap: a sort on a sparse-indexed field may exclude documents lacking the field, changing results, one reason the docs steer everyone to partial indexes. Interview-ready summary: reach for partialFilterExpression by default; say 'sparse' only to explain legacy indexes you inherited.
// Unique referral code, but only for users who have one
db.users.createIndex(
{ referralCode: 1 },
{
unique: true,
partialFilterExpression: { referralCode: { $type: 'string' } },
}
);
// Hot-subset index: 95% of jobs are archived
db.jobs.createIndex(
{ city: 1, postedAt: -1 },
{ partialFilterExpression: { status: 'active' } }
);
// USES the partial index (predicate implies the filter):
db.jobs.find({ status: 'active', city: 'Pune' });
// Does NOT use it (no status predicate), silently:
db.jobs.find({ city: 'Pune' });
Q29Why does skip/limit pagination collapse at depth, and how does range-based pagination fix it?
IntermediatePerformance
Answer
skip(n) does not jump; it walks. The server must locate and discard n index entries (or documents) before returning any, so page 1 examines 20 keys, but page 5,000 examines 100,020, on every request, for every user paging deep. Latency degrades linearly with page depth, deep pages become an accidental DoS vector for crawlers, and on sharded clusters it is worse: mongos must over-fetch skip+limit from every shard and merge. skip/limit is fine for shallow, human-driven pagination (a UI with 20 pages), and it is the only simple option for 'jump directly to page 173'.
Range-based (keyset/cursor) pagination replaces page numbers with a position: sort by an indexed, deterministic key, return the last key of each page as an opaque cursor, and fetch the next page with a range predicate from that key, which is a pure index seek at constant cost regardless of depth. The subtlety that separates candidates: the sort key must be unique or made unique, otherwise ties at a page boundary cause skipped or duplicated rows; the standard fix is a compound sort with _id as tiebreaker ({ postedAt: -1, _id: -1 }) and a compound range predicate using $or (or the tuple-comparison trick with $expr) to resume after (postedAt, _id) precisely. The cursor you hand clients should be opaque (base64 of the key pair) so they cannot forge or misinterpret it. Infinite-scroll feeds, exports, and any API consumed by machines should be keyset-paginated; interviewers frequently ask you to write the boundary predicate, so practice it.
db.jobs.createIndex({ postedAt: -1, _id: -1 });
// Page 1
const page = await jobs
.find({ status: 'active' })
.sort({ postedAt: -1, _id: -1 })
.limit(20).toArray();
const last = page[page.length - 1];
// Next page: resume strictly after (postedAt, _id)
const next = await jobs
.find({
status: 'active',
$or: [
{ postedAt: { $lt: last.postedAt } },
{ postedAt: last.postedAt, _id: { $lt: last._id } },
],
})
.sort({ postedAt: -1, _id: -1 })
.limit(20).toArray();
Q30What does $unwind actually do, and what are its classic pitfalls with $group?
IntermediateAggregation
Answer
$unwind deconstructs an array field, emitting one document per element: a job document with five tags becomes five documents, each with tags holding a single value. It is the bridge between array-shaped storage and row-shaped analytics, typically feeding a $group that counts or aggregates per element. The pitfalls are behavioral edge cases.
First, by default $unwind drops documents where the array is missing, null, or empty, so a 'tag distribution' pipeline silently excludes untagged jobs and your percentages lie; preserveNullAndEmptyArrays: true keeps them (with the field null), and includeArrayIndex gives you the element's position when order matters. Second, cardinality explosion: unwinding a 1,000-element array multiplies the stream a thousandfold, and $unwind on two arrays in sequence produces their cartesian product, an easy way to turn a million documents into billions of intermediate ones; often you can avoid unwinding entirely with array expression operators ($filter, $map, $size, $reduce, $sum on the array directly), which is both faster and the answer interviewers hope you reach ('do you actually need to unwind?'). Third, after $unwind + $group to reassemble ($push), document order within the rebuilt array follows stream order, and equal sort keys give no deterministic ordering unless you sort explicitly. On the $group side, know the accumulator memory ceiling: $group is a blocking stage limited to 100MB unless allowDiskUse is set, and $push/$addToSet accumulators that gather unbounded values are the usual culprit blowing it, or building single documents beyond the 16MB output limit. $topN/$bottomN/$firstN accumulators in recent versions bound that memory elegantly by keeping only N per group.
// Skill demand: count + avg CTC per skill tag
db.jobs.aggregate([
{ $match: { status: 'active' } },
{
$unwind: {
path: '$skills',
preserveNullAndEmptyArrays: false, // deliberate: skip untagged
},
},
{
$group: {
_id: '$skills',
openings: { $sum: 1 },
avgCtc: { $avg: '$ctcLpa' },
// bounded memory: keep only top 3 titles per skill
topTitles: { $topN: { n: 3, sortBy: { ctcLpa: -1 },
output: '$title' } },
},
},
{ $sort: { openings: -1 } },
]);
// Often better: no unwind at all
db.jobs.aggregate([
{ $project: { skillCount: { $size: { $ifNull: ['$skills', []] } } } },
]);
Q31How does the pipeline form of $lookup with let and $expr work, and when do you need it?
IntermediateAggregation
Answer
The basic localField/foreignField $lookup only expresses single-key equality. The pipeline form generalizes it: you pass let, a set of variables bound from the outer document, and pipeline, a full aggregation run against the foreign collection per outer document, referencing outer values as $$varName inside $expr. That unlocks joins on multiple conditions (join applications to interviews on candidateId AND round), inequality joins (fetch prices effective at the order's date: effectiveFrom <= orderDate < effectiveTo), pre-filtering the foreign side ($match on status before joining), reshaping ($project inside the sub-pipeline so you attach only needed fields), and bounded joins ($limit: 5 recent reviews per product instead of all of them, something the basic form cannot do at all).
Two performance truths must accompany this in an interview. First, $expr equality inside the sub-pipeline can use foreign indexes in modern versions (this was a real limitation historically, and stray blog posts still claim pipeline-$lookup never uses indexes; the accurate 2026 statement is that simple $expr equalities are index-eligible, while complex expressions and inequalities often are not, so check explain and consider denormalizing when the sub-pipeline scans). Second, the sub-pipeline conceptually executes per outer document, so a $lookup after a $match that leaves 200 documents is fine, but after one leaving two million it is a catastrophe regardless of indexes. Also know $unionWith (concatenating another collection's documents into the stream) as the other cross-collection stage, and that $lookup can join a collection to itself for hierarchy flattening, with $graphLookup as the recursive alternative for arbitrary-depth trees.
// Attach up to 3 recent, visible reviews per company
db.companies.aggregate([
{ $match: { city: 'Bengaluru' } },
{
$lookup: {
from: 'reviews',
let: { companyId: '$_id' },
pipeline: [
{
$match: {
$expr: { $eq: ['$companyId', '$$companyId'] },
status: 'visible', // plain match: uses index
},
},
{ $sort: { createdAt: -1 } },
{ $limit: 3 },
{ $project: { _id: 0, rating: 1, text: 1, createdAt: 1 } },
],
as: 'recentReviews',
},
},
]);
// Foreign side needs: db.reviews.createIndex({ companyId: 1, status: 1, createdAt: -1 })
Q32How do $facet and $bucket power an analytics or search-results endpoint in one query?
IntermediateAggregation
Answer
$facet runs multiple sub-pipelines over the same input stream and returns one document whose fields hold each sub-pipeline's results. The textbook use is a search results page: one facet computes the paginated results ($sort, $skip, $limit), another the total count ($count), others the filter sidebars (counts by city, by experience band, by salary range), all sharing a single upstream $match so the collection is read once instead of five times. $bucket groups documents into ranges you define with boundaries (values below the first boundary or above the last go to the optional default bucket), and $bucketAuto picks boundaries for you to get roughly equal-sized buckets, ideal for salary histograms where you want '0-5, 5-10, 10-20, 20+ LPA' style bands with counts and averages per band. $sortByCount is shorthand for $group by value + $sort by count, perfect for top-tags widgets. The constraints that matter: each $facet sub-pipeline receives the same input documents but cannot use indexes internally (the input is already a stream; only stages before the $facet benefit from indexes, so put the selective $match outside and first).
The entire $facet output is one document, so the combined result must respect the 16MB document limit, which is why facets should aggregate, not enumerate. Facet sub-pipelines also cannot contain $out, $merge, or another $facet. When an interviewer asks 'how would you build the jobs search page with sidebar counts in one round trip', this exact shape, $match then $facet containing results/total/buckets, is the expected answer, and volunteering the index caveat is the difference between having read about it and having profiled it.
db.jobs.aggregate([
{ $match: { status: 'active', title: /engineer/i } }, // indexed part
{
$facet: {
results: [
{ $sort: { postedAt: -1 } },
{ $skip: 0 }, { $limit: 20 },
{ $project: { title: 1, city: 1, ctcLpa: 1 } },
],
total: [{ $count: 'n' }],
byCity: [{ $sortByCount: '$city' }, { $limit: 8 }],
ctcBands: [
{
$bucket: {
groupBy: '$ctcLpa',
boundaries: [0, 5, 10, 20, 40],
default: '40+',
output: { count: { $sum: 1 } },
},
},
],
},
},
]);
Q33Write concern: what do w:1, w:'majority', and j actually guarantee, and what changed in the defaults?
IntermediateReplication
Answer
Write concern defines when the server acknowledges a write. w:1 acknowledges once the primary has applied it in memory; if the primary crashes before replication, a failover elects a secondary that never saw the write, and when the old primary rejoins it rolls that write back, so an acknowledged w:1 write can silently vanish. w:'majority' acknowledges only after a majority of voting members have the write durably (majority commit implies journaling on those members in modern versions), which survives any single-node failure and any election, because a new primary must be elected from the majority that has the write. j:true additionally requires the on-disk journal flush before acknowledgment at the specified nodes. w:0 is fire-and-forget and belongs nowhere near data you care about. The default changed in MongoDB 5.0: implicit write concern became w:'majority' (previously w:1), a fact interviewers use to filter people whose knowledge fossilized at 4.x. wtimeout bounds how long the write waits for the concern to be satisfied; on timeout you get an error, but crucially the write may still complete afterwards, so wtimeout errors mean 'unconfirmed', not 'failed', and naive retry logic can double-apply non-idempotent writes (retryable writes handle the single-retry case for you, but application-level retries of unacknowledged writes need idempotency keys). Trade-off framing for production: w:'majority' costs one replication round trip of latency and is the right default for anything transactional; w:1 is defensible for high-volume, loss-tolerant telemetry. Per-operation override lets you mix both in one app, and write concern also applies to transactions at commit time, where 'majority' is effectively mandatory for sane semantics.
// Payment-ish write: survive failover, bounded wait
await orders.insertOne(
{ userId, amount: Decimal128.fromString('4999.00'), status: 'paid' },
{ writeConcern: { w: 'majority', wtimeout: 5000 } }
);
// High-volume telemetry: accept small loss window
await events.insertOne(
{ type: 'page_view', path: '/jobs', at: new Date() },
{ writeConcern: { w: 1 } }
);
// wtimeout error != write failed:
// WriteConcernError { code: 64, codeName: 'WriteConcernTimeout' }
// The write may still be replicating. Retries must be idempotent.
Q34Read concern and read preference: how do they differ, and how do stale reads happen?
IntermediateReplication
Answer
They answer different questions and candidates constantly conflate them. Read preference chooses WHICH member serves the read: primary (default), primaryPreferred, secondary, secondaryPreferred, nearest, optionally constrained by tag sets (route analytics to a tagged member) and maxStalenessSeconds (refuse members lagging beyond a bound). Read concern chooses WHAT the read is allowed to see on that member: 'local' (default; whatever that node has applied, including writes that may later be rolled back), 'majority' (only majority-committed data, guaranteed never to roll back), 'linearizable' (primary-only, reflects all majority-committed writes ordered in real time, at meaningful latency cost, for the rare 'read the absolute truth' operation), 'snapshot' (inside transactions), and 'available' (sharding edge cases, can return orphans).
Stale reads happen in two distinct ways worth articulating separately. Reading from secondaries with any read concern can miss recent writes simply because replication is asynchronous: the write-then-read-your-profile flow lands on a secondary that has not replayed the write yet, the classic 'I updated but nothing changed' bug from routing reads to secondaries without causal consistency. And reading 'local' on a primary during a network partition can show writes that will be rolled back when that primary discovers it lost its majority (a stale primary can serve reads while deposed).
The production recipe most teams converge on: primary reads with 'majority' read concern for user-facing state, causal sessions when read-your-own-writes across operations matters, and secondaryPreferred with maxStalenessSeconds only for analytics and search indexing paths that tolerate lag. Also know these compose with write concern: w:'majority' writes + readConcern 'majority' reads give you the no-rollback world most people assume they already live in.
// Analytics reads: offload to secondaries, bounded staleness
const analyticsDb = client.db('goodspace', {
readPreference: 'secondaryPreferred',
readConcern: { level: 'majority' },
});
// mongosh: tag-routed reporting reads
db.getMongo().setReadPref('secondary', [{ workload: 'analytics' }]);
// User-facing truth: primary + majority
await users.findOne(
{ _id: userId },
{ readConcern: { level: 'majority' } }
);
// URI form:
// mongodb://.../?readPreference=secondaryPreferred&maxStalenessSeconds=90
Q35What happens during a replica set election, and what does your application experience?
IntermediateReplication
Answer
MongoDB replication uses a Raft-derived consensus protocol. Members heartbeat each other every 2 seconds; if secondaries lose contact with the primary for electionTimeoutMillis (default 10 seconds), an eligible secondary calls an election, votes are cast, and a candidate holding a majority of votes becomes primary, in practice failover usually completes within a few seconds to low tens of seconds. Eligibility knobs: priority (higher is preferred; priority: 0 members can never become primary, used for analytics or DR-site members), votes (at most 7 voting members of the maximum 50), and hidden/delayed members which are priority 0 by design.
The old primary, when it reconnects, steps down, and any writes it accepted that never reached the majority are rolled back to files under the rollback directory, which is precisely the w:1 durability hole. What the application sees during the window: writes fail (NotWritablePrimary / 'not master' class errors) or block in server selection; the driver's topology monitor detects the new primary and routes to it. Retryable writes (default on) make the driver retry qualifying single-document writes exactly once against the new primary, absorbing most election blips invisibly, and the retryReads counterpart does the same for reads.
Application-level requirements interviewers look for: idempotent write design anyway (retryable writes cover one retry of specific operation types, not multi-statement flows), timeouts plus circuit breaking so requests fail fast instead of piling onto serverSelectionTimeoutMS, and health checks that distinguish 'election in progress' (transient, seconds) from 'majority lost' (stuck read-only until quorum returns). Operationally: rs.stepDown() triggers controlled failover for maintenance, and 'why did we have an election at 3 AM' usually traces to VM stalls, saturated disks, or network partitions visible in rs.status() timestamps.
Key Points
- Heartbeats every 2s; election after ~10s of lost primary contact
- priority: 0 members never become primary; max 7 voting members
- Un-replicated w:1 writes are rolled back after failover
- Drivers auto-discover the new primary; retryable writes absorb blips
- Majority loss = read-only cluster, not an election
Q36Multi-document transactions: how does the API work, and what limits make them a last resort?
IntermediateTransactions
Answer
Since 4.0 (replica sets) and 4.2 (sharded clusters), MongoDB supports multi-document ACID transactions. The API is session-based: start a ClientSession, then either manage explicitly (startTransaction / commitTransaction / abortTransaction) or, strongly preferred, use the withTransaction callback helper, which handles retries of transient errors for you. Every operation inside must pass the session, forgetting to pass it is the number one transaction bug, the operation silently runs outside the transaction and commits independently.
Transactions read at 'snapshot' isolation and should commit with w:'majority' (the helper's default) for the guarantees to mean anything. The limits that justify 'last resort': a transaction must complete within transactionLifetimeLimitSeconds (default 60), holds locks on the documents it writes, and aborts with a WriteConflict if another operation modifies one of those documents first, meaning hot-document contention turns into an abort-and-retry storm rather than graceful queueing. Best practice caps modified documents around 1,000 per transaction; oplog entry limits and cache pressure punish larger ones.
DDL inside transactions is restricted (no creating collections in older versions; relaxed since 4.4 but still constrained), and you cannot touch capped collections or write to system collections. The deeper interview point: needing transactions everywhere signals a relational schema forced into MongoDB. The document model's design goal is that entities modified together live in one document, atomically updated for free.
Legitimate transaction uses are genuinely cross-document invariants: double-entry ledger postings, transferring an application between two jobs, uniqueness workflows spanning collections. State that hierarchy (model first, single-document atomicity second, transactions third) and you have answered the question the interviewer actually asked.
const session = client.startSession();
try {
await session.withTransaction(
async () => {
// EVERY op must carry { session }
const src = await wallets.findOneAndUpdate(
{ userId: from, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session, returnDocument: 'after' }
);
if (!src) throw new Error('insufficient balance');
await wallets.updateOne(
{ userId: to },
{ $inc: { balance: amount } },
{ session }
);
await ledger.insertOne(
{ from, to, amount, at: new Date() },
{ session }
);
},
{ readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' } }
);
} finally {
await session.endSession();
}
Q37How do change streams work, and how do you build a resumable consumer with them?
IntermediateChange Streams
Answer
Change streams let you subscribe to data changes without tailing the oplog yourself: collection.watch(), db.watch(), or client.watch() return a cursor of change events (operationType insert/update/replace/delete/invalidate plus DDL-ish events, documentKey, and for inserts the fullDocument). They require a replica set or sharded cluster (the mechanism rides on majority-committed oplog entries, so you only ever see durable changes, never rolled-back ones). For updates you get the delta (updateDescription with updatedFields/removedFields) by default; fullDocument: 'updateLookup' fetches the current post-update document (racy under rapid updates, it is a lookup at read time, not the document as-of the event), while recent versions support fullDocument: 'required'/'whenAvailable' and fullDocumentBeforeChange via changeStreamPreAndPostImages enabled per collection with collMod, giving true before/after images.
Resumability is the production heart of the feature: every event carries _id, a resume token; persist the token after processing each event (atomically with the side effect, ideally), and on restart pass resumeAfter (or startAfter to survive invalidate events) to continue exactly where you stopped. The token is only replayable while the corresponding oplog window exists, so a consumer that is down longer than your oplog retention cannot resume and must re-sync, which is why oplog sizing appears in the same conversation. watch() takes an aggregation pipeline to filter server-side ($match on operationType or document fields) so you are not streaming the firehose. Real uses: cache invalidation, syncing to OpenSearch/analytics, outbox-less event publication, audit trails. Contrast with Kafka honestly: change streams give at-least-once with you managing checkpoints, no fan-out consumer groups, no long retention; they feed Kafka well but do not replace it.
const pipeline = [
{ $match: { operationType: { $in: ['insert', 'update'] },
'fullDocument.status': 'active' } },
];
const token = await checkpoints.findOne({ _id: 'jobs-sync' });
const stream = db.collection('jobs').watch(pipeline, {
fullDocument: 'updateLookup',
...(token ? { resumeAfter: token.value } : {}),
});
for await (const event of stream) {
await indexIntoOpenSearch(event.fullDocument);
// checkpoint AFTER the side effect: at-least-once
await checkpoints.updateOne(
{ _id: 'jobs-sync' },
{ $set: { value: event._id, at: new Date() } },
{ upsert: true }
);
}
Q38The 16MB document limit and unbounded arrays: how does the bucket pattern save you?
IntermediateData Modeling
Answer
Every BSON document is capped at 16MB, and long before you hit it, large documents hurt: the full document travels the wire on reads (absent projection), rewrites cost more, multikey index maintenance on giant arrays amplifies writes, and WiredTiger cache fills with bloat. The failure sequence is always the same: an array that grows with usage (messages in a chat, readings from a sensor, events on a user) embedded in one document, fine in staging, fatal at month six. When an insert finally fails with 'BSONObj size ... is invalid' or 'Resulting document after update is larger than 16777216', you are firefighting a schema migration in production.
The bucket pattern pre-empts this by splitting the growth dimension into bounded documents: instead of one document per sensor with an endless readings array, one document per sensor per hour holding that hour's readings plus a count; instead of one conversation document, one document per conversation per 50 messages. Writers append with an upsert keyed on the bucket boundary, using $push plus $inc on the count, and either a filter on count or a $slice cap to enforce the bound. Readers fetch a time range by scanning few bucket documents instead of thousands of tiny per-event documents, which also collapses index size (one index entry per bucket, not per event) and improves cache locality, the same reasoning that made MongoDB build time series collections, which are essentially a managed, compressed bucket pattern.
Related escape hatches to name: GridFS for binary blobs over 16MB (chunks collection + files metadata), and the outlier pattern when only rare documents breach the bound. The interview framing: 16MB is not the problem, unbounded growth is; the limit just decides when you notice.
// One bucket per sensor per hour, bounded at 240 readings
await readings.updateOne(
{
sensorId,
hour: new Date('2026-08-11T14:00:00Z'),
count: { $lt: 240 }, // bound enforcement
},
{
$push: { samples: { t: new Date(), temp: 31.4 } },
$inc: { count: 1 },
$setOnInsert: { sensorId, hour: bucketHour },
},
{ upsert: true }
);
// Index: { sensorId: 1, hour: 1 } serves range reads cheaply
// Read a day = ~24 documents, not 5,760 event docs
await readings.find({
sensorId,
hour: { $gte: dayStart, $lt: dayEnd },
}).toArray();
Q39Beyond embed-vs-reference: explain the computed, extended reference, and outlier patterns.
IntermediateData Modeling
Answer
These named patterns are the vocabulary of intermediate-to-senior MongoDB design rounds. Computed pattern: precompute at write time what you would otherwise aggregate at read time. A jobs platform showing 'applications: 1,243' on every listing should not run countDocuments per render; it $incs an applicationCount on the job document inside the application-submission path (or reconciles asynchronously via change streams for looser accuracy).
You trade write-path work and a reconciliation job for removing an aggregation from the hottest read path; read-heavy ratios (1000:1 reads:writes) make this trade obviously correct. Extended reference: instead of a bare ObjectId reference forcing a $lookup on every list render, embed a small, stable projection of the referenced entity, the company's name, logo, and city inside each job document, while the company collection stays the source of truth. The embedded copy goes stale when the company renames; you accept that and reconcile with an updateMany fanout (or change-stream consumer) on the rare rename, because renames are rare and list renders are constant.
Choose fields that are small and change seldom. Outlier pattern: design for the 99.9% and flag the exceptions. A candidate document embeds its applications, and the rule works for everyone except a few power users with thousands; instead of redesigning around the outliers, cap the embedded array, set hasOverflow: true on the rare monsters, and spill their tail into an overflow collection consulted only when the flag is set. Others worth name-dropping if asked for more: schema versioning (a schemaVersion field so readers handle both shapes during migrations), attribute pattern (key-value array + one multikey index replacing hundreds of sparse field indexes), and subset (keep the 10 most recent reviews embedded, full history elsewhere).
Key Points
- Computed: $inc counters at write time; reconcile async; kill hot-path aggregations
- Extended reference: embed name/logo snapshots; reconcile on rare source changes
- Outlier: model for the 99.9%, flag and overflow the exceptions
- Attribute pattern: k/v array + multikey index for product-catalog style data
- schemaVersion field makes rolling migrations tractable
Q40Updates with aggregation pipelines: what can updateOne with [{ $set }] do that operators cannot?
IntermediateCRUD
Answer
Since MongoDB 4.2, update commands accept an aggregation pipeline (an array of stages, limited to $set/$addFields, $unset/$project, and $replaceRoot/$replaceWith) instead of an operator document. The unlock is that pipeline updates can reference the document's own current values in expressions, which classic operators cannot: set one field from another ({ $set: { total: { $multiply: ['$price', '$qty'] } } }), conditionally update ({ $set: { status: { $cond: [{ $gte: ['$score', 80] }, 'shortlisted', '$status'] } } }), derive from dates, concatenate strings, transform arrays with $map/$filter, or normalize a field's type in place. Before 4.2 these required read-modify-write round trips (racy) or $out-based rebuilds; now they are single atomic server-side operations, and they work in updateOne, updateMany, findOneAndUpdate, and bulkWrite.
This is the standard tool for in-place migrations: updateMany({}, [{ $set: { name: { $concat: ['$firstName', ' ', '$lastName'] } } }, { $unset: ['firstName', 'lastName'] }]) rewrites a collection without exporting it. Differences from operator updates that interviewers probe: there is no direct $inc (write { $set: { views: { $add: ['$views', 1] } } }, noting $add treats a missing field as null yielding null, so wrap with $ifNull); array mutation is expression-style ($concatArrays to append, $filter to remove) rather than positional, and there is no arrayFilters equivalent, you rebuild the array with $map; and upserts with pipeline updates build the new document only from the pipeline (plus filter equality fields), so $setOnInsert semantics need $cond trickery. Each matched document is still updated atomically; updateMany as a whole remains non-atomic across documents, pipeline or not.
// Conditional status + derived field, one atomic op per doc
db.applications.updateMany(
{ jobId },
[
{
$set: {
totalScore: { $add: [
{ $ifNull: ['$testScore', 0] },
{ $ifNull: ['$interviewScore', 0] },
] },
},
},
{
$set: {
stage: {
$cond: [{ $gte: ['$totalScore', 140] }, 'offer', '$stage'],
},
},
},
]
);
// In-place migration: merge name fields, drop the old ones
db.users.updateMany({ fullName: { $exists: false } }, [
{ $set: { fullName: { $concat: ['$firstName', ' ', '$lastName'] } } },
{ $unset: ['firstName', 'lastName'] },
]);
Q41How does WiredTiger manage memory and compression, and why does 'MongoDB eats all my RAM' come up?
IntermediateStorage
Answer
WiredTiger, the default storage engine since 3.2, keeps an internal cache whose default size is the larger of 50% of (RAM minus 1GB) or 256MB, tunable via storage.wiredTiger.engineConfig.cacheSizeGB. Data in this cache is uncompressed B-tree pages; on disk, collections are block-compressed (snappy by default; zstd available in modern versions and commonly chosen now for its better ratio at acceptable CPU; zlib the older heavy option) and indexes use prefix compression. That is why db.collection.stats() shows size (uncompressed data size) much larger than storageSize (on-disk), and why 'the working set should fit in RAM' really means 'in the WiredTiger cache plus OS page cache', the remaining RAM is not wasted, the filesystem cache holds compressed blocks and serves reads that miss the WT cache without touching disk.
'MongoDB eats all my RAM' is therefore usually the system working as designed; the pathological version is cache pressure: watch db.serverStatus().wiredTiger.cache, specifically 'bytes currently in the cache' against 'maximum bytes configured', and the dirty percentage. Application threads begin taking on eviction work when the cache exceeds its eviction targets (around 95% full, or 20% dirty, by default), which manifests as sudden latency cliffs under write bursts, the signature is application operations spending time in eviction rather than their own work. Never set the cache above defaults on a box shared with other services, and remember each connection also costs memory (roughly 1MB stack per connection historically), so 10,000 connections is a memory story of its own. Other WiredTiger facts worth having: document-level concurrency via MVCC (writers do not block readers; concurrent writes to the same document retry internally on write conflict), checkpoints every 60 seconds flushing a consistent snapshot, and the journal (write-ahead log) covering the gap between checkpoints for durability.
Key Points
- Cache default: max(50% of RAM - 1GB, 256MB); uncompressed pages inside
- snappy default / zstd modern choice on disk; prefix compression for indexes
- size vs storageSize in stats() = compression visible
- Latency cliffs when app threads get drafted into cache eviction
- MVCC document-level concurrency; checkpoints ~60s + journal
Q42How do you find slow queries in production: profiler, slowms, and the logs?
IntermediatePerformance
Answer
Three overlapping tools, and knowing which to reach for is the skill. First, the free one: mongod already logs every operation slower than slowms (default 100ms) to its log file as structured JSON in modern versions, including the filter shape, planSummary (COLLSCAN vs IXSCAN and which index), docsExamined, keysExamined, nreturned, and durationMillis. Most real slow-query hunts are grep/jq sessions over these entries, no configuration needed.
Second, the profiler: db.setProfilingLevel(1, { slowms: 50 }) additionally writes slow operations as documents into the capped system.profile collection in that database (level 2 records everything and is strictly for short debugging bursts on non-production traffic, its overhead and noise are real). Because system.profile holds queryable documents, you can aggregate it: group by query shape, sum durations, and rank which endpoint is burning the database, which is much harder with raw logs. The sampleRate option ({ sampleRate: 0.1 }) profiles a fraction of slow ops on busy clusters.
Profiling level and slowms are per-mongod and per-database (setProfilingLevel is per-DB; slowms applies to logging process-wide), and system.profile is capped small by default, so it is a magnifying glass, not a monitoring system. Third, the continuous layer: Atlas Query Profiler / Performance Advisor visualize the same data and suggest indexes, and self-hosted equivalents (mtools' mloginfo, or shipping logs to your observability stack) fill that role off Atlas. What you are looking for in any of these: high docsExamined-to-nreturned ratios (missing or weak index), COLLSCAN planSummaries on hot shapes, writeConflicts, and queues. The interview-grade workflow: alert on p99, pull the offending shapes from logs or system.profile, reproduce with explain('executionStats'), fix the index or the query, verify the ratio collapses.
// Profile ops slower than 50ms in this database
db.setProfilingLevel(1, { slowms: 50 });
db.getProfilingStatus(); // { was: 1, slowms: 50, ... }
// Rank slow query shapes from system.profile
db.system.profile.aggregate([
{ $match: { op: 'query', millis: { $gt: 50 } } },
{
$group: {
_id: { ns: '$ns', plan: '$planSummary' },
count: { $sum: 1 },
avgMs: { $avg: '$millis' },
avgScanned: { $avg: '$docsExamined' },
},
},
{ $sort: { count: -1 } },
{ $limit: 10 },
]);
// Turn it off when done
db.setProfilingLevel(0);
Q43What happens when you build an index on a busy production collection, and how do you do it safely?
IntermediateIndexing
Answer
History first, because interviewers use it to date your knowledge: before 4.2, foreground index builds took a database-level lock that blocked everything (the era of 'we built an index and took the site down'), and background: true built slower, unlocked, but produced less efficient index structures. Since 4.2 there is a single hybrid ('optimized') build method: the collection stays available for reads and writes for the duration, with the build tracking concurrent side-writes and applying them, taking brief exclusive locks only at the start and end. The background flag is ignored.
That does not make builds free: they consume CPU, disk I/O and cache, scan the entire collection, and on replica sets the build happens on all members, by default starting simultaneously and committing when a majority have finished (the commit quorum, tunable via the commitQuorum option to createIndex). A build that would hurt latency SLOs on a saturated cluster is still worth scheduling off-peak or executing as a rolling build: take one secondary out (restart standalone or use maintenance mode), build there, rejoin, let it catch up, repeat, and step down the primary last, Atlas automates exactly this as 'rolling index build'. Operational handles: db.currentOp() shows in-progress builds (look for 'IndexBuild' messages with progress), dropIndex during a build aborts it in modern versions, and a build that crashed mid-way resumes on restart. Also worth stating: index builds on empty or small collections are trivial, so creating indexes in migrations before backfilling data sidesteps the whole conversation, and createIndexes accepts multiple specs in one command sharing a single collection scan.
// Modern build: online, hybrid method (background flag ignored)
db.applications.createIndex(
{ jobId: 1, stage: 1, updatedAt: -1 },
{ name: 'apps_by_job_stage' }
);
// Watch progress
db.currentOp({ 'command.createIndexes': { $exists: true } })
.inprog.forEach(op => print(op.msg)); // 'Index Build: scanning ...'
// Control replica-set commit quorum (e.g. do not wait for
// a lagging analytics secondary)
db.runCommand({
createIndexes: 'applications',
indexes: [{ key: { city: 1 }, name: 'city_1' }],
commitQuorum: 'majority',
});
Q44Mongoose in production: middleware surprises, populate vs $lookup, and why lean() matters.
IntermediateDrivers
Answer
Three clusters of Mongoose behavior separate people who have shipped it from people who have read its README. Middleware surprises: pre('save') runs on document.save() and Model.create(), but NOT on updateOne, findOneAndUpdate, or bulkWrite, those have their own query middleware where 'this' is the Query, not the document, and the document being updated is not loaded. The archetypal bug is a password-hashing pre('save') hook bypassed by a findOneAndUpdate password reset, storing plaintext.
Either register matching query middleware, funnel all writes through save(), or move invariants into the service layer. Similarly, query middleware on find does not fire for findOne by default in some configurations, and updateMany middleware receives no per-document context at all. populate vs $lookup: populate is client-side joining, Mongoose collects the foreign keys from the first result set and issues additional queries (batched, one per populated path), then stitches in memory. That is N+1-shaped but usually two round trips, fine for page-sized results, and it respects Mongoose schemas. $lookup joins on the server in one round trip and is the right tool inside aggregations or for large joins, but returns plain objects outside schema casting.
Know that populate cannot be used inside .aggregate() pipelines, and deep populate multiplies round trips. lean(): every hydrated Mongoose document carries getters, change tracking, and prototype weight; on a 10,000-document read this is real CPU and memory (commonly several times slower than lean). .lean() returns plain JS objects, and the rule of thumb, lean() on every query that does not subsequently call save(), is arguably the highest-ROI line-level optimization in Mongoose codebases. Finish with versioning: the __v field and optimistic concurrency (optimisticConcurrency: true) protect against lost updates on save(), something raw driver code must build manually.
// The classic middleware hole:
userSchema.pre('save', async function () {
if (this.isModified('password'))
this.password = await argon2.hash(this.password);
});
// This BYPASSES the hook above -> plaintext password stored:
// await User.findOneAndUpdate({ _id }, { password: newPassword });
// Fix: matching query middleware
userSchema.pre('findOneAndUpdate', async function () {
const update = this.getUpdate();
if (update.password) {
update.password = await argon2.hash(update.password);
this.setUpdate(update);
}
});
// Read path: lean + select + batched populate
const apps = await Application.find({ jobId })
.select('candidateId stage score')
.populate('candidateId', 'name experienceYears')
.lean();
Q45When do you reach for bulkWrite, and how do ordered and unordered bulks differ in failure behavior?
IntermediateCRUD
Answer
bulkWrite sends a heterogeneous batch of operations (insertOne, updateOne, updateMany, replaceOne, deleteOne, deleteMany entries) in one command, amortizing network round trips and server command overhead. The canonical use cases: syncing an external feed where each record is an upsert (thousands of updateOne + upsert entries instead of thousands of round trips), applying a computed migration in batches, and event-driven fanouts that accumulate changes and flush periodically. Semantics mirror insertMany: ordered: true (default) executes sequentially and stops at the first error, everything before it is applied and stays applied (there is no rollback; a bulk is not a transaction), everything after is skipped; ordered: false attempts every operation regardless of individual failures and reports all errors together in MongoBulkWriteError, and additionally allows the server to parallelize, making unordered meaningfully faster on large batches.
The result object (insertedCount, matchedCount, modifiedCount, upsertedCount, upsertedIds, deletedCount) plus writeErrors gives per-operation accounting; upsertedIds keyed by batch index is how you learn which upserts inserted. Practical numbers and habits: drivers split bulks over the 100,000-ops or 16MB-message boundaries automatically, but application-side batches of 500-2,000 keep memory and retry blast radius sane; unordered plus idempotent operations (upserts keyed on a unique index, deletes, $set to absolute values) is the resilient combination, because you can naively retry the whole batch after a network error; ordered bulks with non-idempotent ops ($inc, $push) after partial failure require inspecting writeErrors and resuming from the failure index precisely. One more distinction interviewers like: bulkWrite batches operations against ONE collection; for multi-collection atomicity you are back to transactions, and a bulk inside a transaction is legal and common for keeping transactions short.
// Nightly feed sync: idempotent unordered upserts
const ops = feed.map(row => ({
updateOne: {
filter: { externalId: row.id },
update: {
$set: { title: row.title, ctcLpa: row.ctc, syncedAt: new Date() },
$setOnInsert: { createdAt: new Date() },
},
upsert: true,
},
}));
for (let i = 0; i < ops.length; i += 1000) {
const res = await jobs.bulkWrite(ops.slice(i, i + 1000), {
ordered: false,
});
metrics.increment('feed.upserts', res.upsertedCount);
metrics.increment('feed.updates', res.modifiedCount);
}
Q46What are time series collections, and what do timeField, metaField, and granularity control?
IntermediateTime Series
Answer
Time series collections, introduced in 5.0 and substantially improved since, are purpose-built storage for measurements over time: metrics, sensor readings, price ticks, event telemetry. You create one with db.createCollection('metrics', { timeseries: { timeField: 'ts', metaField: 'meta', granularity: 'minutes' } }). timeField names the BSON Date every measurement must carry. metaField names the field holding the series identity (sensor id, host, symbol, tags object); measurements sharing metaField values get physically bucketed together. granularity (seconds/minutes/hours, or explicit bucketMaxSpanSeconds/bucketRoundingSeconds in newer versions) tells the server the expected arrival cadence so it sizes buckets well. Under the hood the collection is a view over an internal system.buckets collection implementing an optimized bucket pattern: many measurements per stored document, columnar-ish compression within buckets, which yields dramatic storage reduction versus one-document-per-reading and faster time-range scans; you interact with it as if each measurement were a document, and inserts/queries look completely normal.
Constraints to know honestly: it is insert-optimized, updates and deletes were initially prohibited and remain restricted (modern versions allow limited forms, and TTL-style expiry via expireAfterSeconds on the collection is first-class); the collection cannot be sharded in older versions (supported in newer ones with constraints); no unique indexes on measurements; and secondary indexes are supported on metaField and timeField combinations (compound metaField+timeField indexes are the standard pattern). Query-wise, $match on time ranges and metaField prunes whole buckets, and aggregation windows ($setWindowFields), $dateTrunc for downsampling, and $densify/$fill (gap filling) round out the analytics toolkit interviewers pair with this topic. If a candidate proposes hand-rolling the bucket pattern for metrics in 2026, the follow-up is 'why not a time series collection', so know both.
db.createCollection('serverMetrics', {
timeseries: {
timeField: 'ts',
metaField: 'host',
granularity: 'minutes',
},
expireAfterSeconds: 60 * 60 * 24 * 30, // 30-day retention
});
db.serverMetrics.insertMany([
{ ts: new Date(), host: 'prod-backend', cpu: 71.2, memMb: 6100 },
{ ts: new Date(), host: 'prod-frontend', cpu: 22.9, memMb: 2048 },
]);
// 5-minute averages for one host, last 24h
db.serverMetrics.aggregate([
{ $match: { host: 'prod-backend',
ts: { $gte: new Date(Date.now() - 864e5) } } },
{
$group: {
_id: { $dateTrunc: { date: '$ts', unit: 'minute', binSize: 5 } },
avgCpu: { $avg: '$cpu' },
},
},
{ $sort: { _id: 1 } },
]);
Q47How do you harden a MongoDB deployment: authentication, RBAC, network exposure, and encryption?
IntermediateSecurity
Answer
Start from the embarrassing truth that waves of 'Meow' attacks wiped thousands of MongoDB instances that were simply listening on the public internet with auth disabled, because historically mongod shipped with authorization off. Since 3.6 the default bindIp is 127.0.0.1, but the checklist still opens with network posture: bind to private interfaces only, security-group the port (default 27017) to application subnets, never expose it publicly (Atlas enforces IP access lists and supports VPC peering / private endpoints for exactly this). Authentication: enable authorization in mongod.conf; the default mechanism is SCRAM-SHA-256 (username/password with salted challenge-response); x.509 client certificates suit service-to-service auth; Enterprise and Atlas add LDAP, Kerberos, and OIDC-based workforce and workload federation in recent versions.
Replica set members authenticate each other via keyFile (shared secret) at minimum, x.509 preferably. Authorization is role-based: built-in roles (read, readWrite, dbAdmin, clusterMonitor, and the dangerous root) scoped per database, plus user-defined roles for least privilege, the application user should be readWrite on its own database, not clusterAdmin, and your metrics scraper should be clusterMonitor, not root. Auditing (Enterprise/Atlas) records access events for compliance.
Encryption in transit is TLS everywhere (net.tls.mode requireTLS); at rest, the Enterprise encrypted storage engine or cloud disk encryption (Atlas encrypts at rest by default, with optional customer-managed keys via AWS KMS and friends). The differentiator topic in 2026 interviews is Client-Side Field Level Encryption and Queryable Encryption: fields are encrypted in the driver before leaving the application, the server stores ciphertext it cannot read, and Queryable Encryption supports equality queries (with range query support arriving in MongoDB 8.0) on encrypted fields via cryptographic index structures, the strongest answer to 'how do you protect PII from a compromised database or a curious DBA'.
Key Points
- Network first: private binding, security groups, IP access lists, never public
- SCRAM-SHA-256 default; x.509 for services; keyFile/x.509 between members
- Least-privilege RBAC: readWrite on the app DB, not root everywhere
- TLS in transit; at-rest encryption via Enterprise engine or cloud KMS
- Queryable Encryption: server never sees plaintext; 8.0 added range queries
Q48Legacy $text indexes vs Atlas Search: how do they differ and which do you choose?
IntermediateSearch
Answer
The built-in text index (createIndex({ title: 'text', description: 'text' })) provides basic full-text search with $text: whitespace tokenization, language-aware stemming for a fixed language list, stop-word removal, per-field weights, and relevance via { $meta: 'textScore' }. Its limits define the answer: one text index per collection, no fuzzy matching (typos find nothing), no autocomplete, phrase support is rudimentary (quoted phrases), no faceting, no synonyms, no custom analyzers, and compound usage with other predicates is constrained ( $text must be top-level, cannot live inside $or arbitrarily, and cannot use hints freely). It is fine for a small admin panel search; it is not a product search feature.
Atlas Search is a Lucene index embedded in the Atlas deployment and queried through the $search (and $searchMeta) aggregation stage: configurable analyzers per field (standard, language-specific, keyword, custom token filters), fuzzy matching with edit distance, autocomplete field types with edgeGram tokenization, compound queries with must/should/filter clauses, faceting, highlighting, synonym mappings backed by a collection, and relevance tuning via boosts and function scores. Because the index syncs automatically from the collection (change-stream driven), you skip the 'keep OpenSearch in sync with MongoDB' pipeline entirely, which is the operational argument that usually decides it. The honest trade-offs: Atlas-only (self-hosted deployments must run their own search engine and sync it, though search on self-managed MongoDB has been arriving in preview in recent releases), eventual consistency between collection and search index (typically sub-second, but your write-then-search test will flake), index storage overhead, and $search must be the first stage of the pipeline, meaning you filter with its compound.filter clauses rather than a preceding $match. Interview answer template: $text for toy or internal search, Atlas Search for anything user-facing, dedicated OpenSearch/Elasticsearch when you are off Atlas or need features Lucene-via-Atlas does not expose.
// Atlas Search: typo-tolerant, boosted, filtered job search
db.jobs.aggregate([
{
$search: {
index: 'jobs_search',
compound: {
must: [{
text: {
query: 'backend enginer', // note the typo
path: ['title', 'description'],
fuzzy: { maxEdits: 1 },
score: { boost: { path: 'title', value: 3 } },
},
}],
filter: [{ equals: { path: 'isActive', value: true } }],
},
},
},
{ $limit: 20 },
{ $project: { title: 1, city: 1,
score: { $meta: 'searchScore' } } },
]);
// Legacy text index equivalent has no fuzzy, no boost control:
// db.jobs.createIndex({ title: 'text', description: 'text' },
// { weights: { title: 3 } })
// db.jobs.find({ $text: { $search: 'backend engineer' } })
Q49How do you choose a shard key, what makes one catastrophic, and what can you fix after the fact?
AdvancedSharding
Answer
The shard key determines how documents distribute across shards, and it is the highest-stakes schema decision in MongoDB because every query either includes the shard key (routed to specific shards) or fans out to all of them (scatter-gather). The three properties to evaluate: cardinality (enough distinct values to split), frequency (no single value dominating, or that value's chunk becomes an unsplittable 'jumbo' hot spot, think country: 'IN' on an Indian platform where 95% of documents share it), and monotonicity (keys that only increase, like timestamps or raw ObjectIds, aim every insert at the current 'maxKey' chunk on one shard, serializing your write throughput through a single machine). Ranged sharding on a good key preserves range-query locality; hashed sharding ({ _id: 'hashed' }) destroys range locality but spreads monotonic keys evenly, the standard fix for insert-heavy workloads keyed by time or ObjectId.
The usually-right pattern for multi-tenant or user-centric data is a compound key like { tenantId: 1, createdAt: 1 } (or userId + something), giving query isolation per tenant plus split granularity within big tenants. What is fixable later: since 4.4 you can refine a shard key by suffixing fields (refineCollectionShardKey) to break up jumbo chunks; since 5.0 reshardCollection rewrites the collection onto an entirely new key online (it needs substantial free disk and I/O headroom while it clones and applies oplog entries, and MongoDB 8.0 made resharding dramatically faster); shard key values in individual documents became updatable in 4.2. What remains expensive is that queries lacking the shard key still scatter-gather forever, so the honest sequencing is: derive the key from your dominant query patterns first, simulate distribution on real data second, shard third. Interviewers score this question on whether you name concrete failure modes (monotonic hot shard, low-cardinality jumbos, scatter-gather latency amplification where p99 becomes the max across shards) rather than reciting 'choose high cardinality'.
sh.enableSharding('goodspace');
// Multi-tenant compound key: routed queries per tenant,
// splittable within large tenants
sh.shardCollection('goodspace.applications',
{ tenantId: 1, createdAt: 1 });
// Insert-heavy event stream keyed by time: hash it
sh.shardCollection('goodspace.events', { _id: 'hashed' });
// Routed (fast): includes shard key prefix
db.applications.find({ tenantId: 't_812', stage: 'offer' });
// Scatter-gather (every shard answers):
db.applications.find({ candidateEmail: 'a@b.com' });
// Escape hatches
db.adminCommand({
reshardCollection: 'goodspace.applications',
key: { tenantId: 1, _id: 1 },
});
Q50Inside a sharded cluster: what do mongos, config servers, the balancer, and chunk migrations actually do?
AdvancedSharding
Answer
A sharded cluster has three component types. mongos is the stateless query router your application connects to instead of the shards: it caches the routing table, targets shards for queries containing the shard key, scatter-gathers and merges otherwise (including merge-sorting sorted results and handling skip/limit by over-fetching from each shard). You run several mongos instances for availability, typically colocated with app tiers or behind the connection string's host list. Config servers form a mandatory replica set (CSRS) holding cluster metadata: the mapping of chunk ranges to shards, sharding configuration, and authentication data; lose the config replica set's majority and the cluster's metadata becomes read-only, chunk migrations stop, and mongos instances survive on cached routing until restart.
MongoDB 8.0 added embedded config servers, letting a shard double as the config server replica set, trimming the topology for smaller clusters. Chunks are contiguous shard-key ranges; the balancer, which runs on the config server primary in modern versions, moves data between shards to even things out, and its behavior modernized significantly: auto-splitting on 64MB chunk thresholds gave way (6.x era) to balancing driven by actual data size differences between shards, with much larger effective chunk sizes, so folklore about '64MB chunks constantly splitting' now dates a candidate. Migrations copy documents to the destination, apply concurrent changes, then commit a metadata change; the source's leftover documents become 'orphans' cleaned asynchronously, which is why readConcern 'available' can return orphans and why counts on sharded clusters historically over-reported.
Operationally know: sh.status() for the topology and chunk distribution, balancer windows (sh.setBalancerState, activeWindow settings) to keep migrations out of peak hours since they consume I/O and can evict cache, and that broadcast writes (updateMany without the shard key) hit every shard. The design consequence worth volunteering: sharding is not a performance feature you sprinkle on, it multiplies operational surface (three replica sets minimum plus routers), and a beefier replica set often beats a small sharded cluster.
Key Points
- mongos: stateless router; targeted vs scatter-gather is the whole game
- CSRS holds chunk-to-shard metadata; majority loss freezes migrations
- Balancer moves chunks by data-size imbalance in modern versions
- Orphaned documents explain 'available' read anomalies and odd counts
- 8.0: embedded config servers; sharding adds ops surface, use reluctantly
Q51Transactions under contention: what are WriteConflict and TransientTransactionError, and how should retry logic look?
AdvancedTransactions
Answer
WiredTiger uses optimistic concurrency: a transaction does not lock documents when it reads them, but the first write to a document takes ownership until commit or abort. When transaction A writes a document, and transaction B (or a plain non-transactional write, which wins differently: ordinary writes outside transactions block-and-retry internally rather than erroring) tries to write the same document before A resolves, B's operation aborts with a WriteConflict (code 112). The server attaches error labels that tell you what to do: TransientTransactionError means the whole transaction can be safely retried from the top (write conflicts, transient network errors, primary stepdowns before commit); UnknownTransactionCommitResult means the commit's outcome is uncertain (network error during commit, write concern timeout) and specifically the COMMIT should be retried, not the body, because the transaction may have already committed and re-running the body would double-apply.
The withTransaction helper implements exactly this protocol, retrying the callback on TransientTransactionError and retrying commitTransaction on UnknownTransactionCommitResult, with an overall time cap (drivers bound the retry loop, historically at 120 seconds), which is why hand-rolled startTransaction/commit loops that ignore labels are an interview red flag and a production incident generator. Design implications under real contention: keep transactions short (every millisecond held is conflict surface), touch hot documents last inside the transaction so ownership is taken as late as possible, prefer single-document atomic ops for counters (a $inc outside a transaction will not conflict-abort anyone), and consider queueing or partitioning writes to hot entities (the 'hot wallet' problem: a single merchant settlement document written by every payment forces serialization; bucketing into N sub-wallet documents restores parallelism). Observability: serverStatus().transactions tracks aborts, and slow query logs show writeConflicts counts per operation, a rising writeConflicts trend is the early smoke of contention collapse.
// What withTransaction does for you, sketched explicitly:
async function runWithRetries(session, txnFn) {
for (;;) {
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
});
try {
await txnFn(session);
} catch (err) {
await session.abortTransaction();
if (err.hasErrorLabel?.('TransientTransactionError')) continue;
throw err; // business error: do not retry
}
try {
await session.commitTransaction();
return;
} catch (err) {
if (err.hasErrorLabel?.('UnknownTransactionCommitResult')) {
continue; // retry COMMIT only; body may have committed
}
throw err;
}
}
}
// In practice: use session.withTransaction(), which is this,
// plus time caps and backoff.
Q52How does the aggregation optimizer rewrite your pipeline, and which stages are performance cliffs?
AdvancedAggregation
Answer
The server rewrites pipelines before execution, and explain() on an aggregate shows the result. Key rewrites: $match stages are pushed as early as legal, including merging into an earlier $match and sliding before $project/$addFields/$unwind when predicates do not depend on computed fields (a $match on the pre-$unwind array field can move ahead of the $unwind); a leading $match (plus a following $sort) is compiled into the query system, using indexes exactly like a find; $sort followed by $limit coalesces into a bounded top-k sort holding only k documents in memory; $project dependency analysis means the executor fetches only fields the pipeline actually uses even without an explicit early $project (so 'always $project first' is folklore; a manual early $project can even block $match pushdown); $lookup followed by $unwind of the joined array is internally absorbed so the join emits documents directly instead of materializing big arrays; $skip/$limit combine and reorder with adjacent stages where semantics allow. Recent versions execute increasing portions of pipelines on the slot-based execution engine (SBE), whose presence you can spot in explain output, with meaningful speedups for $group and $lookup shapes.
The cliffs to name: blocking stages ($sort without index support, $group, $bucket, $facet) must consume their entire input before emitting anything, each bounded by the 100MB per-stage memory limit unless allowDiskUse spills to disk at a large speed penalty; $facet sub-pipelines cannot use indexes at all; a non-leading $match after a blocking stage cannot use indexes; and any stage receiving unfiltered millions of documents dominates everything else you do. For pipelines that produce materialized results, $merge (4.2+) incrementally upserts into a target collection (the on/whenMatched/whenNotMatched knobs) and can run on a schedule to maintain pre-aggregated rollups, while $out atomically replaces a collection, the pattern for turning a 9-second dashboard aggregation into a millisecond read of a rollup collection. Diagnostic habit: db.collection.explain('executionStats').aggregate(pipeline) and read which prefix became the query layer, then check totalDocsExamined exactly as with find.
// Nightly rollup: heavy pipeline -> incremental $merge
db.applications.aggregate([
{ $match: { updatedAt: { $gte: yesterday } } },
{
$group: {
_id: { jobId: '$jobId', day: {
$dateTrunc: { date: '$updatedAt', unit: 'day' } } },
applied: { $sum: 1 },
shortlisted: {
$sum: { $cond: [{ $eq: ['$stage', 'shortlisted'] }, 1, 0] } },
},
},
{
$merge: {
into: 'jobFunnelDaily',
on: '_id',
whenMatched: 'replace',
whenNotMatched: 'insert',
},
},
]);
// Verify pushdown happened:
db.applications
.explain('executionStats')
.aggregate([{ $match: { jobId } }, { $sort: { updatedAt: -1 } },
{ $limit: 10 }]);
// Expect: winning plan IXSCAN under the query layer, LIMIT absorbed
Q53What does causal consistency give you, and when do you actually need client sessions for reads?
AdvancedConsistency
Answer
By default, operations from one client against different members have no cross-member ordering guarantee: write to the primary, read from a secondary, and you may not see your own write (the replication-lag stale read). Causal consistency fixes the ordering without forcing everything to the primary. Mechanism: every operation in MongoDB is stamped with the cluster time from the underlying oplog ordering; a causally consistent session (client.startSession({ causalConsistency: true }), which is the default for explicit sessions) tracks the highest cluster time and operation time it has observed, sends afterClusterTime with subsequent reads, and a targeted secondary will wait until it has replicated past that timestamp before answering.
The guarantees, formally: read-your-own-writes, monotonic reads (never see older state after newer), monotonic writes, and writes-follow-reads, within one session, and only for operations using read concern 'majority' and write concern 'majority' if you want the guarantees to survive failovers (with weaker concerns, the causal chain can include writes that later roll back). What it does not give you: cross-session ordering (user A's session does not automatically see user B's write; you can propagate causality manually by shipping session.advanceClusterTime/advanceOperationTime tokens between services, for example through an HTTP header, a trick that impresses interviewers because it solves 'the mobile app wrote via service X, then read via service Y'), linearizability (that is read concern 'linearizable', a stronger, primary-only, per-operation property), or isolation (concurrent sessions still interleave; that is what transactions add on top, and transactions are causally consistent internally). Cost model: reads may block waiting for a lagging secondary to catch up to afterClusterTime, so causal reads against badly lagging secondaries trade staleness for latency; pair with maxStalenessSeconds routing to avoid pathological waits. Practical placement: enable it on flows shaped 'write, then immediately read via a possibly different node', profile updates, settings saves, anything where 'I saved but it shows old data' is a bug report you have actually received.
const session = client.startSession(); // causal by default
try {
await profiles.updateOne(
{ _id: userId },
{ $set: { headline: 'Backend @ Bengaluru' } },
{ session, writeConcern: { w: 'majority' } }
);
// May be served by a secondary, but is guaranteed to
// reflect the write above: driver sends afterClusterTime
const fresh = await profiles.findOne(
{ _id: userId },
{ session, readConcern: { level: 'majority' },
readPreference: 'secondaryPreferred' }
);
} finally {
await session.endSession();
}
// Cross-service causality: ship the tokens
// res.header('x-cluster-time', JSON.stringify(session.clusterTime));
// ...other service: session.advanceClusterTime(parsedToken)
Q54How does $vectorSearch work in Atlas, and how would you design a semantic search feature on it?
AdvancedSearch
Answer
Atlas Vector Search stores embedding vectors inside your documents and indexes them for approximate nearest neighbor retrieval, queried through the $vectorSearch aggregation stage (GA since the 2023-2024 wave and now a standard interview topic for AI-adjacent backend roles). Setup: you compute embeddings in your application with any model (OpenAI, Voyage, open-source sentence transformers), store them as arrays of numbers in a field, and define a vectorSearch-type index specifying the field path, numDimensions (which must match your model's output, e.g. 1536), and similarity function (cosine, dotProduct, or euclidean). The index builds an HNSW graph for ANN search.
At query time, embed the user's query with the same model and run $vectorSearch as the first pipeline stage with queryVector, path, limit, and numCandidates (the exploration breadth: higher = better recall, more latency; a common starting point is 10-20x limit). Critically for real products, filter support: pre-filtering on scalar fields declared as filter-type in the index (tenant, status, language) happens inside the ANN traversal, which is essential because post-filtering ANN results destroys recall. Results carry a similarity score via { $meta: 'vectorSearchScore' }.
Design points that separate seniors: hybrid search, combining $vectorSearch (semantic) with $search (lexical/BM25) via reciprocal rank fusion (in recent Atlas versions $rankFusion exists as a native stage; otherwise you fuse in two queries with $unionWith and score arithmetic), because pure vector search whiffs on exact identifiers, names, and rare tokens; embedding lifecycle, re-embedding on document updates via change-stream consumers, versioning the model in a field so mixed-model vectors never get compared; cost and memory, HNSW lives in RAM on Atlas search nodes, so 1M x 1536-dim float vectors is roughly 6GB before graph overhead, motivating dimension reduction or scalar/binary quantization options added in recent releases; and honest failure modes, ANN is approximate (recall < 100%), scores are not probabilities, and numCandidates tuning is workload-specific. Contrast with a dedicated vector DB: keeping vectors next to operational data removes an entire sync pipeline, the same argument as Atlas Search versus external OpenSearch.
// Index definition (Atlas UI / API), field: 'embedding'
// { fields: [
// { type: 'vector', path: 'embedding',
// numDimensions: 1536, similarity: 'cosine' },
// { type: 'filter', path: 'tenantId' },
// { type: 'filter', path: 'status' } ] }
const queryVector = await embed('remote node.js jobs in fintech');
const hits = await jobs.aggregate([
{
$vectorSearch: {
index: 'jobs_vec',
path: 'embedding',
queryVector,
numCandidates: 400,
limit: 20,
filter: { tenantId: 't_812', status: 'active' },
},
},
{
$project: {
title: 1, city: 1,
score: { $meta: 'vectorSearchScore' },
},
},
]).toArray();
Q55A secondary is lagging badly: how do you diagnose replication lag, and what are oplog sizing and flow control?
AdvancedReplication
Answer
Start with measurement: rs.printSecondaryReplicationInfo() shows each secondary's lag behind the primary; rs.status() gives optimeDate per member for the same computation plus member states (a member stuck in RECOVERING has fallen off the oplog entirely); db.serverStatus().oplogTruncation and rs.printReplicationInfo() show the oplog window, the wall-clock span between the oldest and newest entries in the capped local.oplog.rs collection. The oplog window is your safety margin: a secondary that is down or lagging longer than the window cannot catch up incrementally and must perform initial sync (full data clone), and the same window bounds change stream resumability and PIT restore granularity. Since 4.0 the oplog can grow past its configured size to preserve the majority commit point, and since 4.4 you can set a minimum retention period in hours (storage.oplogMinRetentionHours), the modern answer to 'how big should the oplog be' being 'large enough that your longest tolerable secondary outage plus maintenance window fits inside it, measured against your peak write rate'.
Resize online with replSetResizeOplog. Causes of lag, in rough frequency order: underpowered secondary hardware (secondaries must apply the same write volume as the primary; an analytics-tier secondary with smaller disks will lag by construction), long-running operations or index builds on the secondary stalling application, network saturation, and write bursts exceeding apply throughput. Flow control, added in 4.2 and enabled by default, is the server's own defence: when majority-committed lag approaches flowControlTargetLagSeconds (default 10), the primary throttles write ticket issuance, deliberately adding latency to writes so secondaries keep up, visible in serverStatus().flowControl (isLagged, timeAcquiringMicros).
That means severe lag can present as mysterious primary write latency, an incident-review classic. Mitigations to discuss: scale secondary I/O, isolate analytics to a dedicated non-voting member so its stalls do not gate majority acknowledgment, batch large deletes/updates (each becomes per-document oplog entries; a 50M-document updateMany is a replication event), and monitor lag with alerts well under the oplog window, not at it.
// Lag per secondary
rs.printSecondaryReplicationInfo();
// source: mongo-2:27017
// syncedTo: ...
// 0 secs (0 hrs) behind the primary
// Oplog window: first vs last entry timestamps
rs.printReplicationInfo();
// log length start to end: 86400 secs (24 hrs)
// Retention floor + live resize (size in MB)
db.adminCommand({ replSetResizeOplog: 1,
size: 51200, minRetentionHours: 48 });
// Is flow control throttling the primary right now?
const fc = db.serverStatus().flowControl;
printjson({ isLagged: fc.isLagged,
acquireWaitMs: fc.timeAcquiringMicros / 1000 });
Q56Your API's p99 explodes and MongoDB 'is down': walk through the real production failure modes and the fixes.
AdvancedOperations
Answer
The interviewer wants a triage tree, not a tool list. Failure mode one, connection storms: deploys or Lambda scale-outs create thousands of new clients, each opening pools; serverStatus().connections shows current vs available spiking, and every new connection costs auth handshakes and memory, slowing everything, which spawns more retries. Fixes: one client per process, right-sized maxPoolSize (total connections = instances x pool size, do that arithmetic before the incident), maxConnecting (driver limit on simultaneous connection establishment) and proxy-layer pooling for serverless.
Failure mode two, pool exhaustion masquerading as DB slowness: one slow query type (new unindexed filter shipped that morning) occupies all pool slots; app threads queue waiting for checkouts, so EVERY endpoint looks slow while the database is merely busy with one bad shape. Diagnose from the driver side (connection checkout wait metrics, WaitQueueTimeoutError) and the server side (db.currentOp for pileups of the same shape), fix the query, and add maxTimeMS everywhere so one shape cannot squat on the pool indefinitely. Failure mode three, elections and stepdowns: 10-30 seconds of NotWritablePrimary errors; retryable writes absorb single-op cases, but multi-op flows need idempotency; if elections recur, find why the primary keeps stalling (often disk saturation or VM stealtime).
Failure mode four, cache eviction pressure: write bursts push WiredTiger dirty ratio past thresholds, application threads get drafted into eviction, and p99 cliffs without obvious slow queries; serverStatus().wiredTiger.cache tells the story, and the fix is throttling the burst (batch jobs pacing), more RAM, or faster disks. Failure mode five, missing/regressed indexes after a plan flip: the plan cache picked a bad plan for a skewed key; clear it or use index hints/plan cache filters. The meta-answer that scores: instrument BEFORE the incident (driver pool metrics, serverStatus scraping, slow query logs shipped to your observability stack), set timeouts at every layer (maxTimeMS, socket, server selection, and application deadlines), and rehearse failover monthly so 'primary stepdown' is a non-event rather than an outage.
Key Points
- Connections math first: instances x maxPoolSize vs server capacity
- Pool exhaustion makes ONE bad query look like total DB failure
- maxTimeMS on every query: no shape may squat on the pool
- Elections are seconds; recurring ones mean disk/VM pathology
- WiredTiger dirty-cache eviction = p99 cliffs with no slow query
Q57Design a backup and restore strategy: mongodump vs filesystem snapshots vs PITR, and what makes backups real?
AdvancedOperations
Answer
mongodump streams BSON per collection through the network; mongorestore replays it, rebuilding indexes from metadata afterwards. It is portable, selective (single collection restores are trivial), and fine up to tens of gigabytes, but it reads everything through the working set (cache pollution on the source), takes hours at scale, and restore time, the number that actually matters, is worse because of index rebuilds. Run it against a secondary (or a hidden member dedicated to backups), and use --oplog on a replica set so the dump captures a consistent point in time (the oplog slice recorded during the dump is replayed by mongorestore --oplogReplay); without it, a long dump of a busy system is a smear across time, internally inconsistent.
Filesystem or EBS snapshots are the scale answer: with WiredTiger's journal on the same volume, a snapshot at any instant is crash-consistent and restores to a valid state; snapshots complete in minutes regardless of data size and restore fast (lazy-loading EBS caveats aside). Coordinate across sharded clusters carefully: you must stop the balancer and snapshot all shards plus config servers near-simultaneously, one reason sharded backup is where DIY strategies die and managed tooling (Atlas backups, Ops Manager) earns its cost. Point-in-time recovery combines a base (snapshot or dump) with continuous oplog capture, letting you restore to 14:03:27, just before the bad deploy dropped a collection; Atlas provides PITR natively with a slider, self-hosted teams either run Ops/Cloud Manager or build oplog tailing, and if your answer to 'someone ran deleteMany({}) on prod at 2 PM' is 'we have nightly dumps', you are conceding up to 24 hours of data.
Now the parts that make backups real rather than ceremonial: restore drills on a schedule (an unrestored backup is Schrodinger's backup), timing the drill against your RTO, verifying application-level integrity post-restore (counts, checksums, a smoke test), storing backups in a different failure domain AND a different account/credential boundary (ransomware that owns your AWS account owns same-account snapshots), and encrypting at rest. Close by inverting the question: replica sets are availability, not backups; a delayed member protects against some fat-fingers but not disk corruption replicated everywhere, and only PITR-class backups protect against logical corruption discovered late.
# Consistent dump from a secondary, with oplog slice
mongodump --uri "$URI" --readPreference=secondary \
--oplog --gzip --archive=/backups/gs-$(date +%F).archive
# Restore to a point in time captured by the dump
mongorestore --gzip --archive=/backups/gs-2026-08-11.archive \
--oplogReplay --numInsertionWorkersPerCollection=4
# Selective: just one collection, into a scratch DB for inspection
mongorestore --gzip --archive=/backups/gs-2026-08-11.archive \
--nsInclude 'goodspace.applications' \
--nsFrom 'goodspace.applications' \
--nsTo 'restore_check.applications'
# The drill is the strategy: schedule this, time it, alert on failure
Q58Live incident on a cluster you have never seen: how do you use currentOp, killOp, mongostat, and mongotop to find the fire?
AdvancedOperations
Answer
First sixty seconds, establish the shape of the pain. mongostat (one line per second per member) answers 'what kind of load': insert/query/update/delete rates, dirty and used WiredTiger cache percentages, and the qrw/arw columns showing queued vs active readers and writers, sustained queues mean the server cannot keep up, and a dirty cache percentage climbing toward 20 means eviction pressure incoming. mongotop answers 'which collection': per-namespace read/write time per interval, immediately fingering the collection eating the node. Then go operation-level: db.currentOp(true) via the shell, or better, the $currentOp aggregation stage (db.getSiblingDB('admin').aggregate([{ $currentOp: { allUsers: true, idleConnections: false } }, { $match: ... }])), which is filterable, works properly on sharded clusters through mongos, and can show idle sessions holding transactions (idleSessions: true), the invisible lock-holders that plain currentOp misses. Fields that matter per op: secs_running, planSummary (COLLSCAN on a huge namespace = probable culprit), locks and waitingForLock, numYields, and writeConflicts on transactional workloads.
Kill surgically: db.killOp(opid) for a runaway query (its opid from currentOp; on mongos use the composite shard opid form), and killSessions for orphaned transactions holding locks (a stuck transaction also shows in serverStatus().transactions.currentOpen). Know what NOT to kill: internal replication ops, index builds you actually want (killing one aborts the build), and checkpoint threads; killOp on writes already applied does not undo them. In parallel, glance at rs.status() (is a member down, is this node unexpectedly primary after an election), serverStatus().connections (storm?), and the slow query log tail. The narrative interviewers reward is layered triage, cluster to namespace to operation, each tool eliminating a hypothesis, followed by the permanent fix: the killed query gets an index or a maxTimeMS, the stuck transaction gets timeout and retry-label handling, and the dashboard gets the missing panels that would have cut those sixty seconds to ten.
// Filterable, sharded-cluster-safe process listing
db.getSiblingDB('admin').aggregate([
{ $currentOp: { allUsers: true, idleSessions: true } },
{ $match: {
active: true,
secs_running: { $gt: 10 },
ns: { $regex: '^goodspace\\.' },
} },
{ $project: { opid: 1, secs_running: 1, planSummary: 1,
ns: 1, 'command.filter': 1, waitingForLock: 1 } },
{ $sort: { secs_running: -1 } },
]);
// Kill the offender; then fix the cause
db.killOp(8834021);
// Idle transactions pinning resources
db.getSiblingDB('admin').aggregate([
{ $currentOp: { idleSessions: true, allUsers: true } },
{ $match: { 'transaction.parameters.txnNumber': { $exists: true },
active: false } },
]);
Q59What actually changed in MongoDB 7.0 and 8.0 that an interviewer might expect you to know?
AdvancedVersions
Answer
Version questions filter candidates whose mental model froze years ago, so anchor on the changes that alter how you design and operate. The 5.x/6.x groundwork worth one sentence each: time series collections (5.0), online resharding via reshardCollection (5.0), the default write concern moving to w:'majority' (5.0), clustered collections (collections stored ordered by _id, removing the separate _id index, useful for IoT-style append patterns), $lookup/$graphLookup usability on sharded collections, change stream pre- and post-images, and Queryable Encryption's introduction (preview in 6.0, GA in 7.0). MongoDB 7.0 consolidated: Queryable Encryption GA with equality queries on always-encrypted fields, compound wildcard indexes, shard key advisor commands like analyzeShardKey plus queryAnalyzer-based sampling to evaluate candidate keys against real traffic before committing, userRoles system variable for building field-level redaction into views, and broader slot-based execution engine coverage speeding common $group/$lookup pipelines.
MongoDB 8.0 was primarily a performance and sharding release: the company's headline claims centered on substantially faster reads, writes and aggregations plus lower replication overhead (treat specific percentages cautiously unless you have benchmarked), range queries added to Queryable Encryption (equality-only before, a major unlock for encrypted PII like salaries or dates), embedded config servers so a shard can host cluster metadata (cheaper small sharded clusters), moveCollection to relocate unsharded collections between shards (fixing the old 'everything unsharded piles onto the primary shard' pain), dramatically faster resharding, and OIDC/Workload Identity authentication maturing. Around the server, the 2024-2026 platform shifts matter in interviews too: Atlas Search and Vector Search became default architecture for search and AI features, drivers standardized on the unified timeoutMS model, and the community/enterprise split stayed stable while search capabilities began arriving for self-managed deployments in preview. Frame your answer with one design consequence per feature, e.g. 'Queryable Encryption range support means encrypted salary filtering no longer forces client-side scans', and you demonstrate judgment, not release-note memorization.
Key Points
- 5.0: time series, online resharding, w:'majority' default
- 7.0: Queryable Encryption GA, analyzeShardKey, compound wildcard indexes
- 8.0: performance release; QE range queries; embedded config servers
- 8.0: moveCollection + much faster resharding reshape shard operations
- Atlas Search / $vectorSearch became default search architecture in this era
Q60Design a multi-tenant SaaS data layer on MongoDB: shared collections, database-per-tenant, or cluster-per-tenant?
AdvancedArchitecture
Answer
Three models, and the senior answer is a tiered combination. Shared collections with a tenantId field on every document is the default: cheapest, simplest to operate, one set of indexes, easy cross-tenant analytics. Its obligations: EVERY index must be compound with tenantId leading ({ tenantId: 1, status: 1, createdAt: -1 }), every query must carry the tenant filter (enforce it in a repository layer or Mongoose plugin that injects tenantId from request context, because one forgotten filter is a data leak across customers, the worst bug class in SaaS), unique constraints become compound ({ tenantId: 1, email: 1 }), and noisy neighbors share cache and I/O, one tenant's bulk import degrades everyone unless you rate-limit per tenant at the application layer.
When sharding arrives, tenantId-led compound shard keys give routed queries and per-tenant chunk isolation, and zone sharding can pin regulated tenants' ranges to shards in specific regions (India data residency for one enterprise customer, without forking infrastructure). Database-per-tenant gives harder isolation: separate collections and index trees, per-tenant mongodump restores (a huge operational win: 'restore only tenant X to yesterday' is trivial here and painful in shared collections), per-database authorization, simple tenant deletion (dropDatabase satisfies right-to-be-forgotten cleanly). Costs: thousands of databases multiply collections and index files (storage engine overhead per collection is real; historically the dbPath strains well before ten thousand tenants), connection/session patterns get complicated, migrations become fan-out scripts across N databases, and cross-tenant analytics require aggregation over many databases (painful) or an ETL sink.
Cluster-per-tenant (dedicated Atlas project or cluster) is for regulated whales who pay for it: complete blast-radius isolation, tenant-specific versions and maintenance windows, priced accordingly. The blended architecture that wins interviews: shared collections for the long tail, database-per-tenant for mid-size customers with contractual isolation, dedicated clusters for the few enterprise deals demanding it, with tenant placement recorded in a control-plane collection and an abstraction layer hiding the model from application code. Sprinkle in per-tenant encryption keys via CSFLE for the isolation story and per-tenant rate limiting for the noisy-neighbor story, and you have covered what the question is really probing: whether you have thought past the demo into operations, compliance, and failure containment.
// Repository layer that makes forgetting tenantId impossible
class TenantRepo {
constructor(col, tenantId) {
this.col = col;
this.tenantId = tenantId;
}
find(filter = {}, opts) {
return this.col.find({ ...filter, tenantId: this.tenantId }, opts);
}
insertOne(doc, opts) {
return this.col.insertOne({ ...doc, tenantId: this.tenantId }, opts);
}
updateMany(filter, update, opts) {
return this.col.updateMany(
{ ...filter, tenantId: this.tenantId }, update, opts);
}
}
// Every index tenant-first; uniqueness is per-tenant
db.jobs.createIndex({ tenantId: 1, status: 1, postedAt: -1 });
db.users.createIndex({ tenantId: 1, email: 1 }, { unique: true });
// Sharded future: routed per tenant
// sh.shardCollection('app.jobs', { tenantId: 1, _id: 1 })
Frequently Asked Questions
How much do MongoDB skills pay in India in 2026?
MongoDB alone is rarely the job title; it prices in as part of a backend or full-stack package. Node.js or Java backend roles listing MongoDB typically span ₹6-22 LPA depending on city and company tier: service companies (TCS, Infosys, Accenture projects) at ₹6-12 LPA, product companies and funded startups at ₹12-22 LPA for 3-6 years of experience, and senior or staff engineers who can speak fluently about sharding, replication semantics, and schema design at scale clearing ₹25-40 LPA at top product firms. Dedicated database engineer and DBA-flavoured roles (Atlas administration, performance engineering) are rarer but pay comparably, and MongoDB Inc. itself hires engineers and consulting roles from its Gurugram and Bengaluru offices at strong product-company bands.
How long should I prepare for a MongoDB-heavy interview?
If you already use MongoDB at work through an ORM, two to three focused weeks closes the gap between 'I call Mongoose' and 'I understand the database'. Week one: indexing (ESR rule, explain output, covered queries) and aggregation, practicing pipelines against a real dataset in mongosh rather than reading about them. Week two: replication semantics (write/read concerns, elections, retryable writes), transactions, and schema design patterns, these are where mid-level candidates actually fail. Week three: operations and scale topics (profiling, sharding trade-offs, backup strategy) plus mock questions out loud. Complete beginners should budget six to eight weeks and build one small project end to end, because interviewers detect tutorial-only knowledge within minutes by asking about a gotcha, like why their unique index broke on null values.
What do interviewers expect from freshers vs experienced engineers on MongoDB?
Freshers are tested on fundamentals: CRUD and query operators, what an index does and how to read basic explain output, embedding vs referencing with a sensible justification, and honest acknowledgment of what they have not used (claiming sharding experience you lack is a fast rejection). A fresher who can explain why $inc beats read-modify-write for counters is already above the bar. Experienced candidates get scenario questions: design the schema for a given product, debug a slow query from an explain document, reason about what happens to in-flight writes during a failover, choose and defend a shard key. At senior levels, expect trade-off interrogation, when NOT to use transactions, when a relational database is the better choice, how you handled a real production incident, and interviewers care more about the shape of your reasoning than a memorized right answer.
Is MongoDB still worth learning in 2026, given PostgreSQL's popularity?
Yes, with clear eyes. PostgreSQL has won considerable developer mindshare, and 'just use Postgres' is real advice for many CRUD applications. But MongoDB's installed base in Indian industry is enormous: a decade of Node.js-first startups built on MERN means maintenance, scaling, and migration work everywhere; Atlas keeps expanding (Search, Vector Search, stream processing) into an application data platform; and document-model strengths remain genuine for catalogs, content systems, event data, and rapidly evolving schemas. Practically, the highest-value profile in 2026 is bilingual: strong SQL plus strong MongoDB, able to argue when each fits. Engineers who can migrate a struggling MongoDB deployment onto sane schema patterns, or integrate vector search for AI features, are billing premium rates precisely because so many teams adopted the database faster than they understood it.
How does MongoDB knowledge compare with Redis, Cassandra, and DynamoDB for career purposes?
They are complements, not competitors, and job descriptions increasingly list them together. Redis is nearly universal alongside MongoDB (caching, queues, rate limiting) and is quick to learn; every MongoDB-stack engineer should know it. Cassandra and DynamoDB occupy the write-heavy, predictable-access-pattern niche: fewer Indian openings than MongoDB, but concentrated at scale-heavy companies (large consumer apps, fintech event stores) and often paying more because the talent pool is thinner. DynamoDB expertise travels with AWS-centric employers. The pragmatic ordering for a backend career in India: one relational database deeply (PostgreSQL or MySQL), MongoDB deeply, Redis functionally, then Kafka, and add Cassandra or DynamoDB opportunistically when a role demands it. Depth in two databases with honest trade-off judgment beats surface familiarity with five.
Are MongoDB certifications worth it, and which one should I take?
MongoDB's official certifications, the Associate Developer (language-specific variants) and Associate Database Administrator, plus the free MongoDB University courses that prepare you for them, are moderately valuable in India: service companies and consultancies weight them for staffing and client-facing credibility, and they are a reasonable structured syllabus even if you never sit the exam. Product companies largely ignore certificates and test skills directly, so a certification will not rescue a weak interview performance, but the preparation itself (especially the aggregation, indexing, and data modeling units) maps almost one-to-one onto real interview questions. Recommended path: complete the free University learning paths, build something real on an Atlas free tier, and take the Associate Developer exam only if your target employers are in the services or consulting segment where the line on a resume moves screening decisions.
Introduction
MongoDB interviews in 2026 have moved well past the old 'SQL vs NoSQL' warm-up. Interviewers now assume you can write CRUD in your sleep and instead probe whether you understand what the database is doing underneath: how WiredTiger caches your working set, why a compound index built in the wrong field order gets ignored, what actually happens during a replica set election, and how a bad shard key quietly ruins a cluster six months after launch. With MongoDB Inc. running large engineering offices in Gurugram and Bengaluru, and Flipkart, Paytm, Zomato and every major services firm shipping on it, the bar for 'knows MongoDB' has risen sharply in India.
The topics that decide offers cluster into a few areas: data modeling (embedding vs referencing, the 16MB limit, bucket and extended-reference patterns), indexing (the ESR rule, covered queries, partial and multikey indexes), the aggregation pipeline ($lookup, $facet, $merge, and how the optimizer rewrites your stages), replication and consistency (write concerns, read preferences, retryable writes, causal sessions), transactions and their limits, and operations (profiling, replication lag, connection storms, backup strategy). Atlas-specific skills, especially Atlas Search and $vectorSearch for AI features, now show up in product-company interviews far more often than trivia about legacy versions.
This guide contains 60 questions arranged basic to advanced, written against MongoDB 7.x and 8.x behavior rather than folklore from the 3.x era. Each answer explains how the feature behaves in production, the gotcha an interviewer is fishing for, and where teams get bitten in real deployments. Most questions include a runnable mongosh or Node.js driver snippet you can paste into a scratch cluster. Work through the basic set to firm up fundamentals, then spend your remaining prep time on aggregation, replication semantics, and sharding: that is where senior rounds are won or lost.
Ready to practice MongoDB interviews?
Don't just read, practice these MongoDB questions live with an AI interviewer that asks follow-ups and scores your answers.