Last 30 Days
No notifications
MongoDB stores data as JSON-like documents in collections. No fixed schema — each document can have different fields.
| SQL Term | MongoDB Term |
| Database | Database |
| Table | Collection |
| Row | Document |
| Column | Field |
| JOIN | Populate / Lookup |
| Primary Key | _id (auto-generated ObjectId) |
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"name": "Alice",
"email": "alice@test.com",
"age": 28,
"tags": ["developer", "gamer"],
"address": {
"city": "New York",
"zip": "10001"
},
"createdAt": ISODate("2024-01-15T10:30:00Z")
}Mongoose provides schema validation, middleware, and query helpers on top of MongoDB.
const mongoose = require('mongoose');// Define a schema
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, unique: true },
age: { type: Number, min: 0 },
});
// Create model
const User = mongoose.model('User', userSchema);
MongoDB stores documents — JSON-like objects — inside collections that live in a database. There are no rows, no columns, no migrations to add a new field. You write JavaScript-shaped data and read JavaScript-shaped data back. That is its whole appeal.
The mental ladder from SQL to MongoDB:
| SQL | MongoDB |
| Database | Database |
| Table | Collection |
| Row | Document (BSON object) |
| Column | Field |
| Primary key | _id (auto-generated ObjectId) |
| JOIN | $lookup / populate |
| Schema | *Optional* — enforced by your app, not the DB |
// One document
{
_id: ObjectId('66...'),
name: 'Alice',
email: 'a@t.com',
tags: ['admin', 'beta'],
address: { city: 'Pune', country: 'IN' },
createdAt: ISODate('2026-04-01')
}Documents can hold arrays and nested objects — something SQL needs extra tables for.
Two ways to talk to MongoDB from Node:
mongodb) — thin wrapper around the protocol. No schema, no helpers. Best when you want full control.mongoose) — ODM (Object-Document Mapper). Adds schemas, validation, hooks, virtuals, populate. The default for most Node apps.// Create
await User.create({ name: 'Alice', email: 'a@t.com' });// Read
const alice = await User.findOne({ email: 'a@t.com' });
const admins = await User.find({ role: 'admin' }).limit(20).sort('-createdAt');
// Update
await User.findByIdAndUpdate(id, { name: 'New' }, { new: true, runValidators: true });
// Delete
await User.findByIdAndDelete(id);
The one-liner you must remember: pass { new: true } to update calls, otherwise you get the *old* document back. Endless beginner pain.
1. No schema at all — "flexible" turns into garbage data within a month. Use Mongoose schemas with validators.
2. Forgetting new: true on update calls and wondering why your response is stale.
3. Querying with the wrong type — User.find({ _id: '66...' }) returns nothing because the stored type is ObjectId, not string. Use new mongoose.Types.ObjectId(id) or rely on findById.
4. Loading the whole document to update one field. Use updateOne / $set instead of load → mutate → save.
5. No indexes — a 100k-doc collection without an index on email will collection-scan on every login.
6. Storing JWT tokens, files, or huge logs as documents. Mongo has a 16MB document limit. Keep docs small.
const postSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
slug: { type: String, unique: true, lowercase: true, index: true },
status: { type: String, enum: ['draft', 'published', 'archived'], default: 'draft' },
views: { type: Number, default: 0, min: 0 },
tags: [String],
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
meta: { readTime: Number, wordCount: Number },
}, { timestamps: true }); // adds createdAt + updatedAttimestamps: true and ref: 'User' are the two flags that come up everywhere.
const post = await Post.findById(id)
.populate('author', 'name avatar')
.populate({ path: 'comments', options: { limit: 20 } });Under the hood Populate does a *second* query and stitches results in JavaScript. It is convenient but N+1 prone — use $lookup (aggregation) for high-volume joins.
userSchema.index({ email: 1 }, { unique: true });
postSchema.index({ author: 1, createdAt: -1 }); // compound
postSchema.index({ title: 'text', content: 'text' }); // full-textRules: index every field used in find, sort, or $match. Compound indexes follow the ESR rule — *Equality, Sort, Range*.
Avoid "load and save". Use atomic operators:
await User.updateOne({ _id }, { $set: { lastSeen: new Date() } });
await Post.updateOne({ _id }, { $inc: { views: 1 } });
await Post.updateOne({ _id }, { $push: { tags: 'node' } });
await Post.updateOne({ _id }, { $addToSet: { tags: 'node' } }); // no dupes
await Post.updateOne({ _id }, { $pull: { tags: 'old' } });These run as a single DB operation — no race conditions.
userSchema.pre('save', async function() {
if (!this.isModified('password')) return;
this.password = await bcrypt.hash(this.password, 12);
});userSchema.post('save', function(doc) {
if (doc.wasNew) sendWelcomeEmail(doc.email);
});
Great for hashing, slug generation, audit logs. Watch out: hooks do not run for updateOne, findByIdAndUpdate etc. unless you explicitly hook those operations.
Think of it as a Unix pipeline for documents — each stage transforms the stream.
const stats = await Order.aggregate([
{ $match: { createdAt: { $gte: lastMonth } } },
{ $group: { _id: '$status', count: { $sum: 1 }, revenue: { $sum: '$total' } } },
{ $sort: { revenue: -1 } },
{ $project:{ status: '$_id', count: 1, revenue: { $round: ['$revenue', 2] }, _id: 0 } },
]);Key stages: $match, $group, $project, $sort, $limit, $lookup, $unwind, $facet. Put $match first so indexes get used.
Available on replica sets and Atlas. Use them for cross-document atomic changes:
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
await Order.create([orderDoc], { session });
await Inventory.updateOne({ _id: itemId }, { $inc: { stock: -1 } }, { session });
});
} finally {
session.endSession();
}Do not wrap a single-document change in a transaction — single-doc writes are already atomic.
skip + limit works for small data; on huge collections it scans everything skipped. Switch to cursor pagination:
const items = await Post.find(cursor ? { _id: { $gt: cursor } } : {})
.sort({ _id: 1 })
.limit(21);
const hasMore = items.length > 20;
if (hasMore) items.pop();
return { items, nextCursor: hasMore ? items.at(-1)._id : null };post.viewerIds: ObjectId[] that grows forever → will hit the 16MB limit).type field unless they really differ.Decimal128 to avoid float drift.1. Build a User and Post model with proper schemas, validators, and timestamps.
2. Add unique index on email and a compound index on (author, createdAt).
3. Implement create / read / update / delete using atomic operators ($set, $inc).
4. Write an aggregation that returns post count + average views per author, sorted descending.