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
userscollection, 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 writedb.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
userstable and a separateaddressestable, thenSELECT ... 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
$lookupwhen 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 /taskscallsTask.create({...}),GET /taskscallsTask.find(),PATCH /tasks/:idcallsTask.findByIdAndUpdate(...). Swap the resource forbooks,orders, orusersand 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:
- The URI
mongodb://127.0.0.1:27017/taskdbsays where (host + port27017, Mongo's default) and which database (taskdb). Iftaskdbdoesn't exist yet, Mongo creates it the first time you write to it — no setup needed. mongoose.connect(...)returns a Promise, so weawaitit. Everything else must run after this resolves — that's why our whole app lives insidemain()..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
connectonce 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:
- Each field declares a type (
String,Boolean,Number,[String]= array of strings) and options.required: truemeans Mongoose rejects a save with notitle;defaultfills a value when you omit one. { timestamps: true }tells Mongoose to auto-addcreatedAtandupdatedAtDate fields and keep them current — free auditing.mongoose.model('Task', taskSchema)compiles the schema into a model named'Task'. Mongoose lowercases + pluralises that name to pick the collection:Task→ thetaskscollection. That's a classic gotcha — the collection istasks, notTask.
💡 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:
Task.create({...})takes our plain object, validates it against the schema (title present? ✅), fills defaults (donewasn't passed →false), and inserts it into thetaskscollection.- Mongo generates a unique
_id(an ObjectId) because we didn't supply one.timestampsstampcreatedAt/updatedAt. createreturns the saved document — now including its_idand 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.__vis 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:
Task.find()with no filter returns every document intasksas an array.Task.find({...})filters.Task.findById(id)is shorthand forfindOne({ _id: id })— returns the single doc ornull.- (c) is the real query skill:
{ done: false, priority: { $lte: 2 } }reads "done is false AND priority ≤ 2". Multiple keys are AND-ed automatically;$lteis the "less-than-or-equal" operator. Our task (done:false,priority:1) matches — sourgentreturns:
[
{ _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 thenable —
awaitruns it. Withoutawaityou 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:
findByIdAndUpdate(id, update, options)finds the doc by_idand applies the update.$setchanges only the named fields (done,priority) and leavestitle,tags,createdAtuntouched. This is the important gotcha: if you pass a plain object without an operator to a raw update, MongoDB replaces the whole document.$setis the safe, surgical way. (Mongoose is a bit more forgiving, but always write$set— interviewers look for it.){ 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 isfalse(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:
findByIdAndDelete(id)removes the matching document and returns the deleted doc (handy for confirming what you removed) — ornullif that id wasn't there.- The read-back
findById(sameId)now returnsnull— proof the document is gone from thetaskscollection. 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 adeleteMany.
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:
.sort({ priority: 1, createdAt: -1 })—1= ascending,-1= descending. This sorts by priority low→high, and within the same priority newest-first.- 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. - Chaining order doesn't change the result — Mongoose builds one query and Mongo applies sort → skip → limit together.
💡
skipgets 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)
- Connect once —
await mongoose.connect(uri)at startup. - Model once —
Schema(shape + rules) →mongoose.model('Task', schema)→ thetaskscollection. - CRUD per request —
Task.create(C),Task.find/findById(R),Task.findByIdAndUpdate(id, { $set }, { new: true })(U),Task.findByIdAndDelete(id)(D), plusfind().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 → model —
new Schema({ field: type/options })→mongoose.model('Task', schema); model nameTask→taskscollection. - Create —
Task.create({...})validates, fills defaults, generates_id, returns the saved doc. - Read —
find()/findById(id)/ filteredfind({ done:false, priority:{ $lte:2 } }). - Update —
findByIdAndUpdate(id, { $set: {...} }, { new: true });$set= surgical (no whole-doc replace),{ new: true }= return the fresh copy. - Delete —
findByIdAndDelete(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 — freeReady to practise MongoDB?
Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.