MongoDB Interview Questions · 2026

MongoDB Interview Questions (2026): Most Commonly Asked, With Answers

A backend engineer candidate at a 40-person fintech startup lost the round in under two minutes in March 2026. The question wasn't hard on paper: what happens to a $lookup once the foreign collection lives on a different shard. He'd shipped three MongoDB-backed services and had never sharded anything, so he guessed, and guessed wrong. MongoDB usage among professional developers sits at 24.3 percent in the 2025 Stack Overflow Developer Survey, sixth place behind PostgreSQL, MySQL, SQLite, Microsoft SQL Server, and Redis (Stack Overflow, 2025). It's not the default NoSQL pick it was a decade ago, but the interview loops haven't gotten any easier for it.

Here's a take that might be wrong: MongoDB interview prep spends too long drilling aggregation pipeline syntax and not nearly enough on shard key selection and the embedding-versus-referencing call. A clumsy $group stage is slow until someone rewrites it. A bad shard key picked on day one, or an array field that grows without a bound, is a decision you're stuck living with for years, since fixing it later means moving live data under production traffic.

This page covers the MongoDB interview questions that come up across backend and full-stack loops in 2026: the document model and BSON, CRUD operations, query operators, indexing (including the ESR rule almost nobody names correctly on the first try), the aggregation pipeline, embedding versus referencing, replica sets, sharding, and transactions. It's organized by topic instead of seniority level, because the same MongoDB interview questions repeat at a junior screen and a staff onsite. The onsite just attaches a harder follow-up.

52Questions
Aggregation PipelineCore Concept
16MBDocument Size Limit
24.3%2025 Pro Dev Usage

Document model and BSON basics

Every loop opens here, even for candidates with five years of MongoDB on a resume. It's a warm-up, but a shaky answer on what BSON actually is tends to color how the interviewer reads everything that follows.

Easy questions

15

BSON stands for Binary JSON. It's the binary-encoded format MongoDB actually stores documents in on disk and sends over the wire, and it's a superset of JSON's type system rather than JSON with different brackets.

Plain JSON only has strings, numbers, booleans, arrays, objects, and null. BSON adds types JSON can't express: a native Date, ObjectId, binary data, Decimal128 for exact decimal math, and a real split between 32-bit and 64-bit integers. The shell shows you something that looks like JSON because the driver formats BSON into a readable string. What's actually stored is the binary encoding underneath.

A single document can't exceed 16MB, and nesting can't go deeper than 100 levels.

The 16MB cap isn't arbitrary. It keeps one document from hogging an outsized chunk of RAM and forces a decision point once an embedded array could genuinely grow into the thousands. Almost every time a team actually hits that ceiling, it's for the same reason: someone embedded an array that was never going to stop growing, audit trails and activity logs are the usual culprits, instead of giving that data its own referenced collection.

updateOne modifies the single first document matching the filter. updateMany modifies every matching document. replaceOne is the odd one out: instead of applying operators like $set to specific fields, it swaps the entire document body for a new one, keeping only the original _id.

That last part trips people up. Call replaceOne with a partial document and you don't get a merge, you get a document missing every field you left out. Update operators preserve untouched fields by design. replaceOne doesn't, because it isn't an update operator. It's a full swap.

$in checks whether a single field matches any value in a list, and MongoDB can usually satisfy it with one index scan across that list. $or evaluates a set of independent conditions that can span entirely different fields, and each branch may need its own supporting index to be efficient.

If you're checking one field against several possible values, status is "pending" or "processing", $in is the cleaner and usually faster pick. Reach for $or only when the conditions genuinely differ by field, status is "pending" or priority is greater than 5, because that's the one shape $in structurally can't express.

find() is one query: a filter, an optional projection, an optional sort and limit. The aggregation pipeline is a sequence of stages, $match, $group, $project, $sort, $lookup, and more, chained so each stage's output becomes the next stage's input, the same way Unix pipes chain commands.

That's the real difference. find() can shape and filter data. The aggregation pipeline can reshape, group, join, and compute across it in ways a single find() query structurally can't touch.

A replica set is a group of mongod processes holding copies of the same data, one primary accepting writes and one or more secondaries replicating from it. If the primary goes down, the set holds an election and promotes a secondary.

Odd counts, 3 or 5 members being the common defaults, exist so a majority vote is always mathematically decisive. With an even number, a network partition can split voting power exactly in half and nobody reaches a majority, which is how you end up with no primary at all instead of just a slow one. A replica set tops out at 7 voting members, though it can hold up to 50 total if the extras are non-voting (MongoDB docs, replica set elections).

Every write to a single document, including updates touching multiple fields or nested array elements inside it, is always atomic in MongoDB with no transaction required. Either the whole update to that one document applies, or none of it does.

You only need an explicit transaction when the atomic unit spans more than one document, moving money out of one account and into another, say, where both writes need to succeed or fail together. Plenty of candidates reach for transactions reflexively the moment two writes show up in the same request, without checking whether restructuring the schema so both land in one document would've made the transaction unnecessary.

Write concern controls how many replica set members need to acknowledge a write before the driver treats it as successful. With w:1, only the primary needs to have applied the write to its own state, so if the primary crashes before it replicates to any secondary and a different node becomes primary, that write can vanish during failover. With w:majority, a majority of voting members have to acknowledge the write, which means it survives a future election because any new primary has to have that data in its history.

The tradeoff is latency. Majority acknowledgment waits on round trips to other members, so writes get slower, more so if a secondary is geographically distant or under load. In practice, w:1 is fine for high volume, low stakes writes like logging or analytics events, and w:majority is for anything you'd be unhappy to silently lose, like account state or anything touching money.

A TTL index is a single field index on a date field with an expireAfterSeconds value set, and a background thread on the server periodically scans for documents older than that threshold and deletes them. It's commonly used for session data, verification tokens, temporary caches, or logs you only want to keep for a fixed window, so you don't need a cron job running delete-where-older-than queries yourself.

Two things trip people up. The deletion isn't instant, the background task runs on roughly a 60 second interval and can lag under load, so documents can outlive their expiry by a bit. And TTL indexes only work on an actual Date type (or an array of dates, using the earliest one), not on a numeric epoch timestamp, so if createdAt is stored as an integer, the index silently does nothing.

The insert fails with a duplicate key error, E11000, and the document doesn't get written. With insertMany, the default ordered:true behavior stops at the first duplicate, so if document 5 of 10 fails, documents 6 through 10 never get attempted. Setting ordered:false lets Mongo try all of them and return one error listing every failure, which is usually what you want for bulk imports where a single bad row shouldn't block the rest.

One trap: a unique index on an array field enforces uniqueness per array element across the entire collection, not per document. People expect it to mean "each document has a unique set" and get surprised the first time two documents share one overlapping array value and the insert fails.

$push always appends the value, whether or not it's already in the array, so you can end up with duplicates. $addToSet only adds the value if it isn't already present, behaving like a set, but it does that by scanning the existing array on every write, which is cheap for small arrays and gets noticeably slower as the array grows.

Neither one enforces uniqueness as a schema-level guarantee. Two concurrent updates can both check "not present yet" and both add the value under $addToSet, since there's no unique constraint on array contents, just an add-if-not-present check at write time. If you actually need guaranteed array uniqueness, that has to be enforced at the application layer or cleaned up after the fact.

A capped collection is a fixed size collection that automatically overwrites its oldest documents once it hits its size limit, like a circular buffer. You set a maxSize (and optionally a max document count) at creation time, and inserts preserve insertion order without needing a separate index on a date field. The oplog itself is a capped collection under the hood.

It's good for high throughput logging where you only care about the most recent N events and don't want the overhead of manually deleting old data. The catch is you can't shard a capped collection, and you can't delete arbitrary individual documents from it, only the automatic overwrite removes data, so it's a narrow tool rather than a general replacement for TTL indexes.

The oplog is a capped collection on the primary, local.oplog.rs, that records every write in an idempotent form, and secondaries tail it to stay in sync. Change streams and most custom replication tooling read from it too.

Its size sets your replication window, the amount of time a secondary can be offline or behind before it can no longer catch up by replaying the oplog. If a secondary's lag exceeds the oldest entry still sitting in the oplog, it can't resync incrementally and has to do a full initial sync instead, which on a large dataset takes hours and puts real read load on the primary while it's happening. Undersizing the oplog on a write heavy deployment is a common cause of an unplanned initial sync at the worst possible time, often right after a secondary was intentionally taken down for maintenance a bit longer than usual.

mongodump and mongorestore work in BSON, the same binary format MongoDB uses internally, so they preserve every data type exactly, ObjectId, Date, NumberDecimal, all of it, and they're the correct tool for real backups. mongoexport and mongoimport work in JSON or CSV, which is readable and easy to diff or hand-edit, but JSON has no native representation for BSON specific types, so exporting converts them into extended JSON, and a careless reimport can leave you with dates stored as plain strings instead of actual Date objects.

In practice, mongodump/mongorestore is for backup, restore, and whole-database migration, and mongoexport/mongoimport is for one-off data wrangling, feeding a collection into another tool, or producing a CSV someone needs in a spreadsheet.

A sparse index only includes documents that actually have the indexed field, skipping documents where the field is missing entirely, rather than indexing every document with null standing in for the missing ones. A normal index still creates an entry for every document regardless, which bloats the index and can make {field: null} queries behave unexpectedly if what you actually meant was "this field doesn't exist."

You'd reach for one when a field is optional and present on a minority of documents, like a deletedAt field only set on soft-deleted records. The modern, more flexible alternative is a partial index with an explicit filter expression, which most teams reach for now instead of sparse since it can express rules sparse's simple "field exists" logic can't.

Medium questions

25

Every document needs a unique _id, and ObjectId is what MongoDB generates for you if you don't supply one. It's fast to build client-side with zero coordination across servers, and it happens to sort in roughly chronological order.

An ObjectId is 12 bytes: a 4-byte timestamp (seconds since the Unix epoch), a 5-byte random value fixed once per process, and a 3-byte counter that starts random and increments per document. (Side note: the shell prints it wrapped in ObjectId("..."), which confuses nobody until someone compares it to a plain string and it silently never matches.) The chronological ordering is a handy debugging trick most candidates don't know until an interviewer points it out.

If the filter matches nothing, upsert:true tells MongoDB to insert a new document instead of doing nothing, built from the filter's equality conditions plus whatever the update document specifies.

It's the standard fix for the "get or create" race. Without it, you'd query for a document, get nothing back, then insert one yourself, and if two requests do that at the same moment you can end up with two documents that should have been one. Upsert makes the check and the write a single atomic operation, so the race disappears instead of just getting less likely.

Without $elemMatch, MongoDB is happy to match a query for subject "math" and score over 90 even if the math entry scored 60 and a different entry, science at 95, is what satisfied the score condition. Each condition gets checked against the array independently unless told otherwise.

javascript
db.students.find({
  scores: {
    $elemMatch: { subject: "math", score: { $gt: 90 } }
  }
});

$elemMatch forces both conditions onto the same array element. It fixes a bug that's genuinely hard to spot in code review, because the query runs clean and returns results. They're just occasionally the wrong ones.

They sound like they should mean the same thing, and they don't. {field: null} matches documents where the field is literally set to null and documents where the field is missing entirely, since MongoDB treats a missing field as equivalent to null for equality matching. {field: {$exists: false}} matches only documents where the key genuinely isn't present, whether or not null would even be a valid value there.

This is a common gotcha coming from SQL, where NULL and "column absent" aren't really the same idea. If you need to tell "set to null on purpose" apart from "never set at all," $exists is the only one of the two that can.

A compound index is a single index built across more than one field, up to 32 fields in one index. The order those fields are declared in isn't cosmetic. It decides which query shapes the index can actually serve.

javascript
db.orders.createIndex({ status: 1, createdAt: -1, total: 1 });

This index efficiently supports queries filtering on status alone, on status and createdAt together, or on all three fields. It can't efficiently support a query filtering on createdAt or total without status, because a compound index only helps a query that uses a left-to-right prefix of its declared fields (MongoDB docs, compound indexes).

explain("executionStats") shows the actual query plan MongoDB chose to run: which index it used, if any, how many documents it examined versus how many it returned, and how long each stage took.

javascript
db.orders.find({ status: "pending" }).explain("executionStats");

IXSCAN means MongoDB walked an index to find matches. COLLSCAN means it had no usable index and scanned every document to check it against the filter. A COLLSCAN on a few hundred documents is a non-issue. The same COLLSCAN on 40 million documents is the entire performance problem, and it's usually the first thing worth checking once someone says "this query got slow."

A query is covered when every field it filters on and every field it returns already lives inside the index, so MongoDB answers the whole thing from the index structure without ever touching the document itself.

Skipping the document fetch matters because that's a separate read from a separate part of storage, and satisfying a query purely from a smaller index is measurably faster at scale. The catch: the moment your projection asks for one field outside the index, the query stops being covered and MongoDB fetches full documents anyway. Covered queries are more fragile in practice than most people expect.

A $match stage sitting at the very start of a pipeline can use an index and shrink the working document set before any expensive stage ever touches it. The same $match placed after a $project or $group has already lost that chance, since earlier stages reshaped the documents and MongoDB can't map them back to an index anymore.

javascript
db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } }
]);

MongoDB's optimizer will try to push some matches earlier automatically where it can safely do so. Relying on the optimizer to fix a badly ordered pipeline is a weaker answer than just writing it in the right order to begin with.

$group collects documents into buckets defined by an exact key, group by customerId, group by status, the same idea as SQL's GROUP BY. $bucket groups documents into ranges you define yourself, price between 0 and 50, 50 and 100, which makes it the natural pick for a histogram instead of a simple tally.

$bucketAuto is the third option nobody mentions unprompted: it picks boundaries for you to produce roughly N buckets with an even document spread, useful when you want a histogram shape but don't know the right boundaries ahead of time.

MongoDB's own schema design guidance boils down to one line: data that's accessed together should be stored together. If a child document is always read alongside its parent, stays small, and doesn't grow without bound, embed it. If it's queried independently, shared across multiple parents, or could grow into the thousands, reference it with an ObjectId instead.

A user's shipping address: embed it, you always need it with the user and it's never going to have 4,000 of them. Reviews on a high-traffic product page: reference them, because a popular listing can accumulate reviews well past what you'd want inside one 16MB document, and you'll usually want to paginate them separately anyway.

Secondaries send heartbeats to each other roughly every two seconds. Once a primary misses heartbeats for about 10 seconds by default, the remaining voting members mark it unreachable and call an election.

One secondary campaigns and asks the others for votes. Whichever candidate collects a majority first becomes the new primary. Under default settings the whole thing, detecting the failure and completing the election, typically resolves in around 12 seconds, though a slow network or an unlucky vote split can stretch that longer. No writes are accepted anywhere until a new primary exists, though reads can keep going if a driver's configured to read from secondaries.

Replication isn't instant. A secondary applies operations from the primary's oplog slightly after they happened, and under load or a slow network, that lag can stretch from milliseconds to several seconds or more. Reading from a secondary means you can read data that's already stale by the time you see it.

There's a sharper version of this risk too: if a primary accepts a write, goes down before it replicates, and a different secondary gets elected, that unreplicated write can get rolled back entirely once the old primary rejoins. It's rare, but it's the reason "read from secondaries for scale" isn't a free lunch. You're trading consistency for read throughput.

The shard key is the field, or fields, MongoDB uses to split a collection's data into chunks and spread those chunks across shards. It determines which physical shard a given document actually lives on.

For years the honest answer was no, not without a manual dump-and-reload under real operational pain. MongoDB 5.0 added live resharding, which lets you change a shard key on a running cluster without downtime, but it's still a heavyweight background job that reads and rewrites the entire collection. Calling it easy would be a stretch. It's possible now. It's expensive always.

Yes, since version 4.0 for replica sets, extended to sharded clusters in 4.2. A transaction can span multiple documents, multiple collections, and, since 4.2, multiple databases, with real snapshot isolation and full rollback on failure.

javascript
const session = client.startSession();
await session.withTransaction(async () => {
  await accounts.updateOne({ _id: fromId }, { $inc: { balance: -amount } }, { session });
  await accounts.updateOne({ _id: toId }, { $inc: { balance: amount } }, { session });
});

The catch is cost, not correctness. Transactions hold resources for their entire duration, default to a 60-second maximum runtime before MongoDB kills them, and cost noticeably more on a sharded cluster where coordinating a commit across shards adds real network round trips. Needing one on every write is usually a sign the schema should have embedded that data together in the first place, not a sign transactions are broken.

A partial index only indexes documents matching a filter expression you define, which is more flexible than a sparse index's fixed "field exists" rule. You could build a partial index on email that only applies where {status: "active"}, giving you a fast lookup for active users without wasting index space on the millions of inactive accounts sitting in the same collection.

Sparse is really just one special case of this: "index the field if it exists" is one specific filter. MongoDB's own documentation now recommends partial indexes over sparse ones, because partial indexes can express things sparse can't, like a unique constraint that only applies to non-deleted documents, a genuinely common real-world need. You want emails unique among active users, but you don't want a soft-deleted account's old email permanently blocking a new signup from reusing it.

A multikey index is automatically what MongoDB creates when you index a field holding an array, one index entry per array element instead of one entry per whole array, which is what makes a query like {tags: "urgent"} fast even though tags is an array.

The catch: you cannot build a compound index with more than one array field. Trying to index {tags: 1, categories: 1} where both are arrays gets rejected outright, because the index would need to cross every element in tags against every element in categories, and that combinatorial blowup makes the index unusable and also breaks the guarantee about which array element actually matched. This is a real data modeling constraint, not a minor limitation. If you need two independent array fields queried together efficiently, you often have to restructure, maybe flattening a specific combination into its own field, or splitting the data into a separate collection entirely.

$unwind takes a document with an array field and produces one output document per array element, duplicating every other field along the way. It's how you do per-element aggregation, like summing quantities across order line items across a whole orders collection.

The gotcha is that unwinding a large array multiplies your document count, and if that array has thousands of elements, a modest input collection can explode into a huge intermediate result flowing through the rest of the pipeline. Follow that with a $group that has to buffer results, or a $sort, and you can blow past in-memory limits fast. The usual fix is filtering both before and after the unwind, using $filter on the array itself before unwinding if you only care about a subset of elements, so you're not unwinding elements you're going to discard two stages later.

$out completely replaces the target collection with the pipeline's output, atomically swapping it in. If your pipeline produces zero documents, you've just wiped the destination collection out. It always overwrites, never merges, and it can't target a sharded collection.

$merge is more flexible: it can upsert, merge fields into existing documents, or run custom per-document logic through whenMatched: pipeline to decide exactly how new and old data combine, and it can write into a sharded collection. It's the right tool for incremental materialized views, like recomputing daily aggregates and merging them into a running rollup without touching documents that didn't change, whereas $out is closer to "rebuild this collection from nothing every time."

A change stream is a cursor you open against a collection, database, or the whole deployment that gives you a live feed of inserts, updates, deletes, and other operations as they happen, backed by the oplog. Instead of polling every N seconds for anything with an updatedAt newer than your last check, you get pushed events in order, in near real time, with a resume token that lets you pick back up exactly where you left off if the connection drops.

Polling has two real problems this solves. It either misses fast changes between poll intervals, if a document gets updated twice between polls you only ever see the final state, or it wastes resources scanning for changes that never happened. Change streams expose the actual delta, not just current state, so you can build real-time notifications, cache invalidation, or cross-service sync without hammering the database with repeated queries. The tradeoff is they need a replica set or sharded cluster, they don't run against a single standalone node, and resuming after a long disconnect can fail if the oplog has already rolled past your resume token.

Any stage that has to hold data in memory, $sort, $group, $bucket, is capped at 100MB by default. If a single stage tries to exceed that, the pipeline throws an error immediately rather than silently degrading, so you find out in testing rather than getting a query that's mysteriously slow in production.

The standard fix is passing allowDiskUse: true to the aggregate call, which lets a stage that hits the limit spill to temporary files on disk instead of failing. It works, but it's noticeably slower than staying in memory, so treat it as a safety valve for occasional large batch jobs, not something to lean on for a hot path query running on every page load. If you're hitting this limit regularly on something that needs to be fast, that's usually a sign to push more filtering earlier with $match, or restructure so the sort happens on an indexed field MongoDB can use directly instead of an in-memory sort.

Ordered bulk writes, the default, execute operations in the exact sequence you gave them and stop at the first failure, leaving everything after that point unexecuted. That's useful when later operations depend on earlier ones succeeding, like inserting a parent document and updating it in the same batch.

Unordered bulk writes execute in whatever order MongoDB chooses, potentially in parallel, and keep going even when some operations fail, then return a single result with every error collected. That's usually what you want for bulk imports or independent operations where one bad record shouldn't block the other 9,999 in the batch, and it's often faster because the server isn't forced to serialize execution. The tradeoff is you have to inspect the full BulkWriteError afterward to know what actually succeeded, instead of being able to reason "everything up to index N worked."

arrayFilters lets you target and update specific elements within an array field based on a condition, rather than updating the whole array or only the first match. Before arrayFilters, the positional $ operator could update the first array element matching your query filter, but if you needed to update multiple matching elements at once, or match on a condition outside the top-level query, you were stuck pulling the whole document into application code, modifying the array there, and writing it back, which isn't atomic and is a race condition waiting to happen under concurrent writes.

Updating every line item with status pending inside an order to shipped, in one atomic call:

javascript
db.orders.updateOne(
  { _id: orderId },
  { $set: { "items.$[elem].status": "shipped" } },
  { arrayFilters: [ { "elem.status": "pending" } ] }
)

This runs the whole update server-side, atomically, with no read-modify-write round trip, and it works even when the array has dozens of elements matching different conditions at once.

Index intersection is when the query planner uses two or more separate single-field indexes together to satisfy a query, instead of one compound index covering all the fields. A query filtering on both status and category, with separate indexes on each, might use both and intersect the resulting document ID sets.

In practice, the planner rarely picks this path when a good compound index exists, because intersection has real overhead: scanning through two index structures and computing the intersection in memory, versus a compound index on {status: 1, category: 1} giving you a single sorted scan that's naturally narrower. Index intersection is more of a fallback and a safety net for ad hoc queries you didn't specifically build a compound index for, not something to design your indexing strategy around. If a query actually matters, build the compound index for it deliberately instead of hoping the planner intersects two single-field indexes efficiently.

Local, the default for most reads, returns whatever the node you're reading from currently has, even if that data hasn't replicated to a majority of the set yet. If you read from the primary and it later gets rolled back after losing an election to a node that never had that write, a local read could have returned data that, from the cluster's perspective, effectively never happened.

Majority only returns data acknowledged by a majority of voting members, which guarantees it survives any future primary rollback, at the cost of a small amount of extra latency and staleness, you might be reading data a few dozen milliseconds behind the very latest write. You'd pair majority read concern with majority write concern when you need a genuinely durable, non-rollback-able view, like financial balances, and you're fine trading a bit of freshness for that guarantee.

A wildcard index lets you index an entire subtree of fields you don't know ahead of time, using a pattern like {"$**": 1}, or scoped to a subdocument like {"attributes.$**": 1}. It's built for collections with dynamic or user-defined schemas, where the fields present vary document to document, like a product catalog where a laptop has RAM and screen size and a t-shirt has size and color, and neither set is known at schema design time.

It's not a replacement for regular indexes on fields you know you'll query constantly, it's a broader, less efficient fallback that keeps ad hoc queries against unpredictable fields from doing a full collection scan. The real cost is index size and write overhead, every field under the wildcard gets its own index entry, so a document with 40 attributes creates 40 entries just for that document, and that adds up fast on a large, wide, variable-schema collection. You use it when you genuinely can't predict the query shape in advance, not as a general substitute for deliberate compound indexes.

Hard questions

12

Because a find() and an updateOne() run as two separate operations, and anything can happen to that document in the gap between them, another request updates it, someone deletes it. findOneAndUpdate does the read and the write as one atomic step, so nothing else slips into that gap.

This matters most for anything that reads a value, decides something, then writes a new value: decrementing inventory, claiming a job off a queue, incrementing a rate limiter. Two separate calls create a textbook check-then-act race under load. One atomic call doesn't. The returnDocument option (before or after) decides whether you get the pre-update or post-update version, and picking the wrong one hands your code stale data it thinks is fresh.

ESR stands for Equality, Sort, Range. It's MongoDB's own guideline for ordering fields inside a compound index: equality fields first, sort fields second, range fields last.

Equality fields go first because they narrow the scan to the smallest slice before anything else happens. Sort fields go second so MongoDB can walk that slice already in order, skipping a separate in-memory sort. Range fields go last because a range condition, createdAt greater than some date, can't collapse to one contiguous block the way equality can, it scans a wider span no matter where you put it, so it does the least damage at the end (MongoDB docs, ESR guideline). Candidates recite "equality, sort, range" correctly and then build the index in exactly the wrong order two questions later. Naming a rule and applying it aren't the same skill.

$lookup performs a left outer join against another collection in the same database, matching a field from the input documents to a field in the foreign collection and adding the matches as an array on each document.

javascript
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  }
]);

The cost is the honest answer here. $lookup isn't backed by a cost-based join optimizer the way a mature relational database's join is. It's closer to running a query against the foreign collection per batch of input documents. On an unsharded, well-indexed foreign collection it's fine at moderate scale. Against a sharded foreign collection, or without an index on the foreign field, it gets slow fast, which is exactly the workload that pushes teams toward embedding instead, when the joined data is small and read together constantly.

An array field that grows forever, every event a user triggers, every comment a post gets, embedded directly inside the parent document, is one of MongoDB's most-cited schema antipatterns for a reason that goes beyond the 16MB ceiling (MongoDB docs, schema design antipatterns).

Every time that array outgrows the space originally allocated for the document, the storage engine has to relocate the whole document to a bigger spot on disk instead of appending in place. A document that's grown large and gets rewritten constantly is a common cause of write amplification and fragmentation in a deployment nobody's touched the schema on in two years. The fix is almost always the same: cap the array at a reasonable recent-N, and move full history into its own referenced collection.

It depends entirely on how many comments a post can realistically get, and that's the answer interviewers actually want to hear, not one "correct" schema recited from memory.

json
{
  "_id": "post123",
  "title": "Why we moved off MMAPv1",
  "comments": [
    { "author": "jane", "text": "nice post", "createdAt": "2026-03-01" }
  ]
}

For a small internal blog where posts get a handful of comments, embed them as an array like above, one document read gets you the whole thing. For a post that could go viral and pull in ten thousand comments, reference them instead: a separate comments collection with a postId field, indexed and paginated on its own. Some teams split the difference, embedding the most recent 20 comments for the fast path and referencing the rest behind a "load more" click. That third option is the one most candidates don't think to offer, and it's usually the right one for anything that could go either way.

A good shard key has high cardinality, lots of distinct values so data spreads across many chunks instead of a few giant ones, even write distribution, and it lets most real queries target a single shard instead of scattering across all of them.

The classic mistake is picking a monotonically increasing field, a timestamp or an auto-incrementing counter, as the sole shard key. Every new document has a value higher than the last, so every insert lands on whichever shard holds the top of the range, and that one shard turns into a write hotspot while the rest sit idle. A hashed shard key fixes the hotspot by scattering writes evenly, but it trades away range-query locality: a query for everything between two dates now scatter-gathers across every shard instead of hitting one, since sequential values no longer sit near each other.

WiredTiger keeps its own internal cache, separate from the OS page cache, defaulting to 50% of RAM minus 1GB or 256MB, whichever is bigger. Writes land in this cache first, get written to the write-ahead journal for durability, and get periodically flushed to disk as a checkpoint, by default every 60 seconds or once enough journal data has built up, whichever comes first.

On a clean shutdown, MongoDB flushes a final checkpoint and the data files on disk are fully consistent. On an unclean shutdown, power loss, an OOM kill, a kill -9, the data files reflect the last completed checkpoint, which could be up to 60 seconds stale, but the journal has every write since then. On restart, WiredTiger replays the journal against that last checkpoint to recover anything written after it, so as long as the journal itself wasn't corrupted and the storage layer honored its fsync calls, acknowledged writes aren't lost. What can be lost is anything sitting in cache that hadn't been journaled yet, which is exactly why write concern j:true is a stricter guarantee than the default acknowledgment, and why running on storage that lies about fsync completion, which happens with some cloud block storage under specific configurations, is a real, if rare, source of corruption after a crash.

A chunk gets flagged jumbo when it exceeds the configured chunk size, 64MB by default in most deployments, and the automatic chunk splitter can't split it further. The classic cause is a shard key with low cardinality relative to data volume, like sharding on a status field with three possible values, or a key where a huge number of documents share the exact same value. Splitting a chunk means finding a split point within its key range, and there's no split point possible when every value in that range is identical, so the chunk just keeps growing and eventually can't be divided no matter how large it gets.

The balancer refuses to move jumbo chunks between shards, because migrating a multi-gigabyte chunk in one operation would tie up a huge amount of network and disk I/O and risk the migration timing out or overwhelming the destination shard, so it just gets skipped and sits there, quietly defeating the point of sharding for that key range since that data was never actually distributed. Fixing it after the fact usually means manually splitting with a chosen split point if there's any variance at all in the key, or in the worst case, truly identical key values across a huge chunk, means the shard key choice itself was wrong from the start and the collection needs to be resharded with a better key, which on a large production dataset is a real project, not a quick fix.

First thing I'd check is rs.printSecondaryReplicationInfo() or the replSetGetStatus output to see actual lag in seconds per secondary, and whether it's one node lagging or all of them, since that tells you whether the problem is that specific node or the write load itself.

If it's one node, check its hardware, disk I/O especially. A secondary applies oplog entries by actually executing the write against its own storage engine, so a slower disk, an undersized node, or something else running concurrently, a backup job or an index build, makes it fall behind. Check the network link too, a cross-region secondary over a WAN link is a common culprit. If every secondary is lagging together, the write volume on the primary is likely outpacing oplog application, though modern versions parallelize application across documents and collections to help with exactly this.

Other common causes: a long-running query or index build on a secondary blocking oplog application behind it, secondaries with priority 0 getting deprioritized under resource contention, or one giant batch delete or update on the primary generating a wall of oplog entries that has to replay serially. I'd also check whether readPreference secondary reads are hitting that already-lagging node and adding load that compounds the problem, a nasty feedback loop. The fix depends on the cause: dedicated disk or better hardware for that node, moving heavy secondary-only workloads like backups off replication-critical secondaries, or throttling and batching a giant write instead of firing it all at once.

The config servers hold the cluster's metadata: chunk ranges, which shard owns which chunk, shard key definitions, and sharding version. Every mongos process reads and caches this metadata to route queries to the right shard. If the config server replica set loses its primary and can't elect a new one, say two of three config nodes are down, the metadata becomes read-only. Existing mongos processes keep routing with their cached table, and reads and writes to shards generally keep working as long as they don't need fresh metadata, but anything that needs to update it, chunk splits, chunk migrations, adding a shard, changing a shard key, is blocked. New mongos processes starting up can't get a routing table at all, so they can't serve traffic until the config servers recover.

This is different from a regular data-bearing replica set going down, because the blast radius of config server failure is cluster-wide even though it holds a tiny amount of data. It isn't a smaller version of the same idea, it's a single small replica set every other component depends on for coordination, which is why most production topologies give config servers dedicated, highly available hardware and don't skimp on their replication factor just because the dataset looks small. The cost of it being unavailable is completely out of proportion to its size.

WiredTiger uses optimistic concurrency control with MVCC rather than literal per-document locks, so two writes to different documents in the same collection don't block each other the way MMAPv1's old collection-level lock used to. But you can still hit real contention: if many concurrent writers are all updating the exact same document, a shared "global stats" counter is the classic case, WiredTiger detects the conflict when two transactions try to modify that same document at once, and one of them gets a write conflict error and has to retry.

The driver retries some operations automatically, but under high concurrency on a hot document, throughput visibly collapses as the retry rate climbs, and CPU rises from the storage engine repeatedly re-attempting transactions. To debug it, I'd look at currentOp output while it's happening, filtering for operations stuck waiting, and check serverStatus metrics for transaction rollback and retry counters over time, correlating spikes with specific documents or collections. The real fix is almost always in the data model, not database config: shard the hot counter across N sub-documents and sum them on read, use a sharded-counter pattern with $inc, or move the hot field into its own smaller document so writes to it stop competing with writes to the rest of that entity's data. More write concern or a bigger connection pool doesn't help here, the bottleneck is the document itself, not the connection or acknowledgment layer.

First thing to separate out is whether the write actually completed with majority acknowledgment before the failover, or whether it was still in flight when the primary stepped down. If a write is in flight during an election and the driver never gets an acknowledgment back, retryable writes, on by default in current driver versions, will automatically retry it once a new primary is elected, using a unique transaction identifier so the retry can't double-apply if the original write actually did make it through first. Without retryable writes, or on an older driver, the application has to handle that ambiguity itself, and assuming "wasn't acknowledged, so it didn't happen" is exactly the kind of assumption that produces bugs like this.

Second, I'd check whether the read after the write used a causally consistent session. Without one, even reading with majority read concern from a secondary right after a failover, you can land on a node that hasn't caught up to the newly elected primary's oplog yet, because there's no guarantee your operations are pinned to a consistent view unless you explicitly start a session with causalConsistency: true and thread that same session object through both the write and the read that follows it. That session tracks operation time and cluster time, and any read within it is guaranteed to see the effects of an earlier write in that same session, whichever node it actually lands on.

So the debugging path is checking the write's real acknowledgment status and retry behavior around the failover window in the logs, then checking whether the read used the same causally consistent session as the write, or a separate, unpinned connection that could reach a lagging node. Nine times out of ten in an incident like this, it's the second one, an application that opens a fresh client or session per request instead of threading one session through a logical unit of work.

How to prepare for a MongoDB interview in 2026

Skip another flashcard list of operators. Build one small thing end to end: a two-collection schema (authors and posts, say) with a deliberately wrong shard key or an unbounded embedded array, then run explain() against your own queries and fix whatever it tells you is slow. Watching a COLLSCAN show up where you expected an IXSCAN teaches the ESR rule faster than any page reading it front to back.

Across backend and database-focused mock interviews run through LastRoundAI, the embedding-versus-referencing question is the one candidates most often get half right. They name the tradeoff correctly on the first pass, then can't hold it together once the interviewer changes one variable: what if this array hit 50,000 items, what if two services needed to query the child data on their own. We don't have a clean number for how often that happens, only that it's common enough in review to flag here.

If you'd asked this same set of questions in 2019, sharding would've barely come up outside a handful of unicorn-scale interviews. It shows up a lot more now that Atlas turned sharding into a few clicks instead of a self-managed project, which also means more mid-level candidates get asked about it before they've actually needed it in production. I don't have good data on how that shift plays out for data-engineering-heavy roles specifically. This page is written from application-backend loops, so treat an analytics-heavy MongoDB role as its own separate prep track.

Get the reps in before the real thing

Reading an explain() output is not the same as defending your index choice out loud once an interviewer changes one field in the query. LastRoundAI's mock interview mode runs live coding and system-design rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than stacking up unused. Starter is $19/mo if a handful of sessions a month isn't enough.

Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough backend and database-heavy roles. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

Leave a Reply

Your email address will not be published. Required fields are marked *