The 60 MongoDB questions interviewers actually ask, with direct answers, runnable queries, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
MongoDB is an open-source, document-oriented NoSQL database first released in 2009 that stores data as BSON documents, a binary form of JSON, grouped into collections rather than rows in tables. Its schema is flexible: two documents in one collection can hold different fields, so the shape of your data can change without a migration. It's built as a distributed system, scaling out across machines with sharding and staying available through replica sets. In interviews, MongoDB questions probe data modeling judgment (when to embed versus reference), the aggregation pipeline, indexing strategy, and the consistency trade-offs of a distributed store, not memorized command syntax. This page collects the 60 questions that come up most, each with a direct answer and a runnable query. If you're still building fundamentals, the MongoDB Manual from MongoDB, Inc. is the canonical reference; increasingly the first round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: SQL Tutorial - Full Database Course
Video: SQL Tutorial - Full Database Course (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your MongoDB certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
MongoDB is an open-source, document-oriented NoSQL database. It stores data as flexible JSON-like documents grouped into collections instead of rows in tables with a fixed schema. Because the schema is flexible, you can add fields to one document without touching the rest, and related data often lives together in a single document rather than being spread across joined tables.
The core difference is the data model. A relational database spreads one entity across normalized tables joined at query time; MongoDB often keeps related data together in one document, so a read fetches everything in a single lookup. That flexible schema means two documents in the same collection can have different fields.
Key point: A one-line definition plus the row/document mapping beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: MongoDB In 30 Minutes (Traversy Media, YouTube)
A document is a single record: an ordered set of field and value pairs, stored as BSON and written like a JSON object. A collection is a group of documents, roughly the equivalent of a table in a relational database, except it doesn't impose a fixed set of columns. Documents are what you insert, query, and update.
Collections don't enforce a schema by default, so documents inside one can hold different fields. Every document carries a unique _id that acts as its primary key.
// a single document
{
_id: ObjectId("6512..."),
name: "Asha",
role: "engineer",
skills: ["python", "mongodb"]
}
// insert it into the users collection
db.users.insertOne({ name: "Asha", role: "engineer", skills: ["python", "mongodb"] })Key point: Getting the vocabulary mapping right (document/collection/field) signals you've actually used MongoDB, not just read about it.
BSON is Binary JSON: the binary-encoded format MongoDB uses to store and transfer documents. It looks like JSON to you when you read it, but it's stored as bytes on disk and over the wire, which makes it faster to parse and traverse than text. So JSON is the surface you see and BSON is the storage underneath.
BSON adds types JSON doesn't have, like Date, ObjectId, Decimal128, and binary data, and it encodes length prefixes so the engine can skip fields quickly. So it's richer and faster to traverse than parsing text JSON.
| JSON | BSON | |
|---|---|---|
| Form | Text | Binary |
| Types | String, number, bool, null, array, object | Adds Date, ObjectId, Decimal128, binary, and more |
| Traversal | Parse the whole string | Length-prefixed, skip fields fast |
| Used for | APIs, config | MongoDB storage and wire protocol |
Key point: ObjectId and Date are BSON types, not JSON, is the detail that.
An ObjectId is the 12-byte default value MongoDB generates for the _id field when you don't supply your own. It's unique across a collection, small enough to index cheaply, and generated on the client so you don't need a round trip to get one. That's why it's the default primary key for new documents.
Its bytes encode a 4-byte timestamp, a 5-byte random value per process, and a 3-byte incrementing counter. Because the timestamp comes first, ObjectIds sort roughly by creation time, and you can extract that time with getTimestamp().
const id = ObjectId()
id.getTimestamp() // the creation time embedded in the ObjectId
// sorting by _id sorts roughly by insertion order
db.events.find().sort({ _id: -1 }).limit(5) // 5 most recentKey point: Interviewers like the follow-up 'can you get a creation date from _id?'. Yes, via the embedded timestamp, is the answer they want.
Create with insertOne or insertMany, read with find or findOne, update with updateOne, updateMany, or replaceOne, and delete with deleteOne or deleteMany. Every operation runs against a collection, and the One and Many suffixes decide whether it touches a single matching document or all of them. That naming is consistent across the whole driver API.
Reads take a filter document and an optional projection; updates take a filter plus an update document with operators like $set. The One and Many suffixes control how many matching documents the operation touches.
db.users.insertOne({ name: "Ben", active: true })
db.users.find({ active: true })
db.users.updateOne({ name: "Ben" }, { $set: { role: "admin" } })
db.users.deleteOne({ name: "Ben" })Key point: Reeling off the four verbs is table stakes. The signal is knowing updateOne needs an update operator like $set, not a raw replacement.
Watch a deeper explanation
Video: MongoDB Crash Course (Web Dev Simplified, YouTube)
find() takes a filter document that describes which documents to match and returns a cursor you iterate over the results with, rather than the full result set at once. An empty filter, find({}), returns everything in the collection, and you narrow it by adding fields and query operators to the filter.
A projection is the optional second argument that picks which fields to return. Set a field to 1 to include it or 0 to exclude it. _id is included by default unless you set it to 0.
// match active users, return only name and email
db.users.find(
{ active: true },
{ name: 1, email: 1, _id: 0 }
)Key point: The gotcha they probe: you can't freely mix include (1) and exclude (0) in one projection, except for excluding _id.
Query operators express conditions beyond exact equality, so you can match ranges, lists, and presence instead of one exact value. They're prefixed with a dollar sign and used inside the filter document, with several conditions on one field combined inside that field's value object. Comparison operators are $gt, $gte, $lt, $lte, and $ne.
$in and $nin match against a list of values. $exists tests whether a field is present at all, and $regex matches strings by pattern. Together they cover most of what a WHERE clause does in SQL.
// age between 25 and 40, role is engineer or lead, has an email field
db.users.find({
age: { $gte: 25, $lte: 40 },
role: { $in: ["engineer", "lead"] },
email: { $exists: true }
})Key point: Knowing $in takes an array and $exists takes a boolean, not a value, is the kind of precision that separates users from readers.
insertOne adds a single document and returns its inserted _id. insertMany adds an array of documents in one call, which is far faster than looping insertOne because it batches many writes into a small number of round trips to the server. For bulk loading, that batching is the difference between seconds and minutes.
By default insertMany is ordered: it stops at the first error. Pass { ordered: false } to keep inserting the rest and collect the errors at the end, which is useful for bulk loads where a few duplicates shouldn't halt everything.
db.users.insertMany(
[{ name: "Asha" }, { name: "Ben" }, { name: "Cara" }],
{ ordered: false } // keep going past errors
)$set assigns fields, $unset removes them, $inc increments a number, and $push adds to an array while $addToSet adds only if absent. An update without an operator would replace the whole document, so the operators are how you change part of it.
upsert: true means insert the document if the filter matches nothing, otherwise update it. It turns update into a save-or-create in one call.
db.counters.updateOne(
{ _id: "visits" },
{ $inc: { value: 1 } },
{ upsert: true } // create the counter if it doesn't exist
)Key point: The upsert question is a favorite because it maps to a real pattern (increment-or-create). Have the counter example ready.
An index is a sorted data structure, a B-tree, that stores the values of one or more fields alongside pointers to the documents that hold them. It lets MongoDB find matching documents by seeking into the index instead of scanning the whole collection, which is the single biggest lever on read performance. Every collection indexes _id automatically.
Without an index, a query does a collection scan, reading every document, which is slow at scale. With the right index the query jumps straight to the matches. The _id field is indexed automatically; you add others based on how you query.
// index the email field for fast lookups
db.users.createIndex({ email: 1 })
// see whether a query uses an index or scans
db.users.find({ email: "a@x.com" }).explain("executionStats")Key point: Saying 'without an index it's a collection scan' in those words is exactly the phrase the key signal is.
The concepts line up closely even though the underlying model differs. A table becomes a collection, a row becomes a document, a column becomes a field, a primary key becomes the _id field, and a join becomes either an embedded document or a $lookup stage. Once you internalize this mapping, most SQL intuition carries over.
Knowing the mapping helps you translate a relational design into MongoDB and reason clearly about when embedding data replaces a join.
| SQL | MongoDB |
|---|---|
| Table | Collection |
| Row | Document |
| Column | Field |
| Primary key | _id field |
| JOIN | Embedded document or $lookup |
mongosh is the MongoDB Shell, a command-line JavaScript interface where you connect to a database and run queries interactively, so it's what you'll usually type into during a live interview. Atlas is MongoDB's managed cloud service that runs and scales clusters for you across cloud providers, with backups, monitoring, and a free tier.
An application connects with a language driver (for Python, Node, Java, and more) using a connection string. Many teams run production on Atlas rather than self-hosting because the operational work is handled for them.
Watch a deeper explanation
Video: MongoDB with Python Crash Course - Tutorial for Beginners (freeCodeCamp.org, YouTube)
Flexible schema means MongoDB doesn't force every document in a collection to share the same structure. You can add a field to one document, or give two documents different shapes, without altering the others or running a migration. That flexibility speeds up early development and makes evolving a data model far less painful than an ALTER TABLE.
It isn't truly schemaless, though. Your application still expects certain shapes, so the schema moves into your code and your discipline. MongoDB also offers optional schema validation to enforce rules when you want them.
// same collection, different shapes, both valid
db.products.insertOne({ name: "Pen", price: 2 })
db.products.insertOne({ name: "Book", price: 9, author: "K. Rao", tags: ["study"] })Key point: The mature answer is 'flexible, not schemaless'. Claiming MongoDB needs no schema thinking is a red flag to interviewers.
countDocuments(filter) returns how many documents match a filter by actually counting them, so it's accurate but scales with the match size. estimatedDocumentCount() reads collection metadata for an instant total with no filter, trading accuracy for speed. distinct(field, filter) returns the unique values of a field across the matching documents. Pick based on whether you need exactness.
Prefer countDocuments for accurate, filtered counts; reach for the estimated version only when you need a rough total instantly on a large collection.
db.orders.countDocuments({ status: "paid" })
db.orders.distinct("country", { status: "paid" })sort() orders results by one or more fields, using 1 for ascending and -1 for descending. limit() caps how many documents come back, and skip() jumps past a number of them before returning. They chain onto a find cursor, and the server applies them together so it can stop early once the limit is reached rather than sorting the entire collection.
Together, skip and limit power basic pagination, though skip gets slow on large offsets, which is why cursor-based pagination on _id is preferred at scale.
// page 2, 10 per page, newest first
db.posts.find()
.sort({ createdAt: -1 })
.skip(10)
.limit(10)Key point: If you mention that skip is slow at large offsets and The _id-based alternative, you've turned a beginner question into The production-ready answer.
Matching an array field against a single value matches if any element equals it, so tags: "study" finds documents whose tags array contains "study". $all requires every listed value to be present, and $elemMatch matches when one array element satisfies several conditions at once.
Arrays are first-class in MongoDB, which is a big reason embedding lists works so naturally.
// any element equals "study"
db.products.find({ tags: "study" })
// contains both tags
db.products.find({ tags: { $all: ["study", "office"] } })
// one order line matches both conditions together
db.orders.find({ items: { $elemMatch: { sku: "A1", qty: { $gte: 2 } } } })Key point: The $elemMatch versus plain conditions distinction is a classic trap: without it, conditions can match across different array elements.
A capped collection is a fixed-size collection that keeps insertion order and automatically overwrites its oldest documents once it fills up, working like a ring buffer. You set a maximum size in bytes and optionally a maximum document count when you create it, and MongoDB enforces that ceiling by evicting the oldest entries. Inserts stay fast because there's no fragmentation.
They fit logs and recent-event feeds where you want a rolling window. The trade-off is you can't grow a document past its original size and can't delete individual documents.
deleteMany(filter) removes documents matching a filter and leaves the collection and its indexes in place, so it's a normal write that scales with how many documents match. drop() removes the whole collection, including all indexes, in one fast metadata operation that doesn't scan documents at all. So the two solve different jobs.
remove() is the old shell method replaced by deleteOne and deleteMany. Use the newer methods, and reach for drop() only when you truly want the collection gone.
Use dot notation with the path quoted as a string, like "address.city", to reach into an embedded object at any depth. MongoDB matches the field at that path inside the nested document, and the same syntax works whether the nesting is one level deep or several. You always quote the dotted path because it contains a dot.
Dot notation works in filters, projections, sorts, and updates alike. It's the main way you read and write embedded structure without pulling the whole parent document into your application code.
// document: { name: "Asha", address: { city: "Pune", pin: "411001" } }
db.users.find({ "address.city": "Pune" })
db.users.updateOne({ name: "Asha" }, { $set: { "address.pin": "411002" } })SQL databases store data in tables with a fixed schema and use joins and strict relationships; they favor consistency and structured, related data. NoSQL is an umbrella for databases that relax that model, including document, key-value, wide-column, and graph stores, each trading some structure for flexibility or scale. MongoDB is the document category, keeping related data together in JSON-like documents.
So the choice isn't which is better but which fits the data. MongoDB suits evolving, document-shaped data that scales horizontally; a SQL database suits highly relational data with strong transactional rules.
| SQL | NoSQL (MongoDB) | |
|---|---|---|
| Schema | Fixed, defined upfront | Flexible, per document |
| Data unit | Row in a table | Document in a collection |
| Relations | Joins across tables | Embed or reference |
| Scaling | Vertical mostly | Horizontal (sharding) |
Key point: The mature framing is 'fits different data, not universally better'. Calling one strictly superior is the answer interviewers mark down.
For candidates with working experience: aggregation, indexing judgment, replication, and the modeling decisions that separate users from understanders.
The aggregation pipeline processes documents through an ordered series of stages, where each stage transforms the stream and passes results to the next, like a Unix pipe. It's how you do grouping, joins, reshaping, and analytics that a plain find can't.
Common stages are $match to filter, $group to aggregate, $project to reshape, $sort, $limit, and $lookup to join. Order matters for performance: put $match and $sort early so they can use indexes before the data grows.
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$country", revenue: { $sum: "$amount" } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
])Key point: the key signal is 'put $match first so it uses an index'. That single sentence signals you understand pipeline performance.
$group buckets documents by an _id expression, which is the group key, and computes accumulator values per bucket as it goes. Setting _id to a field groups by that field's value; setting it to null groups every document into one bucket for a grand total. The output has one document per distinct group key.
Accumulators include $sum, $avg, $min, $max, $push (collect values into an array), and $addToSet (collect unique values). The group key can be a single field or a compound object of several fields.
// average and count of orders per customer
db.orders.aggregate([
{ $group: {
_id: "$customerId",
orders: { $sum: 1 },
avgAmount: { $avg: "$amount" }
} }
])Key point: The trap is forgetting that $group's _id is the key you group by. Getting $sum: 1 as a counter right is the giveaway you've written real pipelines.
$lookup does a left outer join to another collection in the same database. You give it the foreign collection, the local field, the foreign field, and the name of the array where matches land. Each input document gets an array of its matched documents.
Its limits shape modeling: joins run at query time and can be slow across large collections, they only reach within one database, and heavy $lookup use often signals data that should have been embedded. MongoDB favors embedding over joining for read-heavy paths.
db.orders.aggregate([
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
} }
])Key point: The follow-up is 'when would you embed instead of $lookup?'. Answer: when the data is read together, bounded, and not shared.
Embed when related data is read together, belongs to one parent, and stays bounded in size: order line items, a user's address, a post's handful of tags. One read then fetches everything at once, no join needed, and updates to that data stay atomic because it all lives in a single document.
Reference when the related data is large, grows without bound, is shared across many parents, or changes on its own lifecycle: comments that could reach thousands, a product referenced by many orders. This is the single most tested MongoDB modeling decision.
| Situation | Choose | Why |
|---|---|---|
| Read together, bounded | Embed | One read, no join, atomic update |
| Unbounded growth | Reference | Avoid the 16 MB document limit |
| Shared across many parents | Reference | Update once, no duplication |
| Independent lifecycle | Reference | Change without touching the parent |
Key point: This is the modeling question. Frame it as bounded-and-together = embed, unbounded-or-shared = reference, and you sound like you've shipped MongoDB.
Watch a deeper explanation
Video: Data Modeling with MongoDB (MongoDB, YouTube)
A compound index indexes several fields in a set order. It supports queries that filter on a prefix of those fields, so an index on { a: 1, b: 1 } helps queries on a, and on a and b, but not on b alone.
The order also controls sorting: the index can serve a sort only if it follows the index's field order and direction. Getting field order right (the ESR rule, Equality, Sort, Range) is a common intermediate topic.
// good for: filter by status, then sort by createdAt
db.orders.createIndex({ status: 1, createdAt: -1 })
// this query uses the index fully
db.orders.find({ status: "paid" }).sort({ createdAt: -1 })Key point: Naming the ESR order (Equality, Sort, Range) for compound index fields is the detail that marks a strong intermediate candidate.
Besides single-field and compound indexes, MongoDB has multikey indexes (automatic when you index an array field, indexing each element), text indexes for keyword search, geospatial indexes (2dsphere) for location queries, hashed indexes for even sharding distribution, and partial or TTL indexes.
A TTL index deletes documents after a set time, which is how you expire sessions or logs automatically. A partial index only indexes documents matching a filter, saving space.
explain("executionStats") shows how MongoDB ran a query, including the winning plan, the number of documents examined, and the time taken. The key line is the stage name: IXSCAN means it used an index to seek, while COLLSCAN means it scanned the whole collection document by document. That one word tells you most of what you need.
You want IXSCAN, and you want docsExamined close to the number returned. A large gap means the index isn't selective enough or is missing, so you add or reorder one.
db.orders.find({ status: "paid" })
.explain("executionStats")
// look at: winningPlan.stage (IXSCAN vs COLLSCAN),
// executionStats.totalDocsExamined vs nReturnedKey point: If you say 'I'd check explain for IXSCAN versus COLLSCAN and compare docsExamined to nReturned', you've shown the exact debugging loop the question needs.
A replica set is a group of MongoDB nodes holding the same data: one primary that takes all writes, and secondaries that copy the primary's oplog to stay in sync. It gives you high availability and durability, because if any single node fails the data still lives on the others and the cluster keeps serving requests.
If the primary goes down, the remaining members hold an election and a secondary is promoted to primary automatically, usually within seconds. An odd number of voting members avoids ties; arbiters can vote without holding data.
// on the primary, inspect the set
rs.status() // members, states, who is primary
rs.conf() // configuration and votesKey point: The clean coverage names the three pieces: primary takes writes, secondaries replicate the oplog, election on failure. Say those and the follow-ups get easier.
Write concern controls how many replica set members must acknowledge a write before it's considered successful. w: 1 waits for the primary; w: "majority" waits for a majority, which protects the write from being rolled back in a failover; w: 0 waits for nothing (fire and forget).
Read concern controls the consistency of data you read. "local" returns the node's latest data, "majority" returns only data acknowledged by a majority (so it can't be rolled back). Together they let you trade latency for durability.
| Setting | Waits for | Trade-off |
|---|---|---|
| w: 0 | Nothing | Fastest, may lose the write |
| w: 1 | Primary only | Fast, can roll back on failover |
| w: "majority" | Majority of members | Durable, higher latency |
Key point: Explaining that w: "majority" survives a failover while w: 1 might not is the durability insight interviewers are testing for.
Yes. Since version 4.0 MongoDB supports multi-document ACID transactions across a replica set, and since 4.2 across sharded clusters. A single-document write is already atomic on its own, so you only need a transaction when several documents must change together all-or-nothing, such as debiting one account and crediting another. Reserve them for that genuine case.
The advice the question needs: model so most operations touch one document (embedding helps here), because transactions carry real overhead and shouldn't be the default reach.
const session = db.getMongo().startSession()
session.startTransaction()
try {
accounts.updateOne({ _id: "a" }, { $inc: { bal: -100 } }, { session })
accounts.updateOne({ _id: "b" }, { $inc: { bal: 100 } }, { session })
session.commitTransaction()
} catch (e) {
session.abortTransaction()
}Key point: The trap is saying MongoDB has no transactions (outdated) or reaching for them constantly. 'Single-doc writes are atomic; embed to avoid needing transactions' is the mature take.
The aggregation pipeline is the recommended tool for data processing and analytics in MongoDB. It's faster because its stages run in optimized native code and can take advantage of indexes, and it's easier to read as a sequence of declarative stages than a block of imperative logic. So it wins on both performance and maintainability.
Map-reduce is the older mechanism that runs JavaScript and is slower and harder to maintain. MongoDB deprecated map-reduce in favor of aggregation, so 'I'd reach for the aggregation pipeline' is the expected answer.
You create a text index on one or more string fields, then query with the $text operator and a $search string. MongoDB tokenizes the text, applies stemming, and removes common stop words, so a search for "running" also matches documents containing "run". That's real keyword search rather than an exact substring match.
You can rank results by relevance using the textScore meta field. For richer search (typo tolerance, facets, autocomplete) teams often move to Atlas Search, which is built on Lucene.
db.articles.createIndex({ title: "text", body: "text" })
db.articles.find(
{ $text: { $search: "mongodb indexing" } },
{ score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })MongoDB supports optional schema validation with JSON Schema rules attached to a collection. You declare required fields, allowed types, numeric ranges, and string patterns, and MongoDB rejects or warns on any write that breaks them. So you keep the flexible model while enforcing the invariants that actually matter, like an email always being a string.
You set validationLevel (strict or moderate) and validationAction (error or warn). This gives you guardrails at the database layer without giving up the flexibility to add new optional fields.
db.createCollection("users", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["email"],
properties: {
email: { bsonType: "string", pattern: "@" },
age: { bsonType: "int", minimum: 0 }
}
} }
})Key point: Bringing up JSON Schema validation in practice answers the classic 'but doesn't schemaless cause chaos?' challenge before it's asked.
A unique index rejects any write that would create a duplicate value for the indexed field, which is how you enforce a rule like one account per email address. You create it by passing the unique option when building the index, and it applies to every existing and future document in the collection. It's an index, so it also speeds up lookups.
A partial unique index only enforces uniqueness on documents matching a filter, which solves the null problem: enforce unique emails only where an email exists, so many documents without an email don't collide.
db.users.createIndex(
{ email: 1 },
{ unique: true, partialFilterExpression: { email: { $exists: true } } }
)Yes. Any write to a single document is atomic, including updates that touch several fields or nested arrays inside that one document, so other readers never see a half-applied change. There's no locking or transaction to set up: the atomicity comes for free at the document level, which shapes how you model data.
This is a big reason embedding is idiomatic: if related data lives in one document, updating it stays atomic without a transaction. Atomicity across multiple documents is what needs an explicit transaction.
Key point: Connecting single-document atomicity to the embedding pattern shows you understand why MongoDB models the way it does, not just the rule.
Change streams let your application subscribe to a real-time feed of inserts, updates, deletes, and replacements on a collection, a database, or the whole deployment. They read from the same oplog that powers replication, so they're reliable and resumable: if your listener disconnects, it can resume from where it left off using a resume token rather than missing events.
Use them to trigger downstream work without polling: sync a cache or search index, push notifications, or drive an event pipeline. They require a replica set because they depend on the oplog.
const stream = db.orders.watch([
{ $match: { operationType: "insert" } }
])
for (const change of stream) {
notifyWarehouse(change.fullDocument)
}The positional operator $ updates the first array element matched by the query, so you change one element without knowing its index. For updating many matching elements at once, use the filtered positional operator with an identifier and arrayFilters, which names a condition each target element must meet. Both avoid rewriting the whole array.
This lets you change one line item's quantity or flag several matching subdocuments without pulling the array into your application, changing it, and writing it back.
// bump the quantity of the matched item
db.orders.updateOne(
{ _id: 1, "items.sku": "A1" },
{ $set: { "items.$.qty": 5 } }
)
// update every low-stock item using arrayFilters
db.orders.updateOne(
{ _id: 1 },
{ $set: { "items.$[low].flagged": true } },
{ arrayFilters: [{ "low.qty": { $lt: 2 } }] }
)MongoDB fits poorly when your workload is heavily relational with many complex joins and strict multi-table constraints, or when you need mature cross-entity transactions as the norm rather than the exception. A relational database like PostgreSQL is a better home for that.
It's also not the right first tool for pure caching (use Redis) or for extreme write-throughput multi-region logging where a wide-column store like Cassandra shines. Saying MongoDB isn't universal is a maturity signal.
| Need | Better fit | Why |
|---|---|---|
| Complex joins, strict constraints | PostgreSQL | Relational model, mature transactions |
| Sub-millisecond cache | Redis | In-memory key-value speed |
| Extreme write throughput, multi-region | Cassandra | Leaderless, write-optimized |
Key point: Interviewers plant this to check for hype resistance. Naming a real weakness and the right alternative scores higher than defending MongoDB for everything.
skip and limit read fine for early pages, but skip(N) still walks past N documents every time, so deep pages get linearly slower and heavier as the offset grows. On a large collection, page 5000 pays for scanning everything before it.
The scalable fix is range-based (cursor) pagination: sort by an indexed field like _id or createdAt, and fetch the next page with a $gt filter on the last value you saw. It stays fast at any depth because it jumps straight into the index.
// slow at deep offsets
db.posts.find().sort({ _id: 1 }).skip(50000).limit(20)
// fast: remember the last _id and seek past it
db.posts.find({ _id: { $gt: lastSeenId } }).sort({ _id: 1 }).limit(20)Key point: Naming range-based pagination as the fix for slow deep skips is the intermediate-to-senior tell. Interviewers hear it and stop probing.
advanced rounds probe internals, scaling design, and production scars. Expect every answer here to draw a follow-up.
WiredTiger is MongoDB's default storage engine. It uses document-level concurrency control, so many writes to different documents in one collection proceed in parallel instead of blocking each other, and it applies compression (snappy by default) to shrink data on disk. Those two properties are why write throughput and storage efficiency both improved when it replaced the older engine.
It's an MVCC engine: readers see a consistent snapshot without blocking writers. It keeps a write-ahead journal for crash recovery and checkpoints data periodically, which is how a node recovers cleanly after an unclean shutdown.
Key point: Naming document-level concurrency and MVCC snapshots is the depth marker here. Older MMAPv1 used collection-level locking, worth a one-line contrast.
The oplog (operations log) is a special capped collection on the primary that records every write as an idempotent operation. Secondaries continuously tail the oplog and replay those operations against their own copy of the data to stay in sync, and this replication is asynchronous by default, so a secondary can trail the primary by a short amount.
Because oplog entries are idempotent, replaying the same entry twice is safe, which makes recovery and resync reliable. The oplog's fixed size sets the replication window: a secondary that falls too far behind past the oplog's history must do a full resync.
Key point: The follow-up is often 'what happens if a secondary lags past the oplog window?'. Answer: it can't catch up incrementally and needs a full initial sync.
MongoDB is a CP-leaning system: during a network partition it favors consistency over availability. Writes go only to the primary, so if a partition isolates the primary from the majority, the side that lost quorum can't accept writes until a new primary is elected on the majority side. Consistency is protected at the cost of some availability.
You tune the trade-off with read and write concern. w: "majority" and read concern "majority" push toward strong consistency; reading from secondaries with a relaxed read concern trades consistency for availability and lower latency. Naming that tunability is The production-ready answer.
Key point: Don't just say 'CP'. Show you know consistency is tunable per operation through read and write concern, that's the follow-up they're waiting for.
A covered query is answered entirely from an index: every field the query filters, sorts, and returns is in one index, so MongoDB never reads the documents. It's the fastest kind of read, and you check for it by seeing totalDocsExamined at zero in explain.
Index intersection is when MongoDB uses two separate indexes for one query and combines the results. It exists but is usually beaten by a single well-designed compound index, so the practical advice is to design the compound index rather than rely on intersection.
// index covers this query: filter on status, return only createdAt
db.orders.createIndex({ status: 1, createdAt: 1 })
db.orders.find({ status: "paid" }, { createdAt: 1, _id: 0 })
// explain shows totalDocsExamined: 0 -> coveredKey point: a covered query needs _id: 0 in the projection (unless _id is in the index) is the small detail that.
Filter and sort early: put $match and $sort as the first stages so they can use indexes and shrink the document stream before the expensive stages run. Project away unneeded fields early with $project or $unset to cut the amount of data each later stage has to carry through the pipeline. Order is the biggest lever here.
Watch the memory limit: a pipeline stage that exceeds 100 MB fails unless you allow disk use, so a $sort or $group on huge data should be indexed or bounded first. Run explain on the pipeline to see which stages use indexes and where documents balloon.
Key point: The one-liner that lands: 'push $match to the front so the pipeline starts from an index scan, not a full collection'.
Read preference tells the driver which replica set members can serve reads: primary (the default), primaryPreferred, secondary, secondaryPreferred, or nearest. Reading from secondaries spreads read load off the primary and can lower latency when a secondary sits geographically closer to the client, which is why read-heavy analytics often route there deliberately.
The risk is staleness: because replication is asynchronous, a secondary can lag behind the primary, so a read there might miss a just-written value. Use secondary reads for analytics or tolerant workloads, not for read-after-write consistency.
Key point: The trap is treating secondary indicates free scaling. Naming replication lag and read-after-write staleness is what separates experience from theory.
The big ones: unbounded arrays that grow toward the 16 MB document limit (the massive-array anti-pattern), bloated documents you always slice down at query time, and case-insensitive queries served by an unanchored regex instead of a collation-backed index. Each one quietly degrades either memory or query speed as the data grows past its early size.
Also: too many collections or indexes that waste memory, and separating data you always read together into different collections, which forces needless $lookups. The fix is usually the outlier or bucket pattern for growing data.
Key point: Referencing the outlier and bucket patterns by name shows you've read MongoDB's data-modeling guidance, not just used the query API.
The bucket pattern groups many small related documents, like time-series readings, into one document per time window or fixed count, instead of storing one document per reading. Each bucket holds an array of the individual measurements plus summary fields such as a count or an average, so a window of data lives together and reads in a single fetch.
It cuts document count and index size and keeps a window of data together for fast reads, which is why it fits sensor data, logs, and metrics. MongoDB's native time-series collections automate this pattern under the hood.
// one bucket document per device per hour
{
device: "sensor-7",
hour: ISODate("2026-07-04T10:00:00Z"),
count: 60,
readings: [ { t: 0, v: 21.4 }, { t: 1, v: 21.5 } /* ... */ ]
}The MongoDB driver keeps a pool of already-open connections per server and reuses them across requests instead of opening a fresh one per query, because establishing a connection (TCP plus TLS plus auth) has a real cost. You configure the pool size through maxPoolSize on the client, and the driver hands out and returns connections as requests come and go.
It matters because a serverless or high-concurrency app that creates a new client per request can exhaust the server's connection limit and add latency. The rule is one shared client for the process lifetime, sized to your concurrency.
Key point: The production scar interviewers probe: creating a new MongoClient per request. Saying 'reuse one client, tune maxPoolSize' shows you've been burned by this.
A client session groups related operations so MongoDB can offer causal consistency: within the session, a read is guaranteed to see the effect of a preceding write, even across primary and secondary reads. It solves the read-your-own-writes problem when reading from secondaries.
Sessions also carry transactions and retryable writes. The practical takeaway is that causal consistency, scoped to a session, lets you get read-after-write guarantees without forcing every read to the primary.
Retryable writes let the driver automatically retry a single-document write once if a transient network error or a primary failover interrupts it. MongoDB attaches a unique transaction number to the write so the server can recognize a duplicate and won't apply the same operation twice, which keeps the retry idempotent even if the first attempt actually succeeded before the connection dropped.
This matters during failovers: without it, an interrupted write leaves you unsure whether it applied. It's on by default in modern drivers and is one reason to design writes to be safely retryable.
Time-series collections are a native collection type optimized for measurements recorded over time. You declare the time field and an optional metadata field when creating one, and MongoDB automatically buckets and compresses the data behind the scenes, applying the bucket pattern for you so you don't hand-roll it. So you get the efficiency without managing the layout yourself.
Compared with a normal collection, they use far less storage and speed up time-ranged queries, at the cost of some flexibility (the shape is fixed around time and metadata). They fit metrics, IoT, and financial ticks.
db.createCollection("weather", {
timeseries: {
timeField: "ts",
metaField: "station",
granularity: "minutes"
}
})Because the schema is flexible, you migrate lazily: deploy code that reads both old and new shapes, write new documents in the new shape, and backfill old documents in batches with an aggregation or a background job. No table-lock migration needed.
Add a schemaVersion field so code can branch on shape, keep the app tolerant of both during the transition, and only remove the old-shape handling once the backfill completes. This dual-read, dual-write, backfill sequence is the standard playbook.
Zero-downtime schema migration steps
Flexible schema is what makes this online migration possible without a locking table alter.
Key point: Interviewers love this because it tests real production judgment. Naming the dual-read, dual-write, backfill order is the exact answer they grade.
Reproduce and measure first: run the query with explain("executionStats") and compare totalDocsExamined to nReturned. A COLLSCAN or a huge examined-to-returned ratio points at a missing or unselective index. Check whether an index was dropped, or whether data growth pushed the query past what the current index covers.
Then correlate with what changed: recent writes skewing distribution, an index that no longer fits in RAM (so it pages from disk), or a shard hotspot. Fix at the right layer (add or reorder an index, adjust the shard key strategy, add a covering projection) and confirm with explain again.
Key point: the loop: observe with explain, hypothesize, fix, confirm is the technical point. tools support the evidence.
$setWindowFields computes values over a window of related documents, like a running total, a moving average, or a rank, without collapsing the documents the way $group does. Each input document keeps its identity and gains the computed field, which is the difference from $group. It's the aggregation equivalent of SQL window functions, added in version 5.0.
You partition by a field, sort within the partition, and define the window (by document count or a range). It replaces awkward self-joins for running totals and rankings.
db.sales.aggregate([
{ $setWindowFields: {
partitionBy: "$region",
sortBy: { date: 1 },
output: { runningTotal: {
$sum: "$amount",
window: { documents: ["unbounded", "current"] }
} }
} }
])Turn on authentication and role-based access control so every connection authenticates and gets least-privilege roles; never run open to the internet without auth (the classic breach cause). Bind to private networks or use IP allowlists, and enable TLS for data in transit.
Add encryption at rest, audit logging, and for the most sensitive fields, client-side field level encryption so the database never sees plaintext. On Atlas most of this is default or one toggle; self-hosted, it's on you.
Key point: Leading with 'authentication on, not exposed to the internet' signals you know the single most common MongoDB breach cause: an open, unauthenticated instance.
Clarify scale first (reads per second, total links, analytics needs), then model one collection of links keyed by the short code as _id, with the long URL and metadata embedded. Using the short code as _id makes the redirect a single primary-key lookup, the fastest read MongoDB offers.
For scale, the read path is cache-friendly (put hot codes in Redis in front), and if the collection outgrows one server, shard on a hashed short code so reads target one shard and writes spread evenly. Store click analytics separately using the bucket pattern so the link document stays small.
// link document: short code is the _id, so redirect is a PK lookup
{
_id: "a1B9x",
url: "https://example.com/very/long/path",
createdAt: ISODate("2026-07-04T00:00:00Z"),
clicks: 0
}
db.links.findOne({ _id: "a1B9x" }) // single-key redirect lookupKey point: Structuring the answer clarify, model, read path, scale is what this question grades. Using the short code as _id for a PK-lookup redirect is the insight that lands.
The working set is the portion of your data and indexes that queries actually touch regularly. MongoDB (through WiredTiger) caches this in RAM, and performance stays fast as long as the working set fits in memory. Once it spills to disk, reads have to page in from storage and latency climbs sharply.
This is why indexes that don't fit in RAM hurt, and why a query that suddenly scans cold data slows down. Sizing RAM to the working set, not the whole dataset, is the practical capacity rule, and monitoring cache eviction tells you when you've outgrown it.
Key point: Saying 'size RAM to the working set, not the whole dataset' is the capacity-planning insight that separates operators from users.
$facet runs several independent sub-pipelines over the same input documents in a single stage, returning each branch's result under its own key. It lets you compute totals, a paginated slice, and a set of facet counts from one pass instead of firing several separate queries.
It's how you build a search results page in one round trip: one branch for the page of documents, one for the total count, and one for category counts. The cost is that $facet can't use an index after it runs, so a $match should precede it.
db.products.aggregate([
{ $match: { active: true } },
{ $facet: {
page: [ { $sort: { price: 1 } }, { $limit: 20 } ],
total: [ { $count: "count" } ],
byBrand:[ { $group: { _id: "$brand", n: { $sum: 1 } } } ]
} }
])Key point: The follow-up is 'why $match before $facet?'. Answer: $facet drops index use, so filter first while an index can still help.
MongoDB wins when your data is document-shaped, your schema evolves often, and you need to scale writes across many machines. It trades the relational model's built-in joins and multi-table constraints for a flexible schema and easy horizontal scaling. Where it loses is heavy multi-entity transactional work and complex ad hoc joins, which a relational database like PostgreSQL still handles more naturally. Against key-value stores like Redis it offers rich queries and secondary indexes; against wide-column stores like Cassandra it offers a more expressive query language at the cost of Cassandra's write-optimized, leaderless scaling. Naming these trade-offs out loud is itself an interview signal: it shows you pick a database on merits, not habit.
| Database | Data model | Best at | Watch out for |
|---|---|---|---|
| MongoDB | Documents (BSON) | Evolving schemas, nested data, horizontal scale | Complex cross-collection joins, many-entity transactions |
| PostgreSQL | Relational tables | Joins, constraints, strong transactions | Rigid schema, harder horizontal scaling |
| Redis | Key-value (in memory) | Caching, low-latency lookups, counters | Limited query surface, memory-bound |
| Cassandra | Wide-column | Massive write throughput, multi-region | Query-first modeling, no ad hoc queries |
Prepare in layers, and practice out loud. Most MongoDB rounds move from concept questions to live query writing to a data modeling or scaling discussion, so rehearse each stage rather than only reading answers.
The typical MongoDB interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These MongoDB questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works