All lessons
DataIntermediate12 min readWorth knowing

MongoDB

A document database: store data the way your application reads it.

  • Documents
  • Aggregation
  • Indexes
  • Replica sets
Step 1/5
In SQL, one order is split into rows across tables: orders, items, customers. To show it again, the database JOINs them back together.

In 30 seconds

MongoDB stores data as JSON-like documents (BSON) grouped into collections, instead of rows in tables. A single document can hold nested objects and arrays, so one read often returns everything a screen needs. It scales with replica sets for availability and sharding for size. The current line is MongoDB 8.x, self-managed or as the Atlas cloud service.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • It matters when your data is naturally shaped like documents: a product with variable attributes, a user profile with nested settings, a content block tree. Forcing that into many tables adds joins without adding value.
  • Teams pick it when the schema changes often in early product stages. Fields can be added without a blocking migration, while schema validation still keeps the data honest.
  • Replica sets give automatic failover out of the box, and sharding spreads very large collections across machines, which is harder to retrofit onto a single relational server.
  • It is a good contrast to a relational database such as MySQL or PostgreSQL: knowing both lets you choose by access pattern, not by habit. Strong relations and multi-table reporting still favour SQL.

How it works

01The idea: documents instead of rows

Think of a relational database as a set of spreadsheets that you join together, and MongoDB as a filing cabinet where each folder holds everything about one thing. A document is a BSON object (binary JSON with extra types such as dates, decimals and ObjectId). Documents live in collections, and collections live in a database. Every document has a unique _id. Documents in one collection do not have to share the same fields, but in practice they should, and you enforce it with a JSON Schema validator. The maximum size of one document is 16 MB, which is a hint: documents are for bounded data, not for endless lists.

  • BSON: the binary format MongoDB stores; it supports types JSON lacks, such as Date and Decimal128.
  • Collection: a group of documents, roughly like a table.
  • ObjectId: the default 12-byte _id, roughly ordered by creation time.

02Modelling: embed or reference

The core design question is whether related data goes inside the parent document (embed) or in its own collection with an id pointing to it (reference). Embed when the child belongs to the parent, is read together with it, and has a bounded size: an order and its line items, a user and their addresses. Reference when the child is shared, grows without limit, or is updated independently: comments on a popular post, products referenced by many orders. The rule of thumb from MongoDB's own guidance is “data that is accessed together should be stored together”. Design from your queries backwards: list the screens and API calls first, then shape documents so each one is served by one read.

js
// One order document: line items embedded, customer referenced
{
  _id: ObjectId("665f1c2a9b1e8a0012345678"),
  customerId: ObjectId("665f1b009b1e8a0011112222"),
  status: "paid",
  total: NumberDecimal("59.90"),
  items: [
    { sku: "TSHIRT-M", qty: 2, price: NumberDecimal("19.95") },
    { sku: "CAP-01", qty: 1, price: NumberDecimal("20.00") }
  ],
  createdAt: ISODate("2026-11-05T10:00:00Z")
}

03Queries, indexes and the ESR rule

You query with filter documents ({ status: "paid" }) and operators such as $gt, $in and $elemMatch. Without an index, MongoDB scans every document (a COLLSCAN). Indexes are B-trees, just like in SQL databases, and compound indexes follow the ESR rule: put fields you match by Equality first, then fields you Sort by, then fields you filter by Range. An index on { customerId: 1, createdAt: -1 } serves “latest orders of this customer” perfectly. Always check a query with explain("executionStats") and compare totalDocsExamined with nReturned; if the first is much bigger, the index is wrong or missing.

js
db.orders.createIndex({ customerId: 1, status: 1, createdAt: -1 })
 
db.orders
  .find({ customerId: id, status: "paid", createdAt: { $gte: since } })
  .sort({ createdAt: -1 })
  .limit(20)
  .explain("executionStats")

04The aggregation pipeline

Aggregation is MongoDB's answer to GROUP BY and reporting. A pipeline is an array of stages, and each stage transforms the stream of documents and passes it on, like an assembly line. $match filters (put it first so it can use an index), $group computes totals, $sort orders, $project reshapes, $lookup joins another collection, and $unwind turns an array into one document per element. Pipelines are powerful but can get heavy: a $group over millions of documents uses memory, and stages that exceed 100 MB spill to disk. Keep $match and $project early to shrink the data before expensive stages.

js
db.orders.aggregate([
  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-10-01") } } },
  { $unwind: "$items" },
  { $group: {
      _id: "$items.sku",
      units: { $sum: "$items.qty" },
      revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
  } },
  { $sort: { revenue: -1 } },
  { $limit: 10 }
])

05Replica sets, write concern and transactions

A replica set is a group of mongod servers holding the same data: one primary takes writes, secondaries copy its operation log (the oplog). If the primary dies, the remaining members elect a new one within seconds. Write concern says how many members must confirm a write; the default since MongoDB 5.0 is “majority”, which means an acknowledged write survives a failover. Read concern and read preference control what you read and from where. Multi-document ACID transactions exist (replica sets since 4.0, sharded clusters since 4.2), but they cost performance; a good document model needs them rarely, because a single-document write is already atomic.

  • Use at least three data-bearing members in production, spread across zones.
  • Connect with the full replica set connection string so drivers find the new primary on failover.
  • Reading from secondaries can return slightly stale data; use it only where that is acceptable.

06Using it from an application

Official drivers exist for Node.js, PHP, Python, Go, Java and more. In Node.js many teams add Mongoose for schemas and validation, but the native driver is fine and thinner. Create one client per process and reuse it: the client keeps a connection pool. Validate input at the edge, keep money in Decimal128 (never floating point), and store dates as real Date values in UTC. Turn on schema validation at the collection level so a buggy deploy cannot write garbage.

ts
import { MongoClient } from "mongodb";
 
const client = new MongoClient(process.env.MONGODB_URI!); // reuse per process
const orders = client.db("shop").collection("orders");
 
export async function latestOrders(customerId: ObjectId) {
  return orders
    .find({ customerId }, { projection: { items: 0 } })
    .sort({ createdAt: -1 })
    .limit(20)
    .toArray();
}

Use it when

  • Data with a natural document shape and variable attributes, such as catalogues, CMS content, user profiles or IoT readings.
  • Read-heavy APIs where one document can serve one screen without joins.
  • Workloads that will outgrow a single machine and benefit from built-in sharding and automatic failover.
  • Fast-moving products where the schema evolves weekly, combined with schema validation for safety.

Skip it when

  • Highly relational data with many-to-many links and ad-hoc reporting across entities; a relational database such as PostgreSQL or MySQL fits better.
  • Financial ledgers or workflows that need multi-row transactions on almost every write.
  • When the team only needs a small app database and already knows SQL well; the switch adds learning cost without a clear benefit.

Alternatives

  • PostgreSQL (with JSONB)

    Relational core plus indexed JSON columns; often the best of both for mixed workloads.

  • MySQL

    Mature relational database with a JSON type; strong for classic transactional apps.

  • DynamoDB

    Fully managed key-value and document store on AWS; very scalable but requires strict access-pattern design.

  • Couchbase / Firestore

    Other document stores with different trade-offs around sync, mobile and managed hosting.

Best practices

  1. Design documents from your queries: list the reads first, then shape documents so each is one indexed lookup.
  2. Follow the ESR rule for compound indexes and verify every important query with explain("executionStats").
  3. Keep arrays bounded; anything that can grow forever (comments, events) belongs in its own collection.
  4. Enable JSON Schema validation on collections and store money as Decimal128 and dates as UTC Date.
  5. Run a three-member replica set at minimum and keep the default majority write concern for important data.
  6. Enable authentication and TLS, bind to private interfaces only, and never expose port 27017 to the internet.
  7. Monitor slow queries with the profiler or Atlas Query Insights and review indexes regularly.

Common mistakes

  • Treating “schemaless” as “no schema”: inconsistent field names and types end up breaking queries and reports.
  • Modelling it like SQL with many small collections and $lookup everywhere, losing the main benefit of documents.
  • Unbounded arrays that push documents toward the 16 MB limit and make every update rewrite a huge document.
  • Missing indexes, so queries fall back to collection scans that look fine in development and collapse in production.
  • Publicly exposed instances without authentication, which have been the cause of many real data leaks.

Deep dive

07WiredTiger: how storage really works

WiredTiger is MongoDB's storage engine. It uses document-level concurrency control, so two writers only block each other when they touch the same document. Data and indexes are B-trees compressed on disk (snappy by default, zstd optional). WiredTiger keeps a cache in RAM, by default about 50% of (RAM minus 1 GB); performance depends on the working set (frequently used documents plus indexes) fitting there. Writes go to a journal first and are checkpointed to data files periodically, so a crash recovers from the last checkpoint plus the journal. When the working set does not fit, you see cache eviction pressure and disk reads dominating latency, which is the moment to add RAM, trim indexes or shard.

  • Watch db.serverStatus().wiredTiger.cache for dirty and evicted bytes.
  • Indexes must fit in RAM for fast queries; check sizes with db.collection.stats().

08Sharding and choosing a shard key

Sharding splits a collection across several replica sets. A router (mongos) sends each query to the shards that own the relevant ranges of the shard key. The shard key decides everything: it should have high cardinality, spread writes evenly and appear in most queries. A monotonically increasing key such as a timestamp or ObjectId sends all inserts to one shard (a hot shard); hashed sharding fixes the spread but makes range queries scatter to every shard. A compound key such as { tenantId: 1, createdAt: 1 } often works well for multi-tenant apps. Since 5.0 you can reshard a collection online and since 6.0 the balancer works on data size rather than chunk count, but resharding is still expensive, so choose carefully.

js
sh.enableSharding("shop")
sh.shardCollection("shop.events", { tenantId: 1, createdAt: 1 })
 
// Queries that include tenantId are targeted to one shard
db.events.find({ tenantId: "t_42", createdAt: { $gte: since } })

09Consistency knobs: write concern, read concern, causal sessions

MongoDB lets you choose consistency per operation. Write concern w: "majority" waits until most members have the write, so it survives failover; w: 1 is faster but a write can be rolled back if the primary dies before replicating. Read concern "majority" returns only data acknowledged by a majority, "linearizable" gives the strongest guarantee for single-document reads at higher cost, and "snapshot" is used inside transactions. Causally consistent sessions guarantee that a client reads its own writes even when reading from secondaries. The mistake is mixing settings without thinking: writing with w: 1 and reading from secondaries can show users data that later disappears.

10Change streams, TTL and time series

Change streams let an application subscribe to inserts, updates and deletes on a collection, database or cluster, built on the oplog. They are resumable with a resume token, which makes them a solid source for syncing a search index or cache, or for publishing events (often combined with an outbox pattern). TTL indexes delete documents automatically after a time, useful for sessions and temporary tokens. Time series collections store measurements efficiently in buckets, with automatic compression, which suits metrics and IoT data. Atlas also adds Atlas Search (Lucene-based full text) and Vector Search, and recent Community and Enterprise releases began bringing search and vector search to self-managed deployments.

js
// Expire sessions 24 hours after createdAt
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 })
 
// React to new paid orders
const stream = db.orders.watch([{ $match: { "fullDocument.status": "paid" } }],
  { fullDocument: "updateLookup" })

Real-world scenarios

01

Product catalogue with very different attributes

Problem

A store sells clothes, electronics and furniture. Each category has its own attributes, and the relational model has turned into an entity-attribute-value table that is slow and hard to query.

How to solve it

Store each product as one document with common fields at the top (sku, name, price, categoryId) and category-specific attributes in an attrs array of { k, v } pairs. Create a compound index on { "attrs.k": 1, "attrs.v": 1 } (the attribute pattern), so any attribute filter uses one index instead of one index per field. Add a JSON Schema validator for the common fields and enforce category rules in the application. Keep prices as Decimal128. If faceted full-text search is needed, sync products to a search engine through a change stream rather than overloading MongoDB with text queries.

02

Hot shard after launch

Problem

An events collection was sharded on createdAt. After a traffic spike, one shard receives nearly all writes and its CPU is at 100%, while the others are idle.

How to solve it

A timestamp key is monotonic, so all new documents land in the last chunk on one shard. Plan a reshard to a key that spreads writes and still targets queries, for example { tenantId: 1, createdAt: 1 } if queries are per tenant, or a hashed key on a high-cardinality field if they are not. Use reshardCollection, which runs online but copies the data, so schedule it in a quiet window, check free disk space (it needs roughly double) and monitor progress with currentOp. Meanwhile, batch inserts and confirm the balancer is running to reduce pressure.

03

Orders disappear after a failover

Problem

After a primary crashed, a few orders that customers saw confirmed are missing. The app writes with w: 1 for speed.

How to solve it

With w: 1 the primary acknowledges before replicating; if it dies, the new primary never saw those writes and the old one rolls them back on rejoin. Switch important writes to w: "majority" (the default since 5.0) and add a reasonable wtimeout. Recover lost writes from the rollback files on the old primary. Make sure the app connects with the replica set URI and retryable writes enabled (default in modern drivers), so a write in flight during failover is retried once safely. Accept the small latency cost; correct orders are worth more than milliseconds.

Cheat sheet

Connect with mongosh
mongosh "mongodb://your_user@localhost:27017/shop?authSource=admin"
Create a compound index
db.orders.createIndex({ customerId: 1, createdAt: -1 })
Explain a query
db.orders.find({ status: "paid" }).explain("executionStats")
List indexes and usage
db.orders.aggregate([{ $indexStats: {} }])
TTL index
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
Replica set status
rs.status()
Enable profiler for slow ops
db.setProfilingLevel(1, { slowms: 100 })
Backup a database
mongodump --uri="$MONGODB_URI" --db=shop --out=/backups/2026-11-05
Add schema validation
db.runCommand({ collMod: "orders", validator: { $jsonSchema: { required: ["status"] } } })

Questions & answers

Q1When would you choose MongoDB over PostgreSQL?

I would choose MongoDB when the data is naturally hierarchical, the shape varies between records, and most reads fetch one aggregate at a time, for example a product catalogue with very different attributes per category or a content tree. It also helps when you expect to shard horizontally. I would stay with PostgreSQL when the data is relational, when reporting needs flexible joins, or when strict transactions across entities are central. PostgreSQL's JSONB covers many semi-structured cases, so the honest answer is: pick MongoDB for document-shaped access patterns, not just to avoid writing migrations.

Q2How do you decide between embedding and referencing?

I look at three things: how the data is read, how big it can grow, and how often it changes independently. If the child is always read with the parent, is bounded in size and belongs only to that parent, I embed it, like order line items. If it is shared between parents, grows without limit or is updated on its own, I reference it, like comments or products. There are hybrid patterns too: embed a small summary (name, price) and reference the full record, accepting some duplication. The guiding principle is that data accessed together should be stored together.

Q3Does MongoDB support transactions?

Yes. A write to a single document is always atomic, including nested fields and arrays, which covers most cases if the model is good. Multi-document ACID transactions have been available on replica sets since 4.0 and on sharded clusters since 4.2. They work with snapshot isolation and are committed with a write concern. The cost is extra latency, locks held for the transaction duration and a default 60-second limit. If you find yourself needing transactions on almost every write, it is a signal that the data may be better modelled differently or that a relational database is a better fit.

Q4What is a replica set and why do you need three members?

A replica set is a group of MongoDB servers holding the same data. One primary accepts writes and secondaries replicate its oplog. If the primary fails, the others hold an election and promote a secondary, usually within seconds. An election needs a majority of voting members, so with two members, losing one leaves no majority and the set becomes read-only. Three members can lose one and still elect a primary. You can use an arbiter as the third vote, but it holds no data and weakens majority write guarantees, so three data-bearing members is the safer default.

Q5How do you find and fix a slow query?

First I find it: the database profiler or slow query log, or Atlas Query Insights, shows queries above a threshold. Then I run the query with explain("executionStats") and look at the winning plan. A COLLSCAN stage or a large gap between totalDocsExamined and nReturned means a missing or poorly ordered index. I design a compound index following the equality, sort, range rule, check that the sort is covered by the index so there is no in-memory SORT stage, and add a projection to return less data. Finally I remove unused indexes, because each index slows writes.

Q6Is MongoDB schemaless?

Not in practice. The database does not force a fixed schema, so documents in one collection can differ, which is useful during change. But your application always has a schema, written or not. Good teams make it explicit: a JSON Schema validator on the collection, typed models in the code (for example Mongoose or TypeScript types) and migration scripts when a field changes meaning. The flexible part is how changes are rolled out: you can add a field and backfill it gradually, instead of running a blocking ALTER TABLE on a large table.

Senior-level questions

S1How would you design a multi-tenant SaaS on MongoDB?

There are three models. Database per tenant gives strong isolation and easy per-tenant backup, but thousands of databases strain the server through many files and connections. Collection per tenant has similar limits. A shared collection with a tenantId field on every document scales best: every index starts with tenantId, every query must include it (enforced in a data-access layer, not by convention), and when you shard, tenantId leads the shard key so most queries target one shard. Large tenants can be moved to dedicated zones with zone sharding. The trade-off is isolation: one bug in the filter can leak data, so I would add tests and a repository layer that refuses queries without a tenant.

S2What are the trade-offs of multi-document transactions in a sharded cluster?

Distributed transactions use two-phase commit across shards, coordinated by one shard. They give snapshot isolation and atomicity, but every participating shard holds locks and keeps old versions in cache until commit, so long transactions increase cache pressure and latency for everyone. They also abort on write conflicts and must be retried by the application; drivers help with the withTransaction helper. The default lifetime is 60 seconds. My approach is to design so that invariants live inside one document, use transactions only for rare cross-document cases such as money transfers, keep them short and small, and ensure that the transaction touches as few shards as possible by choosing the shard key with that path in mind.

S3How do you keep MongoDB and a search index or cache consistent?

Dual writes from the application (write to MongoDB, then to Elasticsearch) fail partially and drift over time. A better approach is change data capture: a worker consumes a change stream, stores the resume token durably after each processed batch, and updates the index idempotently using the document _id. If the worker restarts, it resumes from the token; if it falls too far behind and the oplog window passes, you need a full reindex, so size the oplog for your worst outage and alert on lag. For publishing domain events to other services, the outbox pattern works well: write the event into an outbox collection in the same transaction as the change, and relay it from there.

Glossary

Document
One record stored as BSON, a binary form of JSON with extra types, up to 16 MB.
Collection
A group of documents in a database, roughly comparable to a table.
Replica set
Several servers with the same data; one primary takes writes and secondaries take over if it fails.
Oplog
The operation log on the primary that secondaries replay to stay in sync.
Write concern
How many replica set members must confirm a write before it counts as successful.
Shard key
The field or fields used to split a collection's data across shards.
Aggregation pipeline
A sequence of stages that filter, group and reshape documents, like SQL GROUP BY and joins.
ESR rule
Order compound index fields as Equality, then Sort, then Range for the most efficient queries.
Change stream
A resumable feed of insert, update and delete events on a collection or database.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.