Document Database

MongoDB

MongoDB is a document database: flexible BSON documents live in collections, are queried with a JavaScript-like language, and scale out with replica sets and sharding.

intermediate15 min readUpdated Sep 16, 2026
mongosh
js
// mongosh
use shop

db.orders.insertOne({
  customerId: ObjectId("64f1c2a9e13b4a7d8c9e0011"),
  status: "paid",
  items: [
    { sku: "KB-01", name: "Keyboard", qty: 1, price: 8900 },
    { sku: "MS-02", name: "Mouse", qty: 2, price: 2900 },
  ],
  total: 14700,
  createdAt: new Date(),
})

db.orders
  .find({ status: "paid", total: { $gte: 10000 } })
  .sort({ createdAt: -1 })
  .limit(10)
Released
2009
Data model
Document / BSON
Query language
MQL
Written in
C++
Storage engine
WiredTiger
License
SSPL
Version
8.x

Why it matters

Why teams reach for MongoDB

Documents map to objects

A record is a BSON document that mirrors the object your application already uses, so reads rarely need joins or row-to-object mapping.

A query language you can read

Filters are JSON-like documents with operators such as $gt and $in, which makes queries composable and easy to build from code.

Scale out when you must

Replica sets give high availability from day one, and sharding spreads a collection across nodes when a single primary is no longer enough.

The big picture

The three ideas behind MongoDB

A document stores a whole object, a collection groups documents, and an index or pipeline turns them into answers.

Documents

Store

Data lives in BSON documents with typed fields, nested objects and arrays. The schema is enforced by your code, not by the server.

Collections & indexes

Organise

Related documents sit in a collection. Indexes on the fields you filter and sort by turn full scans into targeted lookups.

Driver & Mongoose

Access

The official Node driver exposes the same commands you run in mongosh, while Mongoose adds schemas, validation and models.

At a glance

The MongoDB toolbox

Collections

db.orders is a collection of documents, created on first insert.

find()

Pass a filter document and get a cursor of matching documents.

Aggregation

A pipeline of $match, $group and $lookup stages shapes results.

Indexes

Single, compound, unique, TTL and text indexes share one API.

Transactions

Multi-document ACID transactions when one write is not enough.

Replica sets

A primary and secondaries replicate writes and elect a new primary.

Data model

How a document is shaped

One order, its line items and its status, all in a single document.

An order documentMongoDB document
  • _idObjectIdPrimary key, generated by the driver
  • customerIdObjectIdReferences a document in users
  • statusstringpending, paid or shipped
  • itemsarray<object>Embedded line items: sku, name, qty, price
  • totalnumberDerived total in cents
  • createdAtDateIndexed for recent-order queries

One document in the orders collection, with its line items embedded and its status indexed.

The complete guide

MongoDB: Everything you need to know

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 $text search 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 $match first 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 skip offsets.

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 $lookup on 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.

In practice

Four commands that cover most of the work

The same shapes appear in mongosh and in the Node driver: insert, update, aggregate and index.

mongosh
db.users.insertMany([
  { email: "[email protected]", name: "Ada", plan: "pro" },
  { email: "[email protected]", name: "Linus", plan: "free" },
]);

db.users.find(
  { plan: "pro" },
  { email: 1, name: 1, _id: 0 },
);

Projecting only what you need

A projection cuts the bytes that cross the network and keeps sensitive fields out of responses.

Prefer
db.users.find(
  { plan: "pro" },
  { email: 1, name: 1, _id: 0 },
);
Avoid
db.users.find({ plan: "pro" });
// every field crosses the wire,
// including passwordHash and tokens

Keeping arrays bounded

Embed data that is read together, but never let a single document grow without a limit. Documents have a 16 MB ceiling.

Prefer
// Line items are read with the order.
db.orders.insertOne({
  customerId,
  items: [{ sku: "KB-01", qty: 1, price: 8900 }],
});
Avoid
// This array grows forever.
db.users.updateOne(
  { _id },
  { $push: { events: event } },
);

Trade-offs

Is MongoDB the right store for this?

MongoDB is excellent at document-shaped reads and horizontal scale. Its costs appear when data is deeply relational or needs strong cross-document guarantees.

Strengths

  • The object is the record

    Storing a document that already matches your application's shape removes the mapping layer, and many reads become a single lookup.

  • Schema flexibility

    Documents in one collection can carry different fields, which suits evolving products and heterogeneous data.

  • Built-in horizontal scale

    Replica sets and sharding are first-class, so the same data model can grow from one node to a cluster.

Trade-offs

  • Relationships get harder

    Without joins by default, modelling many-to-many data means duplication or $lookup, and keeping copies consistent becomes your job.

  • Schema-on-write is still a schema

    The server will not stop a malformed document. Validation must live in your code or in a JSON Schema validator.

  • Transactions cost more

    Multi-document transactions work, but they are slower and more complex than a single-document update. Design so you rarely need them.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning MongoDB?

Our interactive tutorial walks you through MongoDB step by step — with quizzes and real code you can run in the browser.