MERN Stack · Interview Prep

MongoDBinterview questions & answers

192+ real MongoDBinterview questions with model answers, plus free lessons to learn the concepts. Prepare in English & Hinglish, then practise with an AI mock interview.

21 topics · 192+ questions

Lessons available in both languages

What you’ll learn

  • What is MongoDB?
  • SQL vs NoSQL
  • Documents & collectionsFree account
  • BSON & data typesFree account
  • Insert (create)Free account
  • Find & projectionFree account
  • Query operatorsFree account
  • UpdateFree account
  • DeleteFree account
  • Sort, limit & paginationFree account
  • IndexesFree account
  • Aggregation pipelineFree account
  • Schema design (embed vs reference)Free account
  • Mongoose (ODM) introFree account
  • Mongoose schema & modelFree account
  • Mongoose CRUD & populateFree account
  • TransactionsFree account
  • Interview recapFree account
  • Project 1: CRUD app
  • Project 2: Blog schemaFree account
  • Project 3: Aggregation reportFree account

What is MongoDB?

Think of a filing cabinet where each customer gets their own folder. One folder can hold a full page of details, another just a sticky note — nobody forces every folder to have the exact same printed form. You open a folder and everything about that customer is right there, together. Now group all the customer folders into one drawer, and put several drawers into one cabinet.

In MongoDB, that folder is a document — a JSON-like object of key/value pairs (stored internally as BSON, Binary JSON). A drawer full of documents is a collection (roughly a table), and the cabinet holding all the collections is a database. The big idea: MongoDB is a NoSQL document database — it stores data as flexible documents instead of rigid rows in tables. Two documents in the same collection can have different fields, because the schema isn't enforced at the database level. Every document also gets a unique _id automatically.

🌍 Real-world example: In a users collection, one document is { _id: ObjectId('...'), name: 'Aisha', age: 25, skills: ['react','node'], address: { city: 'Delhi' } }. Notice it nests an array and a sub-object right inside — no extra tables, no joins. To read it you'd write db.users.findOne({ name: 'Aisha' }) and get that whole document back in one shot.

💡 NoSQL = a database that does NOT use SQL tables/rows; here, a document store.

💡 document = one record — a JSON-like set of key/value pairs (the folder).

💡 collection = a group of documents (≈ a SQL table / the drawer).

💡 BSON = Binary JSON, MongoDB's on-disk format; supports extra types like Date and ObjectId.

Where it fits in MERN: MongoDB is the M — the data layer. Your React front-end and Express/Node back-end talk to MongoDB to store and fetch data. It's the database that remembers everything when the server restarts.

When it shines: flexible, nested, rapidly-evolving data — user profiles, product catalogs, posts with embedded comments, anything where the shape changes often or naturally nests. You read a whole object in one query, no joins needed.

When it's weak: heavily relational data spread across many tables with lots of JOINs, and multi-row transactions where strict consistency across records is critical (classic banking-style ledgers). A relational SQL database (Postgres/MySQL) fits those better.

Standard definition: MongoDB is a NoSQL document database that stores data as flexible, JSON-like documents (BSON) inside collections inside a database, with a unique _id per document and no enforced schema — it is the M (data layer) in the MERN stack and shines for flexible, nested, evolving data rather than heavily relational, multi-table transactional workloads.

// mongosh (MongoDB shell)

// A document = a JSON-like object (stored as BSON)
const user = {
  name: 'Aisha',
  age: 25,
  skills: ['react', 'node'],
  address: { city: 'Delhi' }
};

// documents -> collections -> database
db.users.insertOne(user);      // _id auto-generated

// read the whole document back, nested data included
db.users.findOne({ name: 'Aisha' });

SQL vs NoSQL

Imagine two ways to store paperwork. A relational (SQL) database is like a strict spreadsheet: every row must have the exact same columns in the exact same order. Want to add one person's middle name? You change the whole sheet's structure for everyone. A document (NoSQL) database is like a drawer of folders: each folder holds one person's papers, and one folder can have an extra page the others don't — no permission needed.

In MongoDB (a document NoSQL database) data lives as flexible JSON-like documents inside collections. There are no fixed columns — two documents in the same collection can have different fields. SQL databases (Postgres, MySQL) instead use tables of rows and columns with a fixed schema you define up front, and they connect tables using JOINs. Here is the mental map every interviewer wants:

SQL term MongoDB term
table collection
row document
column field
JOIN $lookup / embedding

🌍 Real-world example: A user's profile with their 3 addresses. In SQL you'd keep a users table and a separate addresses table, then SELECT ... FROM users JOIN addresses ON addresses.user_id = users.id. In MongoDB you just embed the addresses inside the user document as an array — db.users.findOne({ _id: 1 }) returns the user AND their addresses in one read, no join needed.

💡 schema = the shape/rules of your data (which fields exist, their types). SQL enforces it strictly; MongoDB is flexible (Mongoose can add one in your app).

💡 JOIN = combining rows from two tables on a matching key. MongoDB prefers embedding related data together, or $lookup when it must join.

💡 ACID = strong all-or-nothing transaction guarantees (Atomicity, Consistency, Isolation, Durability) — SQL's traditional strength.

💡 vertical vs horizontal scaling = vertical = one bigger server (SQL's usual path); horizontal = spread data across many servers (sharding — where document DBs shine).

When to use which? Reach for SQL when data is highly relational and transaction-heavy — banking, orders with strict consistency, many-to-many relationships. Reach for a document DB like MongoDB when data is flexible, nested, evolving fast, or read together as one unit — user profiles, product catalogs, feeds, real-time apps. Neither is "better"; the trade is SQL's rigid structure + joins + strong ACID vs MongoDB's flexible schema + easy nesting + horizontal scale.

Standard definition: SQL databases store data in tables of rows and columns with a fixed schema and use JOINs plus strong ACID transactions (scaling mostly vertically), while document NoSQL databases like MongoDB store flexible schema-free documents in collections, favour embedding/denormalization over joins, and scale horizontally — use SQL for highly relational, transaction-heavy data and document DBs for flexible, nested, rapidly-evolving data.

-- SQL: a fixed-schema row in a `users` table
INSERT INTO users (id, name, city) VALUES (1, 'Aarav', 'Delhi');
SELECT * FROM users WHERE city = 'Delhi';

// MongoDB: the equivalent document in a `users` collection
db.users.insertOne({ _id: 1, name: 'Aarav', address: { city: 'Delhi' } });
db.users.find({ 'address.city': 'Delhi' });

Project 1: CRUD app

What we're building: A tasks CRUD app on MongoDB using Mongoose end-to-end — connect to the database, define a Task Schema + model, then Create / Read / Update / Delete real documents with real queries, plus a filtered find and a sort+paginate query. No raw driver, no db.tasks.insertOne in the shell — everything through Mongoose the way a Node backend actually does it.

By the end you'll trace one task's whole life: create it → it's saved with an _id → find it back → update a field with $set → delete it — seeing the exact query AND the resulting document at every step. This is the data layer of every MERN app; wire these same calls into an Express route and you have a working API.

🌍 Real-world example: This is literally what a to-do app's backend does. POST /tasks calls Task.create({...}), GET /tasks calls Task.find(), PATCH /tasks/:id calls Task.findByIdAndUpdate(...). Swap the resource for books, orders, or users and it's the same five methods.

💡 Mongoose = an ODM (Object Data Modeling) library. It puts a schema, types, validation, and helper methods on top of MongoDB (which itself is schemaless). You talk to models (Task), Mongoose talks to the collection (tasks).

💡 CRUD = Create, Read, Update, Delete — the four things every app does to its data. In Mongoose: create, find/findById, findByIdAndUpdate, findByIdAndDelete.


Step 0 — The mental model (read this first)

Three layers stack up:

  • Database (taskdb) → holds collections.
  • Collection (tasks) → holds documents (one per task).
  • Document → a JSON-like object with a unique _id (an ObjectId).

Mongoose sits on top: you write a Schema (the shape + rules), turn it into a model (Task), and every method on that model runs a query against the tasks collection. A saved document is just a model instance persisted to disk. Let's build it up.


Step 1 — Connect to MongoDB

Nothing works until we're connected. mongoose.connect(uri) opens the connection pool and returns a Promise:

const mongoose = require('mongoose');

async function main() {
  await mongoose.connect('mongodb://127.0.0.1:27017/taskdb');
  console.log('MongoDB connected');
}
main().catch(err => console.error(err));

What's happening:

  1. The URI mongodb://127.0.0.1:27017/taskdb says where (host + port 27017, Mongo's default) and which database (taskdb). If taskdb doesn't exist yet, Mongo creates it the first time you write to it — no setup needed.
  2. mongoose.connect(...) returns a Promise, so we await it. Everything else must run after this resolves — that's why our whole app lives inside main().
  3. .catch(...) on the top-level call catches a bad URI / server-down so the process doesn't crash silently.

💡 Connection pool = Mongoose opens a handful of reusable sockets to Mongo once, and every query borrows one. You call connect once at startup, never per-request.


Step 2 — Define a Schema + model

The Schema is the blueprint: field names, types, and rules. The model is the callable object you run queries on:

const taskSchema = new mongoose.Schema({
  title:    { type: String, required: true },
  done:     { type: Boolean, default: false },
  priority: { type: Number, default: 3 },
  tags:     [String]
}, { timestamps: true });

const Task = mongoose.model('Task', taskSchema);

What's happening:

  1. Each field declares a type (String, Boolean, Number, [String] = array of strings) and options. required: true means Mongoose rejects a save with no title; default fills a value when you omit one.
  2. { timestamps: true } tells Mongoose to auto-add createdAt and updatedAt Date fields and keep them current — free auditing.
  3. mongoose.model('Task', taskSchema) compiles the schema into a model named 'Task'. Mongoose lowercases + pluralises that name to pick the collection: Task → the tasks collection. That's a classic gotcha — the collection is tasks, not Task.

💡 Schema vs model: the schema is the rules (shape + validation); the model is the doer (Task.create, Task.find). One schema → one model → one collection.


Step 3 — CREATE (Model.create) — TRACE the birth of a document

const task = await Task.create({
  title: 'Write resume',
  priority: 1,
  tags: ['career', 'urgent']
});
console.log(task);

Trace it:

  1. Task.create({...}) takes our plain object, validates it against the schema (title present? ✅), fills defaults (done wasn't passed → false), and inserts it into the tasks collection.
  2. Mongo generates a unique _id (an ObjectId) because we didn't supply one. timestamps stamp createdAt/updatedAt.
  3. create returns the saved document — now including its _id and defaults. The resulting document:
{
  _id: ObjectId('66b0a1f4c2a4e81d3c7f0a11'),
  title: 'Write resume',
  done: false,               // default filled in
  priority: 1,
  tags: ['career', 'urgent'],
  createdAt: 2026-07-16T10:00:00.000Z,
  updatedAt: 2026-07-16T10:00:00.000Z,
  __v: 0
}

That _id is the handle we'll use to find, update, and delete this exact task. Copy it in your head: 66b0a1f4...0a11. Everything below chases this document.

💡 Task.create(obj) = new Task(obj) + .save() in one call. __v is Mongoose's internal version key — ignore it.


Step 4 — READ (find + a filtered find) — find our task back

First prove it's really on disk by reading it back, then run a filtered query:

// (a) find ALL tasks
const all = await Task.find();

// (b) find ONE by its _id
const found = await Task.findById('66b0a1f4c2a4e81d3c7f0a11');

// (c) filtered find — unfinished, high-priority tasks
const urgent = await Task.find({ done: false, priority: { $lte: 2 } });

What's happening:

  1. Task.find() with no filter returns every document in tasks as an array. Task.find({...}) filters. Task.findById(id) is shorthand for findOne({ _id: id }) — returns the single doc or null.
  2. (c) is the real query skill: { done: false, priority: { $lte: 2 } } reads "done is false AND priority ≤ 2". Multiple keys are AND-ed automatically; $lte is the "less-than-or-equal" operator. Our task (done:false, priority:1) matches — so urgent returns:
[
  { _id: ObjectId('66b0a1f4...0a11'), title: 'Write resume', done: false, priority: 1, tags: ['career','urgent'], ... }
]

The read-back proves persistence: we never held the object in a variable across a restart — findById fetched it fresh from the tasks collection by its _id. It survived because it's in the database.

💡 A Mongoose query is thenableawait runs it. Without await you get a Query object, not documents (a common beginner bug: console.log(Task.find()) prints the query, not the data).


Step 5 — UPDATE ($set via findByIdAndUpdate {new:true}) — change one field

Now mark our task done and bump its priority:

const updated = await Task.findByIdAndUpdate(
  '66b0a1f4c2a4e81d3c7f0a11',
  { $set: { done: true, priority: 5 } },
  { new: true }
);
console.log(updated);

Trace it:

  1. findByIdAndUpdate(id, update, options) finds the doc by _id and applies the update.
  2. $set changes only the named fields (done, priority) and leaves title, tags, createdAt untouched. This is the important gotcha: if you pass a plain object without an operator to a raw update, MongoDB replaces the whole document. $set is the safe, surgical way. (Mongoose is a bit more forgiving, but always write $set — interviewers look for it.)
  3. { new: true } tells Mongoose to return the document after the update. Without it you get the old (pre-update) version back — a classic "why is my updated value not showing?" bug. The resulting document:
{
  _id: ObjectId('66b0a1f4...0a11'),
  title: 'Write resume',        // unchanged
  done: true,                   // $set applied
  priority: 5,                  // $set applied
  tags: ['career', 'urgent'],   // unchanged
  updatedAt: 2026-07-16T10:05:00.000Z,  // bumped by timestamps
  ...
}

Same _id, two fields changed, everything else intact. That's a targeted update.

💡 { new: true } = "give me the fresh copy." Default is false (old copy). Memorise this — it's the #1 Mongoose update gotcha.


Step 6 — DELETE (findByIdAndDelete) — remove our task

const deleted = await Task.findByIdAndDelete('66b0a1f4c2a4e81d3c7f0a11');
console.log(deleted); // the doc that WAS removed (or null if not found)

const gone = await Task.findById('66b0a1f4c2a4e81d3c7f0a11');
console.log(gone);    // null — it's really gone

Trace it:

  1. findByIdAndDelete(id) removes the matching document and returns the deleted doc (handy for confirming what you removed) — or null if that id wasn't there.
  2. The read-back findById(sameId) now returns null — proof the document is gone from the tasks collection. That closes the loop: create → the doc exists with an _id → find it → update it → delete it → find returns null. One document, cradle to grave, every query shown.

⚠️ Careful with the bulk cousins: Task.deleteMany({}) with an empty filter deletes every task in the collection. Always double-check the filter before a deleteMany.


Step 7 — Sort + paginate (the list query real apps need)

A list screen never dumps all rows — it shows page 2 of high-priority tasks, newest first:

const page = 2, size = 10;
const tasks = await Task.find({ done: false })
  .sort({ priority: 1, createdAt: -1 })  // priority asc, then newest first
  .skip((page - 1) * size)               // skip the first page
  .limit(size);                          // take 10

What's happening:

  1. .sort({ priority: 1, createdAt: -1 })1 = ascending, -1 = descending. This sorts by priority low→high, and within the same priority newest-first.
  2. Pagination = .skip((page-1)*size).limit(size). For page 2 with size 10: skip 10, take 10 → rows 11–20. General formula: skip = (page - 1) * size, limit = size.
  3. Chaining order doesn't change the result — Mongoose builds one query and Mongo applies sort → skip → limit together.

💡 skip gets slow for deep pages — Mongo still walks past every skipped doc, so page 5000 is expensive. For huge datasets, prefer range-based paging (createdAt < lastSeen) over big skips. Mention this in interviews.


🔎 The full flow (recap the wiring)

  1. Connect onceawait mongoose.connect(uri) at startup.
  2. Model onceSchema (shape + rules) → mongoose.model('Task', schema) → the tasks collection.
  3. CRUD per requestTask.create (C), Task.find/findById (R), Task.findByIdAndUpdate(id, { $set }, { new: true }) (U), Task.findByIdAndDelete(id) (D), plus find().sort().skip().limit() for lists.

And the life we traced: create returns a doc with an _id → findById fetches it → $set updates two fields ({new:true} shows the fresh copy) → findByIdAndDelete removes it → findById now returns null. Same _id the whole way — that's the persistence loop, proven query by query.


🚀 Wiring it into an Express route (bridge to the Express chapter)

Every method above drops straight into a route handler — the model methods are the body of your controllers:

app.post('/tasks', async (req, res) => {
  const task = await Task.create(req.body);        // C
  res.status(201).json(task);
});
app.get('/tasks/:id', async (req, res) => {
  const task = await Task.findById(req.params.id); // R
  task ? res.json(task) : res.status(404).json({ error: 'Not found' });
});
app.patch('/tasks/:id', async (req, res) => {
  const task = await Task.findByIdAndUpdate(req.params.id, { $set: req.body }, { new: true }); // U
  res.json(task);
});
app.delete('/tasks/:id', async (req, res) => {
  await Task.findByIdAndDelete(req.params.id);     // D
  res.status(204).end();
});

The Express layer only handles HTTP (route, params, status codes, JSON); the data work is exactly the Mongoose calls you just wrote. That clean split — Express for the web, Mongoose for the data — is the whole MERN backend.


✅ What you just learned

  • Connect once with mongoose.connect(uri) (Promise-based, at startup).
  • Schema → modelnew Schema({ field: type/options })mongoose.model('Task', schema); model name Tasktasks collection.
  • CreateTask.create({...}) validates, fills defaults, generates _id, returns the saved doc.
  • Readfind() / findById(id) / filtered find({ done:false, priority:{ $lte:2 } }).
  • UpdatefindByIdAndUpdate(id, { $set: {...} }, { new: true }); $set = surgical (no whole-doc replace), { new: true } = return the fresh copy.
  • DeletefindByIdAndDelete(id) returns the removed doc; deleteMany({}) wipes the collection (careful!).
  • Sort + paginate.sort({ f:1/-1 }).skip((page-1)*size).limit(size); skip is slow for deep pages.
  • Bridge to Express — these exact model calls become your route handlers.
const mongoose = require('mongoose');

const taskSchema = new mongoose.Schema({
  title:    { type: String, required: true },
  done:     { type: Boolean, default: false },
  priority: { type: Number, default: 3 },
  tags:     [String]
}, { timestamps: true });

const Task = mongoose.model('Task', taskSchema);

async function main() {
  await mongoose.connect('mongodb://127.0.0.1:27017/taskdb');
  console.log('MongoDB connected');

  // CREATE
  const task = await Task.create({
    title: 'Write resume',
    priority: 1,
    tags: ['career', 'urgent']
  });
  const id = task._id;
  console.log('created', task);

  // READ (all, by id, filtered)
  const all     = await Task.find();
  const found   = await Task.findById(id);
  const urgent  = await Task.find({ done: false, priority: { $lte: 2 } });
  console.log('urgent', urgent);

  // UPDATE ($set + { new: true })
  const updated = await Task.findByIdAndUpdate(
    id,
    { $set: { done: true, priority: 5 } },
    { new: true }
  );
  console.log('updated', updated);

  // SORT + PAGINATE (page 2, 10 per page)
  const page = 2, size = 10;
  const listed = await Task.find({ done: false })
    .sort({ priority: 1, createdAt: -1 })
    .skip((page - 1) * size)
    .limit(size);

  // DELETE
  const removed = await Task.findByIdAndDelete(id);
  const gone    = await Task.findById(id); // null
  console.log('gone?', gone);

  await mongoose.disconnect();
}
main().catch(err => console.error(err));

MongoDBinterview questions & answers

10 sample questions below — 192+ in the full bank inside.

What is the $group stage and what are accumulators in aggregation?

$group stage collects documents and combines values across matched documents. It groups by an _id expression and uses accumulators (like $sum, $avg, $max, $push) to compute totals, averages, counts, or arrays. Syntax: { $group: { _id: '$field', result: { $accumulator: '$value' } } }.

In simple terms: Imagine collecting all invoices by customer: $group { _id: '$customerId' } puts all invoices for each customer together. Then $sum: '$amount' adds up all amounts for that customer. Without grouping, $sum would only add matched docs; with grouping, it aggregates within each group.

Write a pipeline to find the total revenue (sum of amount) for each customer from an orders collection.

db.orders.aggregate([ { $group: { _id: '$customerId', totalRevenue: { $sum: '$amount' } } } ])

In simple terms: $group collects all orders by customerId into groups. For each group, $sum: '$amount' adds up all the amount values in that group. Result: each customer id with their total revenue. The _id field in the output contains the customer id; totalRevenue is the computed sum.

What is the $match stage in the aggregation pipeline?

$match filters documents based on a query condition (like find), and should be placed early in the pipeline for performance. It passes only documents that meet the criteria to the next stage. Syntax: { $match: { field: value, ... } }.

In simple terms: Think of $match as a security checkpoint — only pass documents matching the condition onward. Early placement (right after the source) means MongoDB filters before doing expensive operations like grouping. Example: { $match: { status: 'paid' } } passes only paid orders to the next stage.

Write an aggregation pipeline that filters orders collection to show only those with status 'completed' and amount > 500.

db.orders.aggregate([ { $match: { status: 'completed', amount: { $gt: 500 } } } ])

In simple terms: The $match stage acts like a find() filter. You pass two conditions in one $match: status must equal 'completed' AND amount must be greater than 500 (using $gt operator). Only orders matching both criteria flow to the next stage (if any).

Write a query to fetch page 2 of users (10 users per page), sorted by name ascending.

db.users.find().sort({ name: 1 }).skip(10).limit(10)

In simple terms: For page 2 with 10 items per page: skip 10 (page 1's 10 items), then limit 10 to get the next 10. This is skip=(page-1)*pageSize, limit=pageSize.

What is the difference between deleteOne and deleteMany?

deleteOne(filter) removes only the first document matching the filter and returns { deletedCount: 1 } (or 0 if no match). deleteMany(filter) removes all documents matching the filter and returns { deletedCount: n }. Both are permanent.

In simple terms: deleteOne is like 'remove the first person matching' and deleteMany is 'remove everyone matching'. deleteOne({ status:'inactive' }) deletes one; deleteMany({ status:'inactive' }) deletes all inactive users.

If you insert a document without an _id, what happens?

MongoDB automatically generates a unique _id of type ObjectId and adds it to the document before storing. The insert result returns that insertedId so you can reference the new document.

In simple terms: insertOne({ name:'A' }) is stored as { _id: ObjectId('...'), name:'A' }. You didn't provide _id, Mongo did.

What is the pagination formula to calculate skip and limit for page N with M items per page?

skip = (N - 1) × M, limit = M. For example, page 3 with 10 items: skip(20).limit(10).

In simple terms: Page 1 shows items 0–9, page 2 shows 10–19, page 3 shows 20–29. So page N starts at item (N-1)×M. (Page-1) because pages are 1-indexed but skip is 0-indexed.

How should you store a creation time in MongoDB?

Store it as a BSON Date type (e.g. new Date() in mongosh, or a Date field in Mongoose), not as a string. A real Date can be compared, sorted, and range-queried correctly (e.g. find docs created after a certain day).

In simple terms: createdAt: new Date() lets you later query { createdAt: { $gte: someDate } }. A string date would sort/compare as text, breaking range queries.

What is BSON?

BSON stands for Binary JSON — the binary-encoded format MongoDB uses to store and transfer documents. It looks like JSON to you but on disk it is a typed, length-prefixed binary structure, which makes it compact and fast to traverse.

In simple terms: You write { age: 30 }; MongoDB stores it as binary that records 30 as a specific number type, not text. That's BSON.

182+ more MongoDB questions inside

Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.

Unlock all questions — free

Ready to practise MongoDB?

Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.