What is MongoDB?
MongoDB is a document database. Instead of rows in tables, it stores documents — JSON-like objects encoded as BSON — inside collections. A document can hold nested objects and arrays, so one record can describe a whole aggregate: an order with its line items, a user with their addresses.
It appeared in 2009, when the relational model felt heavy for applications that were growing fast and changing shape. Its promise was simple: store the object your code already has, and scale horizontally without a painful migration. That promise made it the default NoSQL database for a generation of Node.js and JavaScript teams.
The trade-off is real. MongoDB gives up joins by default and pushes schema enforcement into your application. When your data is document-shaped, that is a bargain. When it is deeply relational, a relational database is often the better tool.
Documents, collections and BSON
A document is an ordered set of field-value pairs. A collection is a group of documents that usually share a shape, but nothing forces them to. Collections are created the moment you first insert into them.
db.users.insertOne({
email: "[email protected]",
name: "Ada",
address: { city: "London", country: "GB" },
tags: ["admin", "beta"],
});
Values have BSON types, not just JSON types. BSON is a binary encoding that adds ObjectId, Date, Decimal128, BinData and more, so you can store real dates and precise decimals instead of strings. Field order is preserved, and field names are case-sensitive.
A useful habit is to give every document in a collection a consistent shape even though the server allows variation. You get predictable queries and indexes, and the flexibility is still there when you need to add a field to one document first.
_id and ObjectId
Every document needs a unique _id. If you do not supply one, the driver generates an ObjectId — a 12-byte value made of a timestamp, a random per-process value and an incrementing counter. Because the timestamp comes first, ObjectId values sort roughly by creation time, which is convenient for pagination.
const { ObjectId } = require("mongodb");
const id = new ObjectId("64f1c2a9e13b4a7d8c9e0011");
id.getTimestamp(); // 2023-09-01T...
The _id field is indexed automatically and is always unique. You may supply your own value — a natural key, a UUID string, or a composite — when that makes lookups cheaper or when you need idempotent inserts.
CRUD in practice
Create, read, update and delete map to a small set of methods that behave the same in mongosh and in the Node driver.
// Create
db.users.insertOne({ email: "[email protected]", plan: "pro" });
db.users.insertMany([
{ email: "[email protected]", plan: "free" },
{ email: "[email protected]", plan: "team" },
]);
// Read
db.users.findOne({ email: "[email protected]" });
db.users.find({ plan: "pro" }).toArray();
// Update
db.users.updateOne(
{ email: "[email protected]" },
{ $set: { plan: "team" } },
);
// Delete
db.users.deleteOne({ email: "[email protected]" });
updateOne and updateMany take a filter and an update document. The update document uses operators: $set replaces or adds fields, $unset removes them, $inc adds to a number, $push appends to an array, $addToSet adds only if absent, and $pull removes matching array elements.
db.users.updateOne(
{ email: "[email protected]" },
{
$set: { lastSeenAt: new Date() },
$inc: { logins: 1 },
$addToSet: { tags: "beta" },
},
);
There is also replaceOne, which swaps the whole document except _id. Prefer field operators so concurrent writers do not clobber each other’s changes.
Querying with operators
A filter is itself a document. Equality is the default, and operators begin with $.
db.orders.find({ status: "paid" }); // equality
db.orders.find({ total: { $gt: 5000, $lte: 20000 } }); // range
db.orders.find({ status: { $in: ["paid", "shipped"] } });
db.users.find({ email: { $regex: /@example\.com$/i } });
db.orders.find({ "items.sku": "KB-01" }); // nested field
db.orders.find({
items: { $elemMatch: { qty: { $gte: 2 }, price: { $lt: 3000 } } },
});
$elemMatch matters when several conditions must apply to the same array element. Without it, { "items.qty": { $gte: 2 }, "items.price": { $lt: 3000 } } can match different elements.
Combine filters with $and, $or and $not:
db.orders.find({
$or: [
{ status: "paid" },
{ status: "pending", total: { $lt: 1000 } },
],
});
The query language is composable because it is data, not a string. That is why building filters in application code feels natural — you assemble an object and pass it to find.
Projection, sorting and pagination
A projection selects which fields to return. Include fields with 1 or exclude them with 0, but never mix the two styles except for _id.
db.users.find(
{ plan: "pro" },
{ email: 1, name: 1, _id: 0 },
);
Sort and paginate with .sort(), .skip() and .limit(). For large offsets, skip gets slower because the server still walks the skipped documents. Prefer keyset pagination, where you filter on the last value you saw.
db.orders
.find({ customerId, createdAt: { $lt: lastSeen } })
.sort({ createdAt: -1 })
.limit(20);
Indexes on the sort fields make both the filter and the ordering efficient, which is where the next section comes in.
Indexes: the difference between fast and unusable
Without an index, MongoDB reads every document in the collection — a collection scan. With one, it seeks directly to the matching range. Almost every performance problem in MongoDB is a missing or misordered index.
db.users.createIndex({ email: 1 }, { unique: true });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.products.createIndex({ name: "text", description: "text" });
- Single-field indexes speed up equality and range queries on one field.
- Compound indexes follow the ESR rule: equality fields first, then sort, then range.
{ customerId: 1, createdAt: -1 }serves both the filter and the sort. - Unique indexes reject duplicates and make upserts safe.
- TTL indexes delete documents after a date field, which is perfect for sessions and one-time tokens.
- Text indexes support
$textsearch with stemming and scoring.
Use explain() to see what the planner did. Look for IXSCAN rather than COLLSCAN, and check that the number of documents examined is close to the number returned.
db.orders.find({ customerId, status: "paid" }).explain("executionStats");
The aggregation pipeline
When a query is not enough, the aggregation framework runs a pipeline of stages. Each stage transforms a stream of documents and passes it on. The common stages are $match, $group, $sort, $project, $lookup, $unwind and $limit.
Here is a worked example: the top five products by revenue for paid orders.
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{
$group: {
_id: "$items.sku",
units: { $sum: "$items.qty" },
revenue: {
$sum: { $multiply: ["$items.qty", "$items.price"] },
},
},
},
{ $sort: { revenue: -1 } },
{ $limit: 5 },
{
$project: {
_id: 0,
sku: "$_id",
units: 1,
revenue: 1,
},
},
]);
Read it top to bottom. $match filters early so later stages do less work. $unwind turns each array element into its own document. $group accumulates units and revenue per SKU. $sort and $limit keep the top five. $project renames _id to sku and drops the rest.
$lookup performs a left outer join against another collection, which is how you combine referenced data:
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer",
},
},
{ $unwind: "$customer" },
{ $project: { total: 1, "customer.email": 1 } },
]);
Always put $match first so it can use an index, and put $limit as early as correctness allows. Aggregation is powerful, but a pipeline that scans every document at every stage is a slow query waiting to happen.
Embedding vs referencing
This is the central modelling decision. Ask how the data is read.
Embed when the child data is read with the parent, written with it, and bounded in size. An order’s line items are the classic case: one read returns everything, and there is no join.
{
_id: ObjectId("..."),
customerId: ObjectId("..."),
items: [
{ sku: "KB-01", qty: 1, price: 8900 },
{ sku: "MS-02", qty: 2, price: 2900 },
],
total: 14700,
}
Reference when the data is large, shared, or updated on its own schedule. Users, products and categories are referenced by _id, and $lookup or a second query joins them.
{
_id: ObjectId("..."),
customerId: ObjectId("64f1c2a9e13b4a7d8c9e0011"),
items: [{ productId: ObjectId("..."), qty: 1, price: 8900 }],
}
The rule of thumb is data that is accessed together should be stored together. Duplicate a little data when it makes the common read a single lookup, but remember that copies must be updated in every place they live. And never embed an array that grows without bound — documents top out at 16 MB.
Transactions
Single-document writes are atomic. If you need to change several documents or collections as one unit, use a multi-document transaction. Sessions are the mechanism.
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne(
{ _id: from },
{ $inc: { balance: -100 } },
{ session },
);
await accounts.updateOne(
{ _id: to },
{ $inc: { balance: 100 } },
{ session },
);
});
} finally {
await session.endSession();
}
Transactions require a replica set or sharded cluster, and they carry overhead: locks, a longer window, and retries on conflict. The best use of transactions is rare. If you find yourself wrapping every write, your data model probably wants more embedding.
Replica sets and sharding
A replica set is a group of nodes holding the same data. One is the primary and takes all writes; the others replicate the primary’s oplog and can serve reads. If the primary fails, the set elects a new one automatically. This is the default production deployment, and it also enables change streams and transactions.
Sharding partitions a collection across many replica sets by a shard key. Each shard owns a range of key values. A good shard key has high cardinality, distributes writes evenly, and appears in most queries so the router can target a single shard. A poor key creates a hotspot or forces every query to fan out to all shards.
Choose the shard key before you have data, because changing it later means migrating the collection. For most applications, start with a replica set and shard only when a single primary can no longer keep up.
Mongoose in Node
The official mongodb driver is all you need for queries, but most Node teams use Mongoose for its schemas, validation and models. A schema describes the shape of a document, and a model is the queryable class built from it.
import mongoose from "mongoose";
const orderSchema = new mongoose.Schema({
customerId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
status: {
type: String,
enum: ["pending", "paid", "shipped"],
default: "pending",
},
items: [
{
sku: String,
qty: { type: Number, min: 1 },
price: { type: Number, min: 0 },
},
],
total: Number,
createdAt: { type: Date, default: Date.now, index: true },
});
export const Order = mongoose.model("Order", orderSchema);
const paid = await Order.find({ status: "paid" })
.sort({ createdAt: -1 })
.limit(20)
.lean();
Mongoose validates on save, casts types, and gives you populate() for references. Two habits keep it fast: declare the indexes the queries need, and call .lean() when you only read data, which skips building full Mongoose documents.
Bulk writes and upserts
One command per document wastes round trips. When you need to apply many changes, bulkWrite sends a batch in a single call and reports exactly what happened.
await db.users.bulkWrite([
{
updateOne: {
filter: { email: "[email protected]" },
update: { $set: { plan: "team" } },
upsert: true,
},
},
{
insertOne: {
document: { email: "[email protected]", plan: "pro" },
},
},
{
deleteOne: { filter: { email: "[email protected]" } },
},
]);
The upsert option is the other workhorse. It updates a matching document or inserts one if none exists, which makes idempotent imports and counters easy.
db.stats.updateOne(
{ day: "2026-09-16" },
{ $inc: { visits: 1 } },
{ upsert: true },
);
Bulk writes are not transactions by default. Pass { ordered: false } to keep going after an error and collect every failure, or leave the default ordered mode when each write depends on the previous one. Either way, check the result object: it counts matched, modified, inserted and deleted documents, and an upsert that matched nothing is not an error.
Change streams
A replica set records every write in an oplog. Change streams expose that log as a resumable feed, so an application can react to inserts, updates and deletes without polling.
const changeStream = db.orders.watch([
{ $match: { "fullDocument.status": "paid" } },
]);
for await (const change of changeStream) {
console.log(change.operationType, change.fullDocument._id);
}
The feed is resumable: store the _id of the last event you processed as a resume token, and pass it back with resumeAfter after a restart so no event is missed. Because change streams run on the oplog, they require a replica set and they only report changes that have not yet rolled off the log.
Two rules keep them reliable. Make the consumer idempotent, because an event can be delivered again after a resume. And keep the processing fast or hand work to a queue, since a slow consumer lets the oplog advance past its position.
Best practices
- Model for the read: embed what is read together, reference what is shared or unbounded.
- Index the fields you filter and sort by, and follow the ESR rule for compound indexes.
- Always project only the fields the client needs; never return secrets.
- Put
$matchfirst in an aggregation pipeline so it can use an index. - Cap array growth and watch the 16 MB document limit.
- Validate writes in the application or with a collection JSON Schema validator.
- Use
explain()before you assume a query is fine, and test with production-sized data. - Prefer keyset pagination over large
skipoffsets.
Common mistakes
- Treating MongoDB as schemaless and letting documents drift into incompatible shapes.
- Running queries with no index and wondering why latency grows with the collection.
- Building compound indexes in the wrong order, so the sort cannot use them.
- Embedding an array that grows forever until documents hit the size limit.
- Using
$lookupon every request instead of embedding data that is read together. - Reaching for multi-document transactions when a single-document update would do.
- Returning entire documents and leaking password hashes or tokens.
- Choosing a low-cardinality shard key and creating a write hotspot.
Where to go next
MongoDB teaches document modelling, index design and aggregation — skills that transfer to any data store. If your data is deeply relational and needs joins and constraints at the database level, read the PostgreSQL guide. For microsecond reads, TTL-driven expiry and counters, pair MongoDB with Redis. If you want to connect it to a Node service, revisit the Node.js basics and then compare the query model with SQL.