Database Foundations

MongoDB β€” Documents, Sharding, and the NoSQL Default

MongoDB made a deliberate bet in its early days: store data as flexible JSON-like documents, skip the rigid table-and-column contract, and bake horizontal sharding into the architecture from day one. That bet paid off for a generation of web apps whose data shapes refused to stay tidy. Along the way MongoDB sparked honest debates about durability defaults and what "safe" writes really mean β€” debates that made the whole industry think harder about trade-offs. Today it shines brightest for variable-shape data, read-heavy workloads, and teams that need to iterate fast on a schema that isn't fully known yet.

8 Think Firsts ~25 SVG Diagrams 24 Sections ~40 Tooltips 5 Exercises
Section 1

TL;DR β€” MongoDB in Plain English

  • Why MongoDB stores data as flexible documents instead of fixed rows and columns
  • How replica sets and sharded clusters give you high availability and horizontal scale out of the box
  • What BSON is, and why the wire format matters for performance and type fidelity
  • When MongoDB wins β€” and when a relational database or a different NoSQL store is the smarter choice

MongoDB trades the rigid table-and-column contract for flexible documents β€” and that one swap reshapes how you model data, scale horizontally, and iterate fast on a schema that isn't fully known yet.

MongoDB Sharded Cluster β€” Big Picture Application Your code mongos Query Router Shard 1 β€” Replica Set Primary + 2 Secondaries Shard 2 β€” Replica Set Primary + 2 Secondaries Shard 3 β€” Replica Set Primary + 2 Secondaries Config Servers chunk map, routing metadata reads chunk map
MongoDB stores records as documents β€” self-contained JSON-like objects that can have any shape. There's no rigid schema contract: field A can be an integer in one document and an array in the next. Under the hood MongoDB uses BSON (binary JSON) for storage and the wire protocol, which adds richer types (dates, ObjectIds, binary blobs) that plain JSON can't express. A collection is just a named bucket of related documents, analogous to a table β€” but without the enforced column list.
Flexibility plus horizontal scale in one package. Replica sets give you automatic failover: if the primary goes down, an election promotes a secondary in seconds. Sharding splits a collection across multiple replica sets by a shard key, so you can spread both data and write load across many machines. An aggregation pipeline lets you transform, filter, group, and join data server-side without pulling everything into your application layer.
MongoDB shines when your data shapes vary widely (product catalogs, user profiles, content stores), when you're iterating quickly and the schema will evolve, or when you need easy horizontal write scaling. It's a tougher fit for heavily relational data (complex multi-table JOINs), workloads that need strict multi-document ACID transactions at high frequency, or analytical queries that span the whole dataset (a data warehouse fits better there).
MongoDB replaces rows and tables with flexible documents and collections, ships replica-set HA and sharded horizontal scale as first-class features, and uses BSON on the wire for richer types β€” trading relational JOIN convenience for schema agility and write scalability.
Section 2

Why You Need This β€” The Mismatched Schema Problem

Imagine you're building a product catalog for an online marketplace. Your warehouse carries books, televisions, couches, and running shoes. Each category has completely different attributes:

None of these attribute lists overlap much. They don't share a natural common schema.

The Relational Headache

With a relational database like Postgres you have two main options β€” neither is painless:

  1. Separate tables per category β€” books, televisions, couches, shoes. Clean schema, but now adding a new category means a schema migration, and a single "show me all products" query becomes a messy UNION across four tables.
  2. One wide table with a JSONB column β€” products(id, name, price, attributes JSONB). Works, but you've essentially given up on indexing individual attributes efficiently, and you're storing untyped blobs that the DB can't validate.

Either way you're fighting the database instead of working with it.

The Document Store Answer

With MongoDB you create one products collection and each document just carries whatever fields that product type needs. There's no column list to satisfy, no migration to write, no NULL columns for fields that don't apply. A book document has ISBN; a couch document has weight_limit_kg. Neither document needs to know the other exists.

When you add a new product category β€” say, bicycles β€” you start inserting documents with bicycle-specific fields tomorrow. Zero schema migration. The collection grows naturally to accommodate the new shape.

Think First #1

Your social app stores user profiles. Every user has name and email. But poets in your app also store favorite_quotes (an array of strings), climbers store climbing_grade and preferred_discipline, and photographers store camera_gear (a nested object). These user-type-specific fields have dozens of keys and appear on maybe 5–10 % of profiles.

Question: Would you model this in a relational database with a fixed schema, a JSONB column, or a document store? What's your trade-off reasoning?

Think before reading on. The "right" answer depends on your query patterns β€” we'll revisit this in the Mental Model section.

The mismatched-schema problem β€” product catalogs, user profiles, content stores β€” is where document storage pays off: each document carries its own shape, so wildly different attribute sets live comfortably in one collection without migration pain.
Section 3

Mental Model β€” Documents Replace Rows

If you already know SQL, here's the fastest mental map to MongoDB:

Relational (SQL) MongoDB Key difference
Database Database Same concept
Table Collection No enforced column list
Row Document Any shape, nested arrays OK
Column Field Per-document, not per-collection
JOIN across tables Embed or $lookup Embedding avoids the JOIN entirely
Primary key _id field Auto-generated ObjectId if you don't set it

The Big Superpower: Embedding

In SQL, an "order with its line items" requires at least two tables β€” orders and order_items β€” joined at query time. Every read is a JOIN. In MongoDB you can embed the items array inside the order document. Now reading an order and all its items is a single document fetch β€” no JOIN needed.

SQL β€” 4 Tables + 3 JOINs customers id name email 1 Alice a@x.io orders id customer_id total 99 1 $42 FK order_items id order_id product_id qty 1 99 7 2 2 99 12 1 FK products id name price 7 Widget $15 12 Gadget $12 FK 3 JOINs to read one order + items MongoDB β€” 1 Document, 0 JOINs // orders collection {"_id": ObjectId("..."), "total": 42, "customer": { "name": "Alice", "email": "a@x.io" }, "items": [ { "name": "Widget", "price": 15, "qty": 2 }, { "name": "Gadget", "price": 12, "qty": 1 } ] } 1 read β†’ full order + customer + items

The diagram above shows why this matters in practice: reading an order in SQL requires joining four tables. In MongoDB it's one document fetch. That's a meaningful latency difference at scale β€” and it's the core reason teams reach for document stores when "the data is usually accessed together."

Four Design Heuristics for Embedding vs. Referencing

The freedom to embed anything doesn't mean you should embed everything. Here are four rules that experienced MongoDB teams use:

Embed when accessed together

If you always read the order and its items in the same request, embed the items array inside the order document. One read, no JOIN, fast.

Reference when the list is unbounded

Don't embed a user's entire comment history inside the user document β€” a power user might have 100,000 comments, and MongoDB documents have a 16 MB cap. Store comments in a separate collection, reference the user by user_id.

Single-document writes are always atomic

A write that touches one document is guaranteed atomic β€” either the whole update lands or none of it does. This is free, built-in, and requires no transaction syntax. Design your documents so that the operations you care about fit inside one document.

Schema flexibility β‰  schema chaos

MongoDB lets you enforce shapes using $jsonSchema validators at the collection level. You get the flexibility to evolve your schema over time while still catching obviously wrong inserts. Use validators in production; skip them in early prototypes.

MongoDB maps naturally from SQL: databases stay, tables become collections, rows become shape-flexible documents. The killer feature is embedding β€” related data travels together in one document, replacing JOINs with a single read. The four heuristics (embed together, reference unbounded lists, lean on single-doc atomicity, use schema validators) guide 90 % of modeling decisions.
Section 4

Core Concepts β€” The Six Building Blocks

Before diving into how MongoDB works, let's lock in six terms you'll see everywhere. Each one maps to a concrete thing in the system.

Document

The top-level unit of storage β€” like a row, but with a flexible shape. A document is a set of key-value pairs that looks like JSON. It can contain strings, numbers, arrays, nested objects, dates, and special types like ObjectId and Binary. Every document in MongoDB has a special field called _id that uniquely identifies it within its collection.

{ "_id": ObjectId("..."),
  "name": "Alice",
  "tags": ["admin", "user"],
  "address": { "city": "London" }
}

Collection

A named group of related documents β€” like a table, but without a fixed schema. You create a collection by inserting your first document into it. All documents in a collection live on the same set of shards and share the same indexes. Within one collection, documents can have completely different shapes (though in practice you usually keep them similar).

BSON

Binary JSON β€” the wire format and on-disk format MongoDB uses instead of plain JSON text. BSON adds types that JSON doesn't have: ObjectId, Date, Decimal128, Binary, and Int32 / Int64 (JSON has only "number"). BSON is typically faster to parse than JSON because the byte layout encodes field lengths upfront, so the parser can skip fields without scanning the whole document. It's also more compact for typed numeric data.

Replica Set

A group of MongoDB nodes that hold the same data β€” one primary accepts all writes, one or more secondaries replicate from the primary asynchronously. If the primary goes down, the secondaries hold an automatic election and promote one of themselves. This gives you high availability with no manual intervention. A replica set also keeps an oplog β€” an ordered log of every write, which secondaries use to stay in sync (and which is also the foundation of change streams).

Sharded Cluster

The architecture for horizontal scale. A collection is split into chunks (contiguous ranges of shard key values). Each chunk lives on one shard β€” and each shard is itself a replica set. A lightweight process called mongos acts as a query router: your application talks to mongos exactly as it would talk to a regular MongoDB node; mongos figures out which shard(s) have the data and forwards the query. Config servers hold the chunk map (which shard owns which range).

Aggregation Pipeline

A way to transform data on the server before it reaches your application. An aggregation pipeline is a sequence of stages β€” each stage takes documents in, transforms or filters them, and passes results to the next stage. Common stages: $match (filter), $group (aggregate like SQL GROUP BY), $sort, $limit, $project (reshape), $lookup (JOIN from another collection), $unwind (flatten an array into separate documents). This is how MongoDB does analytics and reporting without pulling raw data into your app layer.

Six concepts power all of MongoDB: documents (flexible records), collections (schema-free groups), BSON (the binary wire format), replica sets (HA via primary + secondaries), sharded clusters (horizontal scale via chunk routing), and the aggregation pipeline (server-side data transformation).
Section 5

Documents, BSON, and Schema Flexibility

When you write a document in MongoDB it looks like JSON β€” the familiar curly-brace key-value syntax you already know. But what actually travels over the network and lands on disk is BSON β€” a binary encoding of that same structure. Understanding this distinction matters because it explains some things that otherwise seem magical: how MongoDB knows a field is a date vs a string, why documents support 64-bit integers when JSON only has "number", and why reads can be faster than raw JSON parsing.

JSON vs BSON β€” What Changes on the Wire

Plain JSON stores everything as text. The number 42 is the two characters "4" and "2". A date like "2024-06-15T00:00:00Z" is a 24-character string. JSON has no binary blob type, no distinct integer vs float, and no special ID type. These limitations matter when you need precision (64-bit integers lose precision in JavaScript), performance (parsing text is slower than reading fixed-width binary), or richer semantics (a date should sort chronologically, not lexicographically).

BSON solves all of these by adding a type byte before every value. The parser reads the type, knows exactly how many bytes to consume, and moves on β€” no scanning for a closing quote or bracket needed.

Same Field β€” JSON Text vs BSON Binary JSON (text) "age": 27 8 chars for key + colon + space + 2 chars for value = ~12 bytes of text Type unknown until full parse. "27" could be int, float, or string. BSON (binary) 0x10 type "age\0" key (CString) 1B 00 00 00 int32 LE (27) 1 type byte + 4 key bytes + 4 value bytes = 9 bytes Type known immediately. Parser skips to next field in one jump. BSON Types JSON Can't Express ObjectId 12-byte unique ID (timestamp + machine + process + counter). Auto-generated _id. Date 64-bit milliseconds since epoch. Sorts chronologically. JSON just stores a string. Decimal128 128-bit decimal float for financial precision. JSON's "number" loses precision past 2^53. Binary Arbitrary byte arrays (images, encryption keys). Not encodable in plain JSON. Int32 / Int64 Distinct integer types. Critical when counting beyond JavaScript's safe integer limit.

Schema Flexibility β€” Power and Responsibility

Because MongoDB doesn't enforce a schema at the collection level by default, you can insert documents with any shape at any time. This freedom is genuinely useful during early development β€” you can add new fields as your understanding of the domain grows without writing a migration script. But "no schema enforced" doesn't mean "anything goes in production." Messy documents are harder to query, harder to index, and a nightmare for anyone reading the code six months later.

The modern approach: start flexible during prototyping, then add a $jsonSchema validator to the collection once your shape has stabilized. The validator rejects inserts or updates that don't match the defined schema β€” catching bugs at the database layer before they spread.

// Insert a single document into the "products" collection.
// If the collection doesn't exist yet, MongoDB creates it on the fly.
db.products.insertOne({
  name: "Wireless Keyboard",
  brand: "Logitech",
  price_usd: 79.99,
  tags: ["electronics", "keyboard", "wireless"],
  specs: {
    connectivity: "Bluetooth 5.1",
    battery_months: 24,
    key_count: 104
  },
  created_at: new Date()   // stored as BSON Date, not a string
});

// MongoDB returns:
// { acknowledged: true, insertedId: ObjectId("665a3f...") }
// The _id was auto-generated as a 12-byte ObjectId.

Notice new Date() β€” this stores a proper BSON Date, not a string. That distinction matters for date range queries and TTL index expiry.

// The same "products" collection can hold documents with totally different shapes.
// No migration needed when you add a new product category.

// A book
db.products.insertOne({
  _type: "book",
  title: "Designing Data-Intensive Applications",
  author: "Martin Kleppmann",
  isbn: "978-1491903001",
  pages: 616
});

// A television (completely different fields)
db.products.insertOne({
  _type: "television",
  brand: "Samsung",
  screen_inches: 65,
  resolution: "4K",
  hdmi_ports: 4,
  refresh_rate_hz: 120
});

// A couch (yet another shape β€” no NULL columns for the fields that don't apply)
db.products.insertOne({
  _type: "couch",
  material: "velvet",
  seat_count: 3,
  dimensions_cm: { w: 220, d: 95, h: 85 },
  weight_limit_kg: 300
});

Adding a _type field (or using the newer $jsonSchema) helps your application code distinguish document types at read time without requiring separate collections.

// Add a $jsonSchema validator to enforce shape AFTER your schema has stabilized.
// This validator runs on every insert and update β€” the DB rejects violations.
db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email", "created_at"],
      properties: {
        name: {
          bsonType: "string",
          description: "must be a string and is required"
        },
        email: {
          bsonType: "string",
          pattern: "^.+@.+\\..+$",
          description: "must be a valid email and is required"
        },
        age: {
          bsonType: "int",
          minimum: 0,
          maximum: 150,
          description: "optional integer 0-150"
        },
        created_at: {
          bsonType: "date",
          description: "must be a BSON Date"
        }
      }
    }
  },
  validationAction: "error"  // reject the write (vs "warn" which logs but allows)
});

// Now this insert will be REJECTED β€” missing required "email":
// db.users.insertOne({ name: "Bob", created_at: new Date() })
// β†’ MongoServerError: Document failed validation

Pro tip: start with validationAction: "warn" when retrofitting validators onto an existing collection. Warnings surface in the server logs so you can see what existing documents violate the schema before enforcing them.

MongoDB documents look like JSON but travel and live on disk as BSON, which adds crucial types (ObjectId, Date, Decimal128, Binary) that plain JSON can't express β€” and makes parsing faster via upfront type tags. Schema flexibility is the default but not a requirement: $jsonSchema validators bring relational-style shape enforcement when you're ready for it.
Section 6

Indexes β€” From Equality to Geospatial

Here's a question that reveals a lot about how databases work: if you have ten million documents in a collection and you run db.users.find({ email: "alice@example.com" }) β€” how does MongoDB find it?

Without an index, MongoDB has no choice but to look at every single document from start to finish. That's called a collection scan (the equivalent of a full table scan in SQL). For ten million documents that's slow β€” potentially seconds. With an index on email, MongoDB jumps directly to Alice's document in a handful of operations. That's the entire point of indexes: they trade a small amount of write overhead and storage space for dramatically faster reads.

How MongoDB Indexes Work β€” The B-tree Under the Hood

MongoDB's default index structure is a B-tree β€” the same balanced tree structure used by Postgres, MySQL, and most other databases. A B-tree keeps values sorted, which means it supports both exact equality lookups (email = "alice@example.com") and range lookups (age >= 25 AND age <= 35) efficiently.

B-tree Index on users.email β†’ Points into Collection m@x.io alice@x.io z@x.io alice@x.io carol@x.io points to doc on disk users collection (on disk) { _id: ..., email: "alice@x.io", age: 28 } { _id: ..., email: "carol@x.io", age: 34 } ... (10M more docs) leaf level: sorted β†’ range queries fast

The leaf level of a B-tree holds the actual index values in sorted order. For a range query like age >= 25, MongoDB finds the first matching leaf and walks forward β€” no random access needed. That's why the B-tree supports both equality and range queries with a single structure.

The Six Index Types You'll Actually Use

Single-Field Index

The default. Create one on any field you query frequently by equality or range. Supports both ascending and descending scans (direction only matters for sort performance on compound indexes).

db.users.createIndex({ email: 1 })
// 1 = ascending, -1 = descending

Compound Index

Covers queries on multiple fields together. Follows the same left-prefix rule as SQL composite indexes: an index on (a, b, c) can satisfy queries on a, a + b, or a + b + c β€” but NOT a query on b alone.

db.orders.createIndex({ status: 1, created_at: -1 })
// Satisfies: { status: "shipped" } sorted by date

Multikey Index

Automatically created when you index a field that holds an array. MongoDB creates one index entry per array element, so you can query { tags: "electronics" } and it will hit the index β€” even though tags is an array. One limitation: you can't create a compound multikey index if both indexed fields are arrays.

db.products.createIndex({ tags: 1 })
// { tags: ["electronics","wireless"] }
// β†’ creates entries for BOTH "electronics" AND "wireless"

Text Index

Powers full-text search across string fields. Tokenizes words, strips stop words, and supports phrase search and word-stem matching. A collection can have only one text index (but it can cover multiple fields). For large-scale search needs, many teams move to dedicated tools like Elasticsearch β€” the text index covers basic search well enough for moderate traffic.

db.articles.createIndex({ title: "text", body: "text" })
db.articles.find({ $text: { $search: "machine learning" } })

Geospatial (2dsphere)

For "find things near me" queries. Indexes GeoJSON geometry fields (Points, LineStrings, Polygons). Supports $near (closest N), $geoWithin (inside a polygon), and $geoIntersects. Uses an S2 spherical geometry library, so distance calculations are accurate across the Earth's curvature.

db.places.createIndex({ location: "2dsphere" })
db.places.find({
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [-0.127, 51.507] },
      $maxDistance: 1000  // metres
    }
  }
})

TTL Index

Automatically deletes documents after a set number of seconds. Perfect for sessions, cache entries, temporary tokens, or log data you only need for N days. MongoDB runs a background task roughly every 60 seconds to expire documents β€” so deletion isn't instantaneous, but it's close enough for most use cases.

// Sessions expire 30 minutes after last_active
db.sessions.createIndex(
  { last_active: 1 },
  { expireAfterSeconds: 1800 }
)
// MongoDB deletes documents where
// Date.now() - last_active > 1800 seconds
// --- Single-field index on email ---
db.users.createIndex({ email: 1 }, { unique: true })
// unique: true adds a uniqueness constraint β€” duplicate emails are rejected.

// --- Check which indexes exist on a collection ---
db.users.getIndexes()
// Returns an array; _id index always exists by default.

// --- Drop an index you no longer need ---
db.users.dropIndex("email_1")
// Index names default to fieldName_direction (email_1, age_-1, etc.)

// --- Use explain() to confirm the index was hit ---
db.users.find({ email: "alice@example.com" }).explain("executionStats")
// Look for: winningPlan.stage === "IXSCAN" (index scan) vs "COLLSCAN" (full scan)
// totalDocsExamined should be 1 for a unique index hit.
// Fetch shipped orders, newest first β€” a very common dashboard query.
// Without an index this is a full collection scan + in-memory sort.

// Create compound index: status ascending, created_at descending.
db.orders.createIndex({ status: 1, created_at: -1 })

// The query now uses the index for BOTH the filter AND the sort β€”
// no in-memory sort step needed.
db.orders.find({ status: "shipped" }).sort({ created_at: -1 })

// LEFT-PREFIX RULE in practice:
// This index can also satisfy: { status: "pending" }           βœ…
// But NOT:  { created_at: { $gte: someDate } }                 ❌ (status missing)
// And NOT:  { status: "shipped" }.sort({ user_id: 1 })         ❌ (user_id not in index)

// Verify with explain:
db.orders.find({ status: "shipped" })
  .sort({ created_at: -1 })
  .explain("executionStats")
// Look for: "IXSCAN" + "nReturned" close to "totalKeysExamined"
// Auto-expiring sessions collection.
// Documents are deleted ~60s after (last_active + expireAfterSeconds).

db.sessions.createIndex(
  { last_active: 1 },
  { expireAfterSeconds: 1800 }   // 30 minutes
)

// Insert a session
db.sessions.insertOne({
  session_token: "abc123xyz",
  user_id: ObjectId("..."),
  last_active: new Date(),       // MUST be a BSON Date, not a string
  data: { cart: [], prefs: {} }
})

// Refresh a session on each request
db.sessions.updateOne(
  { session_token: "abc123xyz" },
  { $set: { last_active: new Date() } }
)

// MongoDB's TTL thread will delete this document automatically
// once 30 minutes pass since last_active.
// Note: the thread runs ~every 60s, so expiry is approximate.

// WARNING: TTL field MUST store a BSON Date (or array of Dates).
// Storing a string like "2024-06-15T12:00:00Z" will NOT expire the document.
Index Cost β€” Every Write Pays

Every index you add must be updated on every insert, update, and delete that touches an indexed field. A collection with 8 indexes pays 8 update costs on every write. For read-heavy workloads this trade-off is almost always worth it. For write-heavy workloads (high-frequency logging, event streams), index counts matter β€” profile your real queries with explain() before adding indexes speculatively, and remove indexes you're not using.

MongoDB's B-tree indexes work exactly like SQL indexes β€” sorted structures that convert collection scans into targeted pointer jumps. The six types (single-field, compound, multikey, text, 2dsphere, TTL) cover the full spectrum from equality lookups to full-text search to location queries to automatic data expiry. Every index has a write cost β€” use explain() to confirm indexes are hit before adding more.
Section 7

Replica Sets β€” High Availability Out of the Box

Imagine your database server crashes at 2 a.m. Do you want your whole application to go down with it? Of course not. MongoDB's answer to this is the replica set β€” a group of servers that all hold the exact same data. If one server dies, the others keep running and one of them steps up to take over automatically. Your application barely notices.

A replica set always has one primary node and one or more secondary nodes. All writes go to the primary. Secondaries watch the primary's operation log (the oplog) and replay every write to keep their own copies current. It's like a transcriptionist copying everything the primary writes β€” in near real-time.

Normal Operation App writes here PRIMARY accepts all writes oplog (capped) tails oplog tails oplog SECONDARY 1 replica of primary SECONDARY 2 replica of primary After Primary Fails PRIMARY CRASHED βœ— election vote NEW PRIMARY promoted in ~10-30s SECONDARY still running App reconnects Typical Failover Timeline 1. Primary stops responding to heartbeats 2. Secondaries detect failure (~5-10s) 3. Election completes, new primary writes resume (~10-30s total)

Four Things Every Developer Should Know

The Oplog

The oplog (operation log) is a special capped collection on the primary that records every write in order β€” think of it as a changelog for the database. Secondaries tail the oplog just like a Unix tail -f and replay each entry to stay in sync. Because it's capped, older entries roll off after a while. The oplog window (how far back it holds history) is typically hours to days on a healthy cluster β€” important for recovering a secondary that was offline briefly.

Elections

When the primary goes silent, secondaries hold an automatic election using a Raft-like protocol. Each node votes for the candidate with the most up-to-date oplog. The winner needs a majority of votes β€” that's why you want an odd number of nodes (3, 5, 7). With 2 nodes, neither can ever reach majority alone; with 3 nodes, the 2 survivors always have majority. An "arbiter" node counts for voting but holds no data β€” it's a cheap way to break ties.

Read Preference

By default, clients read from the primary. But you can dial this. primaryPreferred reads from primary but falls back to a secondary if the primary is unreachable. secondary always reads from a secondary β€” useful for analytics queries you don't want competing with writes. nearest picks the node with the lowest network latency regardless of role. The trade-off: secondaries can be slightly behind (eventual consistency), so use primary when you need the absolute latest value.

Write Concern

Write concern tells MongoDB how many nodes must acknowledge a write before calling it "done." w: 1 means just the primary confirmed it β€” fastest, but data could be lost if the primary crashes before replicating. w: majority waits for a majority of nodes to confirm β€” safe against primary failure, slightly slower. w: 3 waits for exactly 3 nodes. For financial data or any write you can't afford to lose, always use w: majority.

Production Minimum: 3 Nodes

Most production deployments run at least 3 replica set nodes. With 3 nodes you can survive a single-node failure and still have the majority needed (2 of 3) to elect a new primary without any manual intervention. Typical failover takes around 10–30 seconds in healthy network conditions β€” most applications retry connections transparently in that window.

A replica set keeps 1 primary + N secondaries in sync via the oplog; on primary failure, secondaries auto-elect a new primary in ~10-30 seconds. Read preference and write concern let you tune the consistency-vs-latency dial per query.
Section 8

Sharded Clusters β€” Horizontal Scale

A replica set is great at high availability β€” but it doesn't help with scale. Every node holds the complete dataset, so adding more secondaries doesn't add more write capacity or more storage. At some point your data simply outgrows one machine. That's when you need sharding.

Sharding splits a collection across multiple machines. Instead of one replica set holding 10 TB, you might have 10 shards each holding 1 TB. Writes and reads spread across all of them in parallel. The trick is deciding which documents go to which shard β€” that's the job of the shard key.

Sharded Cluster Architecture Client your app mongos Query Router (stateless) Config Servers chunk map + metadata reads chunk map Shard 1 β€” Replica Set keys A–G | Primary + 2 Secondaries Shard 2 β€” Replica Set keys H–N | Primary + 2 Secondaries Shard 3 β€” Replica Set keys O–T | Primary + 2 Secondaries Shard 4 β€” Replica Set keys U–Z | Primary + 2 Secondaries Balancer (background) moves chunks between shards to equalize load

The three moving parts: shard servers hold the actual data (each is a replica set for HA), config servers hold a metadata map of which chunk of data lives on which shard, and mongos routers are the stateless entry point your app connects to. mongos reads the config servers' chunk map and forwards each query to the right shard(s). Your application doesn't need to know any of this β€” it just talks to mongos like a normal MongoDB server.

Three Sharding Strategies

Ranged Sharding

Documents are assigned to shards based on ranges of the shard key value. For example, user IDs 1–10,000 go to Shard 1, 10,001–20,000 go to Shard 2, and so on. The upside is that range queries (user_id: { $gte: 5000, $lte: 8000 }) can be routed to a single shard β€” no fan-out. The downside is hotspots: if most of your new writes have high user IDs (monotonically increasing), they'll all pile onto the last shard.

Hashed Sharding

MongoDB hashes the shard key value and uses the hash to assign the document to a shard. This distributes documents evenly at random β€” no hotspots, perfect write balance. The cost: range queries now scatter across all shards because adjacent key values hash to different locations. Best for insert-heavy workloads where point lookups are more common than ranges.

Zoned (Tag-Aware) Sharding

You pin specific shard key ranges to specific shards using zones. Example: all documents where region: "EU" must stay on EU-located shards for GDPR compliance. Or: your "hot" products (high view counts) stay on high-IOPS SSDs while archival data lives on cheaper spinning disks. It's the escape hatch when you need data locality for legal, latency, or cost reasons.

Shard Key Choice is Permanent (Almost)

Choosing the wrong shard key is one of the most painful mistakes in MongoDB operations. Changing a shard key after launch is possible since MongoDB 5.0's reshardCollection command, but it's a multi-hour operation on large collections β€” the cluster is still available, but there's significant overhead. Choose a key with high cardinality (many distinct values), even distribution (no single value dominates writes), and query alignment (most queries include it). When in doubt, a hashed index on a frequently-queried unique field is a safe starting point.

A sharded cluster splits data across multiple replica-set shards via a shard key; mongos routers hide the complexity from your app. Three strategies β€” ranged (good for range queries), hashed (even distribution), and zoned (data locality) β€” cover the major trade-offs. Shard key choice is the most consequential schema decision you'll make.
Section 9

Storage Engine: WiredTiger

Every database has a storage engine β€” the layer that actually reads and writes bytes to disk. You usually never think about it, but it decides almost everything about how fast your reads are, how safe your writes are, and how much RAM the database uses. MongoDB switched to WiredTiger as its default storage engine in version 3.2, replacing the older MMAPv1 engine. The difference was dramatic: document-level concurrency instead of collection-level locking, built-in compression, and a proper write-ahead log for crash safety.

WiredTiger Internal Architecture Application / MongoDB Layer Buffer Cache (RAM) Page-level LRU cache β€’ Default: ~50% of system RAM Reads served from cache first; miss β†’ load page from disk writes also go to WAL first Journal (WAL) Sequential write β€’ synced ~every 100ms Crash recovery replays journal entries checkpoint every ~60s B+ Tree (Disk) snappy or zstd compressed pages Checkpoint flushes dirty pages Document-level concurrency β€” multiple writers don't block each other

Four Key WiredTiger Features

Document-Level Concurrency

Old MMAPv1 used a collection-level lock β€” if one writer was updating any document, all other writers to that collection had to wait. WiredTiger lets each writer see its own consistent view of the data without blocking other writers β€” a technique called MVCC (multi-version concurrency control). Writers touching different documents never block each other, which is why MongoDB handles high-concurrency write workloads on the same collection without writers queuing up.

Block Compression

WiredTiger compresses data pages before writing to disk using snappy by default β€” a compression algorithm tuned for speed over ratio, adding very little CPU overhead. Switching to zstd typically saves 30–50 % more space at the cost of slightly more CPU. For index pages, the default is no compression (indexes are already structured data; compressing them rarely saves much). Compression means less disk I/O, which means faster reads from spinning disks and lower storage bills in cloud environments.

Write-Ahead Journal

Before a write is applied to the B+ tree in the buffer cache, WiredTiger first appends it to the journal β€” a sequential append-only log file on disk. If the server crashes before the checkpoint, MongoDB replays the journal on startup to recover any writes that hadn't been checkpointed. The journal syncs to disk roughly every 100 ms (configurable). This is what guarantees no data loss on crash with default settings β€” the write-ahead log is the industry-standard durability pattern used by Postgres, MySQL, and others.

Checkpoint

Every 60 seconds by default, WiredTiger writes all dirty (modified) pages from the buffer cache to the B+ tree files on disk and advances the "checkpoint" marker. Once a checkpoint succeeds, the journal entries up to that point can be discarded β€” they've been safely persisted. Between checkpoints, only the journal guarantees durability. The 60-second interval is a balance: shorter means less journal replay on crash, longer means fewer write amplification spikes during the checkpoint flush.

Tuning the Cache

WiredTiger claims roughly 50% of available RAM for its buffer cache by default β€” intentionally generous, because more cache hits mean fewer disk reads. On a 16 GB server the cache is roughly 7.5 GB. If you're running MongoDB alongside other processes (application servers, log shippers), set wiredTigerCacheSizeGB in mongod.conf to leave headroom. Too small a cache and you'll see cache eviction pressure; too large and the OS has no room to buffer its own I/O, which can actually hurt performance.

WiredTiger gives MongoDB document-level concurrency (via MVCC), block compression (snappy/zstd), a write-ahead journal for crash durability, and 60-second checkpoints to flush dirty pages. The ~50% RAM cache default is intentional β€” tune it down only when sharing a host with other heavy processes.
Section 10

Aggregation Pipeline β€” Server-Side Data Transformation

MongoDB's find() is great for fetching documents that match a filter. But what if you want something more analytical β€” "give me the top 10 users by total order value over the last 30 days" or "for each product category, show me the average review score"? find() alone can't do that. You'd have to pull thousands of documents into your application and crunch numbers in code β€” slow, wasteful, and fragile.

The aggregation pipeline solves this by letting you describe the transformation you want and letting MongoDB do the heavy lifting on the server. Think of it like Unix pipes: documents flow in one end, pass through a sequence of transformation stages, and results come out the other end. Each stage does one job and passes its output to the next.

Aggregation Pipeline β€” Documents Flow Stage to Stage orders { ... } { ... } { ... } 10 M docs $match Filter early uses index βœ“ $lookup JOIN users collection $unwind Explode items array β†’ docs $group Sum revenue per user $sort Top users desc order 10 M docs β†’ 8 M β†’ 8 M (with user) β†’ 12 M (unwound) β†’ 120 K users β†’ 10 results Each stage narrows or reshapes the document stream β€” results returned to app

Eight Stages You'll Use Every Day

$match β€” Filter

Like a SQL WHERE clause. Uses indexes when placed first in the pipeline. Always put $match as early as possible β€” it reduces the number of documents flowing into subsequent expensive stages. A $match late in the pipeline is wasteful because every earlier stage processed documents that were just going to be thrown away.

$project β€” Reshape

Include, exclude, or rename fields. Like SQL's SELECT a, b AS alias. Use it to strip out large fields you don't need downstream β€” less data flowing between stages means faster pipelines.

$group β€” Aggregate

The SQL GROUP BY equivalent. Group documents by a key and compute accumulators: $sum, $avg, $min, $max, $count, $push (collect into array). Example: total revenue per product category.

$lookup β€” JOIN

Left outer join from another collection. Slower than a SQL JOIN (documents aren't pre-joined on disk), but fully functional. Works best when the joined collection is small or well-indexed. For hot paths, design schemas to avoid $lookup entirely by embedding.

$unwind β€” Explode Array

Deconstructs an array field: a document with a 3-element array becomes 3 documents, each with one element. Essential when you need to aggregate on individual array values rather than the whole array.

$sort / $limit / $skip

Order results, take top N, or skip N for pagination. Putting $sort + $limit together is optimized by MongoDB β€” it doesn't need to sort the entire result set if you only want the top 10.

$facet β€” Multiple Pipelines

Run multiple sub-pipelines on the same input in one pass. Classic use case: a search results page that simultaneously returns the matching items and the facet counts (how many results per category, per price range, etc.) without two separate queries.

// Top 5 users by number of orders β€” classic leaderboard query.
db.orders.aggregate([
  // Stage 1: only count completed orders (uses index on status)
  { $match: { status: "completed" } },

  // Stage 2: group by user_id, count how many orders each user has
  { $group: {
    _id: "$user_id",
    order_count: { $sum: 1 },
    total_spent: { $sum: "$amount" }
  }},

  // Stage 3: sort by order count descending
  { $sort: { order_count: -1 } },

  // Stage 4: take only the top 5
  { $limit: 5 }
])

// Result shape:
// [
//   { _id: ObjectId("..."), order_count: 842, total_spent: 18540 },
//   { _id: ObjectId("..."), order_count: 731, total_spent: 12900 },
//   ...
// ]
// Daily revenue for the last 30 days β€” good for a trend chart.
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)

db.orders.aggregate([
  // Filter to last 30 days (uses index on created_at if present)
  { $match: {
    status: "completed",
    created_at: { $gte: thirtyDaysAgo }
  }},

  // Group by calendar date (truncate timestamp to day)
  { $group: {
    _id: {
      $dateToString: { format: "%Y-%m-%d", date: "$created_at" }
    },
    daily_revenue: { $sum: "$amount" },
    order_count: { $sum: 1 }
  }},

  // Sort chronologically
  { $sort: { _id: 1 } }
])

// Result:
// [
//   { _id: "2026-04-08", daily_revenue: 4230, order_count: 87 },
//   { _id: "2026-04-09", daily_revenue: 5110, order_count: 103 },
//   ...
// ]
// Get top orders and JOIN the user's name from the users collection.
// $lookup is MongoDB's left outer JOIN β€” avoid on hot paths; great for reports.
db.orders.aggregate([
  { $match: { status: "completed" } },
  { $sort: { amount: -1 } },
  { $limit: 10 },

  // JOIN users collection on orders.user_id = users._id
  { $lookup: {
    from: "users",
    localField: "user_id",    // field in orders
    foreignField: "_id",      // field in users
    as: "user_info"           // result stored here as array
  }},

  // $lookup returns an array β€” unwind to get a single subdoc
  { $unwind: "$user_info" },

  // Keep only what the app needs
  { $project: {
    order_id: "$_id",
    amount: 1,
    "user_info.name": 1,
    "user_info.email": 1,
    _id: 0
  }}
])

Performance note: $lookup is not as fast as a relational JOIN because MongoDB fetches matching documents from the joined collection at query time rather than using a pre-joined index. For high-traffic endpoints, embed the user's name directly in the order document and skip the lookup entirely.

Pipeline Optimizer Hint

MongoDB's query planner automatically reorders some stages for efficiency β€” for example, it can push a $match ahead of a $project even if you wrote them in the other order. But don't rely on this: write pipelines in the logical order (filter first, then shape, then aggregate) and use .explain("executionStats") to verify the plan.

The aggregation pipeline transforms documents through stages β€” match early (use indexes), project to trim fields, group to aggregate, lookup to join, unwind to explode arrays, and facet to run parallel sub-pipelines. It's MongoDB's answer to SQL analytics, running server-side so no raw data has to travel to your app.
Section 11

Transactions β€” When You Need All-or-Nothing

For a long time, MongoDB's atomicity story was "one document, all-or-nothing." A write that touches a single document either lands completely or doesn't land at all β€” no half-written documents. This covers a surprisingly large number of real-world cases if you design your schema well (embed related data together).

But sometimes you genuinely need to update multiple documents β€” or multiple collections β€” as a single atomic unit. Transfer $100 from Alice's account to Bob's: you must debit Alice and credit Bob or do neither. The relational world calls this all-or-nothing guarantee a transaction, and the bundle of safety properties it brings β€” atomic, consistent, isolated, durable β€” gets the acronym ACID. MongoDB 4.0 added full multi-document ACID transactions across a replica set. MongoDB 4.2 extended them to sharded clusters. Today, MongoDB has full transaction semantics β€” but they come with a cost, so reach for them deliberately.

Multi-Document Transaction Flow 1. Start Session client.startSession() 2. Begin Txn session.startTransaction() 3. Operations debit Alice –$100 credit Bob +$100 COMMIT all changes persist ABORT all changes rolled back Auto-abort after 60s timeout Snapshot Isolation (Default) When a transaction begins, MongoDB takes a snapshot of the data at that point in time. Reads inside the transaction always see this consistent snapshot β€” even if other writers commit concurrently.

Four Things to Know Before Reaching for Transactions

Snapshot Isolation

The default isolation level inside a MongoDB transaction is snapshot β€” when your transaction starts, it sees a consistent point-in-time view of all data. Concurrent writers don't affect what you read. This prevents dirty reads, non-repeatable reads, and phantom reads β€” the classic isolation bugs. It's the same isolation level Postgres uses by default.

Cross-Shard Transactions Are Slow

Single-shard transactions are fast. Cross-shard transactions require a two-phase commit across coordinator nodes β€” dramatically more round trips, locks held longer, higher latency. If your transaction touches data on 4 different shards, every operation in it has to coordinate across all 4. This is why good shard key design matters: keep data that belongs to the same transaction on the same shard.

60-Second Timeout

A transaction that hasn't committed within 60 seconds is automatically aborted. This prevents abandoned transactions from holding locks indefinitely. The 60-second limit is configurable via transactionLifetimeLimitSeconds β€” but the right answer is usually to shorten transactions, not lengthen the timeout. Keep transactions small and fast.

Best Practice: Schema First

The fact that you can use multi-document transactions doesn't mean you should restructure your whole application around them. The MongoDB team's advice: design your schema so that operations you care about happen inside a single document. Transactions exist for the cases where that's genuinely not possible β€” transfers, order + inventory decrement, etc. Single-document atomicity is free; transactions have overhead.

// Single-document atomic update β€” no transaction syntax needed.
// This entire operation is atomic by default in MongoDB.
// Use case: increment a product's stock count while ensuring it doesn't go negative.

const result = await db.collection("products").findOneAndUpdate(
  {
    _id: productId,
    stock: { $gte: quantity }  // only proceed if enough stock exists
  },
  {
    $inc: { stock: -quantity },           // decrement stock
    $push: { reserved: { orderId, quantity } }  // log the reservation
  },
  {
    returnDocument: "after",   // return the document AFTER update
    upsert: false
  }
)

if (!result) {
  throw new Error("Insufficient stock")  // condition wasn't met β€” no write happened
}

// WHY this works without a transaction:
// findOneAndUpdate is a single atomic operation on one document.
// The $gte check and the $inc happen together β€” no race condition possible.
// Multi-document transaction β€” transfer funds between two accounts.
// This MUST be atomic: debit Alice AND credit Bob, or do neither.

const client = await MongoClient.connect(uri)
const session = client.startSession()

try {
  session.startTransaction({
    readConcern: { level: "snapshot" },
    writeConcern: { w: "majority" }
  })

  const accounts = client.db("bank").collection("accounts")

  // Check and debit Alice
  const alice = await accounts.findOne({ _id: "alice" }, { session })
  if (alice.balance < 100) throw new Error("Insufficient funds")

  await accounts.updateOne(
    { _id: "alice" },
    { $inc: { balance: -100 } },
    { session }
  )

  // Credit Bob
  await accounts.updateOne(
    { _id: "bob" },
    { $inc: { balance: 100 } },
    { session }
  )

  // Both succeeded β€” commit the transaction
  await session.commitTransaction()
  console.log("Transfer complete")

} catch (err) {
  // Something failed β€” roll back both changes
  await session.abortTransaction()
  throw err
} finally {
  session.endSession()
}
// Cross-shard transactions can fail with transient errors (network blips,
// election during commit). The right response is to retry β€” not to crash.

async function transferWithRetry(fromId, toId, amount, maxRetries = 3) {
  const client = await MongoClient.connect(uri)
  const session = client.startSession()

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      session.startTransaction({ writeConcern: { w: "majority" } })

      const accounts = client.db("bank").collection("accounts")
      await accounts.updateOne({ _id: fromId }, { $inc: { balance: -amount } }, { session })
      await accounts.updateOne({ _id: toId   }, { $inc: { balance:  amount } }, { session })

      await session.commitTransaction()
      console.log(`Transfer succeeded on attempt ${attempt}`)
      return   // success β€” exit retry loop

    } catch (err) {
      await session.abortTransaction()

      // Transient errors are safe to retry; others are not.
      const isTransient =
        err.hasErrorLabel("TransientTransactionError") ||
        err.hasErrorLabel("UnknownTransactionCommitResult")

      if (!isTransient || attempt === maxRetries) throw err

      // Exponential backoff before retry
      await new Promise(r => setTimeout(r, 100 * attempt))
    }
  }

  session.endSession()
}

// MongoDB drivers define TransientTransactionError and UnknownTransactionCommitResult
// error labels specifically to guide retry logic β€” always check for these labels.
MongoDB has full ACID multi-document transactions since v4.0 (replica sets) and v4.2 (sharded clusters), using snapshot isolation by default. Single-document atomicity is free and fast β€” use it whenever schema allows. Transactions carry overhead, especially cross-shard; always retry on transient errors.
Section 12

Consistency & Read Preferences β€” The Freshness Dial

Not every read in your application needs to be perfectly up-to-date. Your user's dashboard showing "total orders placed last month" can tolerate data that's a few hundred milliseconds old. Your shopping cart totals absolutely cannot. MongoDB gives you a per-query dial that lets you trade data freshness for speed β€” or hold out for guaranteed consistency when accuracy matters.

This dial is controlled by two settings: read preference (which node to read from) and read concern (how fresh the data must be). Together with write concern, they form MongoDB's full consistency contract.

Read Preference Spectrum β€” Consistency vs Latency ← Most consistent Lowest latency β†’ primary Always reads from primary. 100% fresh. primaryPreferred Primary if available, secondary on failure. secondary Always secondary. Possibly stale. secondaryPreferred Secondary if available, primary as fallback. nearest Lowest latency node. May be primary or 2Β°. Replica Lag in Practice Healthy clusters typically show 0–100 ms lag. Bulk write operations or a lagging secondary can spike lag to seconds. Monitor with: db.printSecondaryReplicationInfo() or Atlas performance advisor

Write Concerns β€” How Many Nodes Must Confirm?

Write Concern What It Means Speed Safety
w: 0 Fire-and-forget β€” no acknowledgement Fastest Can lose writes
w: 1 Primary acknowledged β€” default pre-5.0 Fast Safe if primary stays up
w: majority Majority of nodes ACKed β€” default since 5.0 Slight overhead Safe against primary failure
w: 3 All 3 specific nodes ACKed Slowest Explicit count β€” rarely needed
Don't Read Sessions from Secondaries

Secondary reads can return data that's slightly behind the primary. For user sessions, authentication tokens, and any data where you must see the latest write immediately, always use primary read preference. A user who just logged in and whose session was written to the primary might get a stale "not logged in" response if their next request reads from a secondary that hasn't replicated yet. This is one of the most common subtle bugs in MongoDB deployments.

MongoDB's read preference dial (primary β†’ nearest) lets you trade freshness for speed on a per-query basis. Write concern (w:1, w:majority) controls how many nodes must confirm a write before it's considered safe. Replica lag is typically 0–100 ms healthy β€” always read sessions and auth data from the primary to avoid stale-read bugs.
Section 13

Schema Design Patterns for Documents

In SQL, schema design means one thing: normalize. Split data into tables, link them with foreign keys, and join them at query time. That rule exists because relational databases were designed around the idea that data is stored once and queried many ways. MongoDB's design principle is almost the opposite: model your data around your queries, not around the data's natural shape.

This swap changes everything. The question isn't "how do I avoid repetition?" β€” it's "what does my app read, and can I make that a single document fetch?" Five patterns cover roughly 95% of real MongoDB apps. Once you recognize them, schema design becomes a lot less mysterious.

SQL Normalization vs MongoDB Document Patterns SQL β€” Normalized (3 tables) posts id title author_id 1 "Mongo" 42 comments id post_id body 1 1 "Great!" FK post_tags post_id tag 1 "nosql" 3 tables β†’ multiple JOINs per read MongoDB β€” 4 Patterns (same data) 1. Embedded (one-to-few) { title:"Mongo", comments:[{body:"Great!"}], tags:["nosql"] } 2. Reference (one-to-many) { user_id: ObjectId("..."), post_ids:[id1,id2,...] } // separate posts collection 3. Bucket (time-series) { hour:"2024-06-15T14", readings:[{t:0,v:22.1}, {t:60,v:22.4}] } 4. Computed counter { title:"Mongo", comment_count: 142, view_count: 9800 } Design around queries β€” pick the pattern your app needs

The Five Canonical Patterns

Embedded One-to-Few

Keep related data inside the parent document as a nested array. An order with its line items, a blog post with its three tags, a user with two addresses β€” these are all "few" relationships. The payoff is a single read that returns everything at once. No join, no second query.

When to embed: the child data is always needed with the parent, the list stays small (under a few hundred items), and the child data has no independent lifecycle.

Reference One-to-Many

Store the child in a separate collection and put only a reference (like user_id) in the parent β€” or vice versa. A user with thousands of orders, a post with hundreds of comments. Embedding these would balloon the parent document, eventually hitting MongoDB's 16 MB per-document cap. References keep the parent document lean and let you query children independently.

Bucket Pattern

Instead of one document per event (a sensor reading every second = 86,400 documents/day), group events into time windows β€” one document per minute or hour with an array of readings. This dramatically reduces document count, makes range reads faster (one I/O for an hour's data), and shrinks index size. It's the go-to pattern when you're storing a steady stream of events tagged with timestamps β€” sensor readings, app metrics, audit logs β€” the kind of workload the industry calls time-series.

Outlier Pattern

Most documents in a collection are "normal" β€” but a small number are exceptional. A celebrity Twitter account has millions of followers; an average user has 200. If you embed the followers array, the celebrity's document could be gigabytes. The outlier pattern: store the first N entries inline, then set a flag has_overflow: true and write the rest to an overflow collection. Normal documents stay fast; outliers are handled gracefully.

Computed Pattern

Pre-calculate expensive aggregations at write time and store the result on the document. A blog post's comment_count, a product's average_rating, a page's view_count. Reading the count is now a single field fetch β€” not a COUNT(*) aggregation across thousands of rows. You trade a small write overhead (increment the counter on every new comment) for dramatically cheaper reads. Works beautifully for read-heavy metrics.

// EMBEDDED ONE-TO-FEW: order with its line items inside
// One read returns the complete order β€” zero JOINs.
db.orders.insertOne({
  _id: ObjectId("..."),
  customer_id: ObjectId("..."),   // reference to customers collection
  status: "shipped",
  total_usd: 97.50,
  created_at: new Date(),
  // items are EMBEDDED β€” they don't need their own collection
  items: [
    { product_id: ObjectId("..."), name: "Keyboard", qty: 1, price: 79.99 },
    { product_id: ObjectId("..."), name: "USB Hub",  qty: 2, price: 8.75  }
  ],
  shipping_address: {
    street: "12 Baker St",
    city:   "London",
    post:   "NW1 6XE"
  }
});

// Fetch the full order in ONE read β€” no second query for items:
const order = await db.collection("orders").findOne({ _id: orderId });
// order.items[0].name === "Keyboard" β€” already there.
// BUCKET PATTERN: group sensor readings by hour
// Instead of 3,600 documents per sensor per hour β†’ 1 document.
db.sensor_buckets.insertOne({
  sensor_id: "temp-sensor-7",
  hour: new Date("2024-06-15T14:00:00Z"),  // bucket boundary
  count: 60,
  readings: [
    { offset_sec: 0,  value: 22.1 },
    { offset_sec: 60, value: 22.4 },
    { offset_sec: 120, value: 22.3 },
    // ... up to 60 readings per bucket
  ],
  min: 21.9,   // pre-computed for fast range queries
  max: 22.8,
  sum: 1334.6  // pre-computed for average: sum/count
});

// Append a new reading to an existing bucket:
db.sensor_buckets.updateOne(
  { sensor_id: "temp-sensor-7", hour: currentHour },
  {
    $push: { readings: { offset_sec: 180, value: 22.2 } },
    $inc: { count: 1, sum: 22.2 },
    $min: { min: 22.2 },
    $max: { max: 22.2 }
  },
  { upsert: true }  // creates the bucket document if it doesn't exist yet
);
// COMPUTED PATTERN: pre-aggregate comment_count on the post
// Reading the count is now a field lookup β€” not a COUNT(*) query.

// When a new comment is inserted, atomically increment the counter:
async function addComment(postId, commentBody, authorId) {
  const session = client.startSession();
  await session.withTransaction(async () => {
    // 1. Insert the comment in the comments collection
    await db.collection("comments").insertOne({
      post_id: postId,
      author_id: authorId,
      body: commentBody,
      created_at: new Date()
    }, { session });

    // 2. Bump the counter on the post document
    await db.collection("posts").updateOne(
      { _id: postId },
      { $inc: { comment_count: 1 } },
      { session }
    );
  });
}

// Reading the post dashboard β€” comment_count is just a field:
const post = await db.collection("posts").findOne({ _id: postId });
console.log(`${post.comment_count} comments`);
// No COUNT(*) aggregation. No join. Instant.
The Golden Rule of MongoDB Schema Design

Design schemas around your queries, not around the data's natural shape. The most common beginner mistake is copying SQL normalization habits straight into MongoDB β€” splitting everything into separate collections and then being surprised when reads require many $lookup calls. Ask yourself: "What does my app actually read in a single request?" That answer tells you what to embed.

MongoDB schema design centers on five patterns β€” embedded one-to-few (single-read access), reference one-to-many (avoids document bloat), bucket (groups time-series events), outlier (handles exceptional documents), and computed (pre-aggregates metrics at write time) β€” all chosen around query patterns, not data normalization.
Section 14

Working with Aggregations at Scale

An aggregation pipeline is like an assembly line: documents enter one end, each stage does something to them (filter, reshape, group, sort), and results come out the other end. For a few thousand documents this just works β€” the pipeline is fast enough that the order of stages doesn't matter much. For millions of documents, stage order and index awareness become critical. The wrong pipeline can turn a 50 ms query into a 30-second timeout.

The good news: there are a handful of concrete rules. Follow them and your pipelines stay fast even on large collections.

Aggregation Pipeline Optimization β€” Stage Order Matters Bad β€” Sort Before Match $sort 1M docs sorted in mem $match then filter down to 100 result Sorts ALL 1M docs β†’ allocates huge RAM or spills to disk Index on status not used β€” full collection scan Good β€” Match First (uses index) $match index scan β†’ 100 docs $sort sorts only 100 docs result $match hits index β†’ only 100 docs reach $sort stage Trivial memory use, no disk spill Performance Impact (approximate for 1M doc collection) Bad (sort-first): ~8-30 seconds β€” sorts entire collection, may need allowDiskUse Good (match-first): ~50-200ms β€” index scan delivers 100 docs, trivial sort Speedup: 40-150x from stage reordering alone β€” no hardware change needed

Six Rules for Fast Pipelines

Put $match First

Every stage after $match only processes the documents that survive the filter. If your match reduces 1 million documents to 500, everything downstream runs on 500 documents β€” a 2,000x reduction in work. MongoDB can even push the $match filter into an index scan automatically if you place it first.

Sort Early or via Index

A $sort on a large result set is expensive because it must hold all the data in memory at once. If you sort on an indexed field and place $sort right after $match, MongoDB may be able to do an index-ordered scan β€” returning documents already in sorted order, with no in-memory sort at all. Check with explain() whether the sort stage says SORT (bad, in-memory) or is absorbed into the index scan.

$project to Drop Fields Early

If later stages only need five fields from a 40-field document, add a $project early to drop the unused fields. Smaller documents flow through subsequent stages faster, use less memory, and transfer less data between shards on a sharded cluster.

Avoid $lookup When Possible

$lookup is MongoDB's equivalent of a JOIN β€” for each input document it runs a separate query against another collection. At scale this is expensive. If you're running $lookup against a large collection on a frequently-run pipeline, consider embedding the data instead (Schema Design: Embedded One-to-Few). If you must use $lookup, always have an index on the joined field in the foreign collection.

allowDiskUse: true for Large Sorts

By default, an aggregation pipeline that needs more than 100 MB of memory for a sort or group stage will just error out. Adding allowDiskUse: true lets MongoDB spill to disk. This prevents crashes but makes the query significantly slower. Treat it as a safety net, not a performance strategy β€” if you hit the 100 MB limit regularly, fix the pipeline first.

explain() Everything

Never guess whether your pipeline is fast β€” check. Append .explain("executionStats") to any aggregation to see the execution plan: which indexes were used, how many documents were examined at each stage, and how long each stage took. It's the fastest way to catch a missing index or a mis-ordered pipeline before it hurts production.

// BAD: sort before match β€” MongoDB must sort ALL orders before filtering.
// On 1M documents this is slow and may exceed the 100MB memory limit.
db.orders.aggregate([
  { $sort: { created_at: -1 } },   // ← sorts EVERY document first
  { $match: { status: "shipped" } }, // ← then filters (too late!)
  { $project: { customer_id: 1, total_usd: 1, created_at: 1 } }
]);

// Why this is bad:
// 1. $sort on 1M documents needs ~100MB+ RAM.
// 2. The index on { status, created_at } is NOT used because $match comes after $sort.
// 3. MongoDB can't push the filter earlier β€” pipeline stages run in order.
// 4. Likely to fail with: "Exceeded memory limit for $sort stage"
//    unless allowDiskUse:true is added (which makes it even slower).
// GOOD: match first β†’ uses index β†’ sort on tiny result set.
// Requires: db.orders.createIndex({ status: 1, created_at: -1 })
db.orders.aggregate([
  // Stage 1: filter early β€” MongoDB uses the compound index.
  // Only "shipped" orders pass through (~50k out of 1M).
  { $match: { status: "shipped" } },

  // Stage 2: sort the small result set (50k docs, not 1M).
  // Because $match came first, this sort is trivial.
  { $sort: { created_at: -1 } },

  // Stage 3: drop fields we don't need in the result.
  { $project: { customer_id: 1, total_usd: 1, created_at: 1, _id: 0 } },

  // Stage 4: only show the latest 100 results.
  { $limit: 100 }
],
// allowDiskUse only as a safety net for truly massive result sets:
{ allowDiskUse: false }
);

// Result: 50-200ms instead of 8-30 seconds.
// The compound index handles both the filter AND an ordered scan.
// Run explain("executionStats") to verify the pipeline is hitting your index.
db.orders.explain("executionStats").aggregate([
  { $match: { status: "shipped" } },
  { $sort: { created_at: -1 } },
  { $limit: 100 }
]);

// What to look for in the output:
// βœ… GOOD signs:
//   "stage": "IXSCAN"           β†’ index scan used (not COLLSCAN)
//   "inputStage.indexName": "status_1_created_at_-1"
//   "nReturned": 100            β†’ only 100 docs returned
//   "totalDocsExamined": 100    β†’ examined count matches returned count (tight)
//   "executionTimeMillis": 45   β†’ fast

// ❌ BAD signs:
//   "stage": "COLLSCAN"         β†’ full collection scan β€” add an index
//   "totalDocsExamined": 1000000 β†’ examined far more than returned β€” index missing
//   "stage": "SORT"             β†’ in-memory sort β€” $match may not be first, or no index
//   "memUsage": 104857600       β†’ hit 100MB limit β€” pipeline needs restructuring

// Pro tip: look at the "stages" array β€” each element shows one pipeline stage
// and how many documents it ingested vs emitted.
Sharded Clusters Fan Out by Default

On a sharded cluster, an aggregation pipeline that starts without a $match on the shard key gets broadcast to every shard β€” every shard runs the full pipeline, and mongos merges the results. This is called a scatter-gather query. If your collection has 20 shards, you've just done 20x the work. Always include a $match on the shard key as the first stage when you can β€” MongoDB will then route the query to only the one or two shards that have the relevant data.

Fast aggregation pipelines follow six rules: put $match first to cut documents early, sort on indexed fields to avoid in-memory sorts, project away unused fields, minimize $lookup calls, use allowDiskUse only as a safety net, and always verify with explain() β€” on sharded clusters, shard-key matching in $match prevents expensive scatter-gather fan-out.
Section 15

Operational Considerations

Running MongoDB in production means more than just inserting and querying documents. The database is a live, stateful system that needs monitoring, regular backups, security configuration, and occasional upgrades. Teams that skip these basics tend to discover their gaps at 3 AM during an incident. This section is a practical checklist of what matters and why.

MongoDB Production β€” Key Metrics to Watch Replica Lag 0.3s βœ“ Good (target < 10s) Oplog Window 48 h βœ“ Healthy (want > 24 h) WiredTiger Cache Hit 97% βœ“ Great (alert if < 90%) Slow Queries (last 1h) 12 ⚠ Check profiler / explain() Active Connections 143 βœ“ Normal (pooled by drivers) Disk Usage 61% Plan expansion above 80% Replica Lag Alert Logic If lag > oplogWindow β†’ secondary must full resync (expensive!) Alert at: lag > 30s (warning) and lag > oplogWindow/2 (critical) Root cause: write load spike, secondary disk I/O bottleneck, network

Six Production Essentials

Backups

mongodump is a logical backup tool β€” it reads documents and writes them to BSON files. It works fine for small datasets but gets slower as data grows because it reads every document. For production, filesystem snapshots (LVM, EBS snapshots on AWS, or Atlas's continuous backup) are faster and more reliable β€” they capture the storage layer directly in seconds. Ops Manager and Atlas both offer point-in-time restore. Whatever you use, test restores regularly β€” a backup you've never restored from might not actually work.

Monitoring

Four metrics deserve alerts: replica lag (if it exceeds the oplog window a secondary must full-resync), oplog window size (keep it above 24 hours so a brief secondary outage doesn't force a full resync), WiredTiger cache hit rate (below ~90% means the working set no longer fits in RAM β€” add memory or reduce hot data size), and slow query log (any query over 100ms likely needs an index). Free tools: mongostat, mongotop; commercial: Atlas, Datadog, Grafana with the MongoDB Exporter.

Upgrades

Replica sets support rolling upgrades β€” upgrade one member at a time (secondaries first, then the primary) so the cluster stays available throughout. Major version jumps (e.g. 5.0 β†’ 6.0) require an extra step: you must first raise the Feature Compatibility Version (FCV) to the current version before upgrading. Skipping that step can leave the cluster in an inconsistent FCV state that's hard to recover from. Always read the release notes before upgrading.

Security Defaults

Versions of MongoDB before 3.0 shipped with authentication disabled by default. Many developers installed MongoDB for local development, accidentally exposed it to the internet, and had databases wiped and held for ransom. Always explicitly enable authentication (--auth flag or security.authorization: enabled in config). Modern versions enable auth by default when using the package manager on most distros β€” but double-check your config file. Use SCRAM-SHA-256 for user auth. Restrict network access: bind MongoDB to specific IPs, not 0.0.0.0.

Encryption

In transit: enable TLS between clients and mongod, and between replica set members. Without TLS, credentials and data travel in plaintext over the network. At rest: WiredTiger supports an encrypted storage engine (available in MongoDB Enterprise and Atlas). For regulated industries (healthcare, finance) where compliance requires encryption at rest, this is non-negotiable. Client-side field-level encryption (CSFLE) goes further β€” data is encrypted before it even leaves your application, so even a DB admin can't read sensitive fields.

Connection Pooling

MongoDB drivers maintain a connection pool β€” a set of pre-opened connections that requests share instead of opening a new TCP connection per query. Opening a TCP connection + TLS handshake can take 10-50 ms; reusing a pooled connection takes under 1 ms. The default maxPoolSize in most drivers is 100. For high-concurrency services (>100 simultaneous requests), tune this up. For serverless functions that spin up many instances, tune it down β€” each function instance creates its own pool, and 1,000 Lambda instances Γ— 100 connections each = 100,000 connections which will exhaust mongod's connection limit.

The "No Auth by Default" Era Left a Mark

Before MongoDB 3.0, the default install had no authentication. Combined with the rise of cloud VMs and developers binding to 0.0.0.0, this led to a wave of public data leaks where entire databases were exposed to the open internet. This reputation stuck for years even though modern MongoDB requires auth by default in most installation paths. The lesson: always verify your mongod.conf has security.authorization: enabled, even if you're "sure" it's already on.

Production MongoDB requires attention to six areas: filesystem-snapshot backups (not just mongodump), monitoring replica lag and cache hit rate, rolling upgrades with FCV bumps, authentication enabled and network-restricted, TLS + at-rest encryption for sensitive data, and connection pool sizing tuned to your deployment model (traditional servers vs serverless).
Section 16

When MongoDB Wins, When It Doesn't

MongoDB is not a drop-in replacement for Postgres. It's a different kind of tool, optimized for a different set of problems. Picking the right one means being honest about your actual workload β€” not about which database sounds more exciting or modern.

Here's a quick decision path, then a deeper look at the concrete wins and losses.

Should You Use MongoDB? Variable schema? Different docs, different fields Yes No Lean toward Postgres (strong schema works better) Data fits as documents? Not heavy JOINs across many entities Yes No Postgres + JSONB may be better for relational needs Need horizontal write scale? Or frequent schema evolution? MongoDB is a strong fit

MongoDB Wins β€” Variable-Shape Catalogs

Product catalogs, content management systems, user profile stores with extension fields β€” any app where the "shape" of a record varies significantly per record type. Instead of a wide sparse table with hundreds of NULL columns or a JSONB blob that the database can't index efficiently, each document carries exactly its own fields. Adding a new product category tomorrow requires zero schema migration.

MongoDB Wins β€” Horizontal Write Scale

When write volume exceeds what a single server can handle, MongoDB's built-in sharding distributes writes across multiple shard replica sets automatically. This is substantially easier than horizontal write scaling in Postgres (which requires external tooling like Citus, or a manual application-level sharding strategy). IoT event ingestion, mobile analytics, and high-throughput behavioral event stores are common use cases.

MongoDB Loses β€” Cross-Account Financial Transactions

A bank transfer deducting from account A and crediting account B needs strict ACID across two separate entities. MongoDB does support multi-document transactions (since version 4.0), but they carry overhead and the programming model is more awkward than Postgres's. For applications where most operations are multi-entity ACID transactions, Postgres is simply a better tool. The relational model was designed for exactly this.

MongoDB Loses β€” Heavy SQL Analytics

If your team writes complex analytical queries β€” multi-table joins, window functions, CTEs, GROUP BY rollups across the whole dataset β€” Postgres is significantly more comfortable. The aggregation pipeline can do some of this, but it's more verbose than SQL and lacks features like CTEs. For serious analytics, most teams pair MongoDB with a separate data warehouse (BigQuery, Redshift, Snowflake) and use dbt-style transformations on exported data.

MongoDB Loses β€” Strict Relational Integrity

Foreign key constraints, cascading deletes, check constraints enforced at the database layer β€” MongoDB has none of these. You can implement referential integrity in application code, but it's error-prone under concurrency and not enforced at the database layer. If your app has 15 entities with complex many-to-many relationships and strict integrity requirements, a relational database is a much better fit.

Using Multiple Databases Is Normal

Many teams that "moved to MongoDB" eventually moved part of their workload back to Postgres for transactional or relational work. This isn't a failure β€” it's a sign of maturity. Modern distributed apps routinely use multiple databases at once β€” a habit the industry calls polyglot persistence: MongoDB for the product catalog and user profiles, Postgres for financial records and orders, Redis for caching and sessions. Picking one database for the whole app is often the wrong default. Match the database to the workload, not the other way around.

MongoDB excels for variable-schema catalogs, fast-evolving apps, and horizontal write scale β€” it loses to Postgres for strict ACID transactions across many entities, complex relational integrity, and heavy SQL analytics; polyglot persistence (both in the same app) is the mature default, not a compromise.
Section 17

MongoDB vs Postgres JSONB

A common question from developers who already know Postgres: "Can I just use a JSONB column instead of moving to MongoDB?" The honest answer is: it depends on how central documents are to your app. Postgres's JSONB column is genuinely powerful β€” you can index into it, query nested paths, and mix it with fully relational columns in the same query. But MongoDB is built around the document model end-to-end, which gives it advantages when documents aren't just a minor part of your data model but are your data model.

Choosing Between Postgres JSONB and MongoDB Use Postgres + JSONB when… βœ“ Most data is relational (users, orders, products) βœ“ JSON fields are a small extension (metadata, prefs) βœ“ Team already knows SQL and Postgres tooling βœ“ Need ACID transactions across relational + JSON data βœ“ Single-server scale is fine for now Example: SaaS app where users.settings is a JSON blob ⟷ Use MongoDB when… βœ“ Documents ARE the primary data model βœ“ Schema varies widely across documents βœ“ You need horizontal write sharding built in βœ“ Team iterates fast; schema still evolving βœ“ Need Atlas Search, Vector Search, or Realm sync Example: product catalog, CMS, IoT event store

Feature Comparison

Postgres + JSONB

Best of both worlds within one engine. A JSONB column sits next to normal typed columns in the same table. You can join a JSONB field to another table's integer ID, run window functions over a mix of relational and JSON data, and wrap everything in a single ACID transaction. GIN indexes on JSONB let you query nested keys efficiently. The tooling ecosystem (pgAdmin, DBeaver, dbt, every analytics BI tool) works out of the box.

Limitation: JSONB doesn't do horizontal write sharding natively. Past a certain write volume, you need Citus or application-level sharding. Also, JSONB fields don't get the rich type system that BSON provides (no ObjectId, no Date type β€” just strings in JSON).

MongoDB

Document-first from top to bottom. The driver, the query language, the aggregation pipeline, the index types (multikey, geospatial, text, TTL) β€” all designed around documents. Horizontal sharding is built in and works transparently to the application. BSON gives richer types than JSON. Change streams let you subscribe to real-time document updates without polling. Atlas extends the open-source base with Lucene-based full-text search and vector search for AI/ML workloads.

Limitation: weaker SQL-style analytics, no native FK constraints, more awkward for heavily relational workloads. Aggregation pipeline syntax is more verbose than SQL for complex transformations.

-- Postgres JSONB: find users who prefer "dark" theme,
-- living in "London", and join to their order count.
-- Notice: normal columns (city, email) mix with JSON field (settings).

SELECT
  u.id,
  u.email,
  u.city,
  u.settings->>'theme' AS theme,
  COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE
  u.city = 'London'
  AND u.settings->>'theme' = 'dark'
GROUP BY u.id, u.email, u.city, u.settings->>'theme'
ORDER BY order_count DESC
LIMIT 20;

-- GIN index to speed up JSONB key lookups:
-- CREATE INDEX idx_users_settings ON users USING GIN(settings);
-- Or a targeted expression index:
-- CREATE INDEX idx_users_theme ON users ((settings->>'theme'));
// Same query in MongoDB β€” using aggregation pipeline.
// Users and orders are in separate collections.
// Note: MongoDB doesn't have a JOIN keyword β€” we use $lookup.

db.users.aggregate([
  // Stage 1: filter on city and nested settings.theme field.
  { $match: {
      city: "London",
      "settings.theme": "dark"   // dot notation for nested fields
  }},

  // Stage 2: join to orders collection to count each user's orders.
  { $lookup: {
      from: "orders",
      localField: "_id",
      foreignField: "user_id",
      as: "orders"
  }},

  // Stage 3: compute order count from the joined array.
  { $addFields: {
      order_count: { $size: "$orders" }
  }},

  // Stage 4: drop the full orders array (we only need the count).
  { $project: { orders: 0 } },

  // Stage 5: sort by order count descending.
  { $sort: { order_count: -1 } },
  { $limit: 20 }
]);

// Required indexes for performance:
// db.users.createIndex({ city: 1, "settings.theme": 1 })
// db.orders.createIndex({ user_id: 1 })
Performance Comparison β€” 1M users, 10M orders (approximate)

Scenario A: "Find users by JSON field value" (no join)
  Postgres JSONB (expression index):  ~8ms   βœ…
  MongoDB (compound index):           ~6ms   βœ…
  β†’ Roughly equivalent with proper indexes

Scenario B: "Join users to order count" (aggregation with lookup)
  Postgres (indexed JOIN):            ~40ms  βœ…
  MongoDB ($lookup with index):       ~180ms ⚠
  β†’ Postgres wins on JOIN-heavy queries
  β†’ MongoDB's $lookup is more expensive than SQL JOIN

Scenario C: "Horizontal write scale (sharded cluster)"
  Postgres (single node):             ~2,000 writes/sec ceiling
  MongoDB (3-shard cluster):          ~15,000 writes/sec
  β†’ MongoDB wins on write throughput at scale
  β†’ Postgres needs Citus or application sharding to match

Scenario D: "Schema migration β€” add new field to 1M docs"
  Postgres (ALTER TABLE ADD COLUMN):  lock-free in Pg14+, ~instant for NOT NULL nullable
  MongoDB (no migration needed):      add field to new docs, old docs just lack it
  β†’ Both are reasonably fast now; MongoDB requires no migration script at all

Key takeaway: for relational-heavy queries, Postgres JSONB wins.
For document-primary apps with horizontal scale needs, MongoDB wins.
Most Apps with Light JSON Needs Stay on Postgres

If your application is primarily relational β€” users, orders, products, payments β€” and you just need to store some variable metadata (user preferences, product attributes, event properties), Postgres JSONB handles that comfortably. MongoDB becomes the clear winner when documents are the primary model, not an afterthought attached to an otherwise relational schema.

Postgres JSONB handles JSON fields elegantly within a relational app β€” full SQL, ACID, GIN indexes, familiar tooling β€” while MongoDB wins when documents are the primary data model, horizontal write scale is needed, or the schema evolves rapidly; most apps with light JSON needs stay on Postgres.
Section 18

Cloud-Managed MongoDB

Running MongoDB yourself β€” installing it, tuning it, monitoring it, patching it, managing backups β€” is a real job. Most teams don't want that job. The rise of cloud-managed databases means you can hand off most of that operational work and focus on your application instead. There are four main paths, each with different trade-offs.

MongoDB Deployment Options β€” What's Actually Running MongoDB Atlas Official managed service βœ“ Real MongoDB engine βœ“ AWS / GCP / Azure βœ“ Backups, alerts built in βœ“ Atlas Search (Lucene) βœ“ Atlas Vector Search Most popular choice 2024 Best default AWS DocumentDB API-compatible, NOT MongoDB βœ“ MongoDB-like API βœ“ AWS-native integration βœ— Different engine internals βœ— Performance differs βœ— Missing some features e.g. change streams, ops Test carefully first Self-Hosted Your infra, full control βœ“ Full feature access βœ“ Cost optimization at scale βœ“ Compliance / networking βœ— You own operations βœ— Backups, patching, HA Needs a DBA or SRE team For large-scale teams FerretDB Open-source proxy β†’ Postgres βœ“ MongoDB wire protocol βœ“ Stores data in Postgres βœ“ Apache 2 license ⚠ Not full-featured yet ⚠ Perf differs from native Good for licensing-sensitive Licensing escape hatch

The Four Options in Detail

MongoDB Atlas

The official cloud service from MongoDB, Inc. β€” this is what most new projects use in 2024. Atlas runs the actual MongoDB engine on your choice of AWS, Google Cloud, or Azure. It handles backups (continuous and point-in-time restore), monitoring with built-in alerts, automatic minor version upgrades, and a visual performance advisor that suggests indexes based on your actual query patterns.

Atlas extends beyond the open-source release in two notable ways: Atlas Search (a Lucene-based full-text search engine built directly into the cluster β€” no separate Elasticsearch needed) and Atlas Vector Search (stores and queries embedding vectors for AI/ML similarity search, without needing a dedicated vector database). These features are becoming core to many production workloads.

AWS DocumentDB

Amazon's MongoDB-compatible database service. It exposes a MongoDB wire protocol API β€” meaning drivers and most application code work without changes β€” but the underlying engine is completely different from MongoDB. Amazon built their own storage engine that understands MongoDB's API but operates differently internally.

This distinction has real consequences: performance characteristics differ, some features are missing or behave differently (change streams, certain aggregation operators, $lookup behavior), and you may hit compatibility issues with newer MongoDB driver features. It can be a reasonable choice if you're deeply committed to the AWS ecosystem and want to avoid MongoDB, Inc.'s licensing terms β€” but test your specific workload carefully before committing.

Self-Hosted

Running MongoDB on your own infrastructure β€” VMs, bare metal, or Kubernetes. This path makes sense at very large scale where cloud pricing becomes a significant cost driver, for compliance requirements that mandate specific data residency or network isolation, or for organizations with strong internal operations teams that already manage large database fleets.

The operational burden is real: you own backups, monitoring, failover testing, security patching, and major version upgrades. Most teams underestimate this cost. Start with Atlas and only consider self-hosting when you have clear, quantified reasons (cost savings, compliance, networking) β€” not "we like control."

FerretDB

An open-source project that acts as a translation proxy: your application sends MongoDB wire protocol commands to FerretDB, and FerretDB translates them into SQL and stores the data in a Postgres backend. From your application's perspective it looks like MongoDB; under the hood it's Postgres.

Why would you want this? MongoDB, Inc. changed its license in 2018 from AGPL to SSPL β€” a license that some organizations' legal teams won't accept for hosted services. FerretDB under Apache 2.0 is an escape hatch: keep your existing MongoDB-compatible application code, avoid the SSPL, and store data in Postgres (which most orgs already trust). The trade-off: FerretDB doesn't support 100% of MongoDB's feature set yet, and performance differs from native MongoDB.

Atlas Search and Atlas Vector Search

Atlas's "Atlas Search" (powered by Apache Lucene) gives you full-text search β€” relevance scoring, fuzzy matching, autocomplete, faceting β€” directly on your MongoDB data, without running a separate Elasticsearch cluster. "Atlas Vector Search" stores and queries embedding vectors, enabling similarity search for AI-powered features like semantic search, recommendation engines, and RAG (Retrieval-Augmented Generation) for LLM applications. Both are available as pipeline stages in the aggregation framework, so they integrate naturally with regular MongoDB queries.

Most teams deploy MongoDB via Atlas (official managed service on AWS/GCP/Azure), which handles backups, monitoring, and extends MongoDB with Lucene-based Atlas Search and Vector Search β€” AWS DocumentDB is API-compatible but a different engine requiring careful testing, self-hosting makes sense only at large scale with strong ops teams, and FerretDB offers an Apache-licensed escape hatch by translating MongoDB protocol to Postgres.
Section 19

Tools & Drivers β€” Your MongoDB Toolbox

MongoDB has a healthy ecosystem of official and third-party tools β€” from a command-line shell you can use for quick scripts to a full GUI for visual query building and schema exploration. Knowing which tool fits which job saves you a lot of context-switching. Here's the rundown of the six you'll reach for most often.

Typical MongoDB Development Workflow Compass (GUI) Explore data, build + test queries mongosh (shell) Scripts, migrations, one-off admin tasks Driver (code) Java, Node, Python… Application integration Atlas (production) Managed hosting, backups, Performance Advisor, Search Design phase β†’ Development phase β†’ Application code β†’ Production deployment ODMs like Mongoose sit between the driver and your application as a schema abstraction layer

mongosh

The official MongoDB shell β€” a full Node.js REPL where you write JavaScript to query and manage your database. The legacy mongo shell was deprecated in MongoDB 5.0 and dropped from the server distribution in MongoDB 6.0, with mongosh as the replacement. Because it's a proper JavaScript environment, you can write loops, conditionals, and multi-step scripts directly in the shell without switching to a separate scripting language. Think of it as the psql equivalent for MongoDB: indispensable for quick queries, one-off migrations, and checking what's in a collection at 2 AM during an incident.

MongoDB Compass

The official GUI for MongoDB. You connect it to any MongoDB instance (local or Atlas) and get a visual interface for exploring collections, browsing documents, running queries with an autocomplete editor, and viewing index utilization. It also has a schema visualization tab that scans a sample of your documents and shows you what fields actually exist and how data is distributed across types. Particularly useful when you inherit an unfamiliar database and want to understand its shape before writing code.

Atlas Performance Advisor

An Atlas-only feature that watches your real query patterns over time and automatically suggests indexes you should create. Rather than manually running explain() on every query, the Performance Advisor identifies slow operations, shows you what indexes they're missing, and gives you a one-click "Create Index" button. It's particularly useful after launch when you can see real traffic patterns β€” your production query mix often differs significantly from what you anticipated during development.

Robo 3T / Studio 3T

Popular third-party GUIs for MongoDB. Studio 3T (the commercial version) adds SQL query support β€” you can write a SQL query and Studio 3T translates it to a MongoDB aggregation pipeline, which is a helpful bridge for teams migrating from relational databases. Robo 3T is the free, lightweight version. Both are solid alternatives to Compass if you prefer a different UX. The choice is mostly personal preference; functionality overlaps significantly with Compass.

Official Driver Libraries

MongoDB maintains official drivers in every major language: Java, Python (PyMongo), Node.js, Go, Rust, C#/.NET, PHP, Ruby, and more. Each driver handles connection pooling, BSON serialization/deserialization, retry logic, and the MongoDB wire protocol so you don't have to. Use the official driver for your language rather than rolling your own connection logic β€” the drivers handle edge cases like server discovery and reconnection that are genuinely subtle to get right.

Mongoose / TypeORM (ODMs)

ODMs β€” Object Document Mappers β€” sit on top of the driver and give you a schema abstraction layer in your application code. Mongoose (for Node.js) lets you define schemas with types, validations, and virtual properties in JavaScript/TypeScript, then enforces those schemas in application code before writes reach the database. TypeORM supports MongoDB as a backend. ODMs are a trade-off: they add structure and save boilerplate, but they add a layer between you and raw MongoDB features. For complex aggregation pipelines or advanced query patterns, you'll often drop down to the raw driver anyway.

Run these directly in mongosh β€” either against a local instance or after connecting to Atlas. The shell is JavaScript, so variables, loops, and helper functions work normally.

// Connect to Atlas from the command line:
// mongosh "mongodb+srv://cluster0.abcd.mongodb.net/mydb" --username admin

// Switch to (or create) a database
use shopdb

// Insert a product
db.products.insertOne({
  name: "Mechanical Keyboard",
  price_usd: 149.99,
  stock: 50,
  tags: ["electronics", "keyboard"],
  created_at: new Date()
})

// Find all products under $200, sorted by price
db.products.find(
  { price_usd: { $lt: 200 } },
  { name: 1, price_usd: 1, _id: 0 }  // projection: only name + price, hide _id
).sort({ price_usd: 1 })

// Update stock count for one product
db.products.updateOne(
  { name: "Mechanical Keyboard" },
  { $inc: { stock: -1 } }   // decrement stock by 1 β€” atomic
)

// Count products with stock below 10
db.products.countDocuments({ stock: { $lt: 10 } })

// Check if the find query used an index
db.products.find({ price_usd: { $lt: 200 } }).explain("executionStats")
// Look for winningPlan.stage: "IXSCAN" (index used) vs "COLLSCAN" (full scan)

The official Node.js driver uses async/await and returns plain JavaScript objects. Connection pooling is managed automatically β€” create one client instance and reuse it across your application.

import { MongoClient, ObjectId } from 'mongodb';

// Create ONE client and reuse it β€” don't open a new connection per request
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
const db = client.db('shopdb');
const products = db.collection('products');

// Insert a document
const result = await products.insertOne({
  name: 'Wireless Mouse',
  price_usd: 49.99,
  stock: 200,
  tags: ['electronics', 'mouse'],
  created_at: new Date()
});
console.log('Inserted id:', result.insertedId);

// Find documents β€” toArray() pulls all results into memory
// For large result sets, use a cursor instead: const cursor = products.find(...)
const cheapItems = await products
  .find({ price_usd: { $lt: 100 } })
  .sort({ price_usd: 1 })
  .limit(10)
  .toArray();

// Find one by _id (ObjectId must be constructed explicitly)
const item = await products.findOne({ _id: new ObjectId('665a3f...') });

// Update with upsert: insert if not found, update if found
await products.updateOne(
  { name: 'Wireless Mouse' },
  { $set: { price_usd: 44.99 }, $inc: { stock: -1 } },
  { upsert: false }
);

// Always close the client when the process exits
process.on('SIGINT', async () => { await client.close(); process.exit(0); });

PyMongo is the official synchronous Python driver. The aggregation pipeline below groups orders by status and calculates total revenue per status bucket β€” the kind of report you'd build with GROUP BY in SQL.

from pymongo import MongoClient
from datetime import datetime, timedelta
import os

# One client, reuse it. MongoClient manages a connection pool internally.
client = MongoClient(os.environ["MONGO_URI"])
db = client["shopdb"]
orders = db["orders"]

# --- Aggregation pipeline: revenue by order status (last 30 days) ---
thirty_days_ago = datetime.utcnow() - timedelta(days=30)

pipeline = [
    # Stage 1: filter to recent orders only
    { "$match": { "created_at": { "$gte": thirty_days_ago } } },

    # Stage 2: group by status, count docs and sum revenue
    { "$group": {
        "_id": "$status",                    # group key
        "order_count": { "$sum": 1 },        # count documents in each group
        "total_revenue": { "$sum": "$total_usd" }
    }},

    # Stage 3: sort by revenue descending
    { "$sort": { "total_revenue": -1 } },

    # Stage 4: rename _id to something readable
    { "$project": {
        "status": "$_id",
        "order_count": 1,
        "total_revenue": 1,
        "_id": 0
    }}
]

results = list(orders.aggregate(pipeline))
for row in results:
    print(f"{row['status']:12s}  orders={row['order_count']:5d}  revenue=${row['total_revenue']:,.2f}")

# Output example:
# shipped       orders=  842  revenue=$42,150.00
# pending       orders=  213  revenue=$10,640.00
# cancelled     orders=   57  revenue=$2,850.00
The MongoDB toolbox: mongosh for interactive shell work and scripts, Compass for visual exploration and query building, Atlas Performance Advisor for real-traffic index recommendations, Studio 3T / Robo 3T as third-party GUI alternatives, official drivers in every major language for application integration, and Mongoose/TypeORM as ODM layers that add application-level schema enforcement.
Section 20

Common Misconceptions About MongoDB

MongoDB has been around since 2009 and has attracted a lot of opinions β€” some valid, some badly outdated, some just wrong. The misconceptions below circulate widely on forums and in job interviews. Each one contains a grain of truth, which is exactly why they persist. Read carefully: knowing why each belief is at least partially wrong is more useful than just knowing the correct answer.

"MongoDB is schemaless."

Half-true β€” and the half that's wrong is the dangerous part. The MongoDB engine doesn't enforce a schema: you can insert a document with any shape at any time and the database won't reject it. But your application absolutely has an implicit schema. Your code reads specific field names, applies specific types, and breaks in specific ways when those expectations aren't met. Calling MongoDB "schemaless" lulls teams into skipping schema documentation and validation, which leads to inconsistent data that's a nightmare to query later. The modern best practice: use $jsonSchema validators at the collection level once your shape has stabilized. MongoDB gives you schema flexibility β€” the freedom to evolve your shape over time β€” not schema chaos. Those are very different things.

"MongoDB doesn't support transactions."

Outdated β€” by about seven years. Multi-document ACID transactions were introduced in MongoDB 4.0 (released 2018) for replica sets, and extended to sharded clusters in MongoDB 4.2 (2019). You can wrap multiple reads and writes across multiple collections and multiple documents in a single transaction with full atomicity, consistency, isolation, and durability. The catch is that multi-document transactions are meaningfully slower than single-document operations β€” MongoDB's architecture was optimized for single-document atomicity, and the distributed transaction machinery adds overhead. The practical guidance: design your schemas so that the operations you care about touch only one document (relying on the free built-in single-document atomicity), and reach for multi-document transactions only when you genuinely need cross-document consistency.

"MongoDB loses data."

This reputation comes from MongoDB's early days (pre-3.0) when the default write concern was fire-and-forget β€” a write returned success as soon as it hit memory, before it was written to disk. A crash between the write acknowledgement and the disk flush meant data loss. Those defaults changed significantly in MongoDB 3.0 and later. Today, with writeConcern: { w: "majority" }, a write is only acknowledged after a majority of replica set members have durably persisted it. This is a strong durability guarantee. The data-loss reputation persists because some tutorials and StackOverflow answers are years old and still use old connection strings without explicit write concern settings. Always set w: "majority" on any write you cannot afford to lose.

"MongoDB can't do JOINs."

Inaccurate. MongoDB has the $lookup aggregation stage, which performs a left-outer JOIN between a local collection and a foreign collection. You can join on a single field or on multiple fields using a pipeline sub-expression. The nuance is performance: $lookup is substantially slower than a well-indexed SQL JOIN on a relational database because MongoDB's architecture wasn't built around join optimization β€” there are no join indexes, no hash-join operator, no sort-merge join. For occasional JOINs on moderate data sizes, $lookup works fine. For workloads that heavily join large collections in tight loops, you're fighting the grain of the tool. This is exactly why the embedding-vs-referencing decision matters so much: embed data you always access together and you eliminate the JOIN entirely.

"Just embed everything."

A harmful oversimplification of a correct principle. "Embed data you access together" is good advice. "Embed everything" leads directly to hitting MongoDB's 16 MB document size limit, unbounded array growth, and documents that become increasingly expensive to update as they bloat with embedded sub-documents. A user document that embeds every comment, every event, every notification, and every log entry will hit 16 MB and then start failing writes β€” with no warning until it happens in production. The correct heuristic has two parts: embed for "accessed together and bounded in size," reference for "unbounded in size or updated independently." Most real-world data models use a mix of both patterns, chosen field-by-field based on access patterns.

"MongoDB is faster than Postgres."

"Faster" is meaningless without specifying the workload. MongoDB tends to be faster than Postgres for single-document reads and writes on document-shaped data β€” particularly when embedding avoids the JOINs that Postgres would need. Postgres tends to be faster for complex multi-table JOINs, for analytical queries that scan large datasets, and for workloads that benefit from SQL's mature query planner and optimizer. Both databases have well-tuned storage engines and can handle millions of operations per second with proper indexing. The correct question isn't "which is faster?" but "which fits my access patterns better?" Choose the data model that matches how you actually read and write your data; performance will follow from that fit.

The six most persistent MongoDB misconceptions β€” schemaless means no schema, no transactions, inherent data loss, no JOINs, embed everything, always faster than SQL β€” each tells half a truth. MongoDB is schema-flexible (not schema-free), has had ACID transactions since 4.0, is durable with w:majority, supports $lookup JOINs, requires embed/reference judgment calls, and wins or loses on performance depending entirely on workload fit.
Section 21

Real-World Disasters & Lessons

These incidents are real patterns β€” some widely reported, some composite examples drawn from common failure modes that MongoDB practitioners encounter repeatedly in production. Every one was preventable. Read them as the most concrete possible proof that schema design, shard key selection, and security configuration aren't academic concerns β€” they're the difference between a five-minute fix and a multi-week migration.

Bad Shard Key (date) β€” Hot Shard Pattern mongos All writes arrive here β†’ routed by date Shard 1 β€” today's chunk πŸ”₯ ALL new writes land here CPU hot, disk I/O maxed Shard 2 β€” yesterday Idle β€” reads only, no new writes Shard 3 β€” last week Idle β€” cold data, rarely touched Better Shard Key Choices βœ— { date: 1 } β€” monotonically increasing, always hot shard βœ— { _id: 1 } β€” ObjectId encodes timestamp, same problem βœ“ { user_id: 1 } β€” high cardinality, even distribution βœ“ { user_id: 1, date: 1 } β€” compound, query-aligned βœ“ hashed shard key β€” random scatter, no hot shards Rule: shard key must distribute WRITES, not just data at rest Think: "where do new writes land?" not "how is existing data spread?" Resharding (MongoDB 5.0+) is possible but expensive β€” pick carefully upfront

Pre-3.0 Default No-Auth Deployments β€” The Great MongoDB Leak

In MongoDB's early years, the default configuration listened on all network interfaces with no authentication required. A MongoDB instance installed on a cloud server with a public IP was immediately accessible to anyone on the internet β€” no username, no password, just connect and read. Between roughly 2015 and 2017, security researchers and attackers found hundreds of thousands of publicly exposed MongoDB instances containing real production data: user records, financial data, medical records, and more. Many operators were simply unaware that MongoDB required explicit authentication configuration. The attacks peaked in 2017 when automated bots began wiping exposed databases and leaving ransom notes. Lesson learned: authentication and network binding are not optional extras β€” they're non-negotiable from the first day of any deployment. Always enable authentication (--auth flag or security.authorization: enabled in the config file), bind to localhost or a private network interface only, and use TLS for any connection that crosses a network boundary. Atlas enforces all of this by default, which is a significant security advantage of managed hosting.

Unbounded Array Growth β€” Hitting the 16 MB Document Limit

A team built a user activity tracking system. The initial design embedded an events array directly inside each user document β€” every page view, click, and API call was appended as a new object in that array. During development and early beta, with dozens of events per user, this felt natural and fast. Six months into production with power users accumulating tens of thousands of events, the team started seeing write errors: MongoServerError: document too large. MongoDB enforces a hard 16 MB limit per document, and heavily active users were hitting it. Worse, querying the user document to show a profile page now required loading megabytes of embedded event data that wasn't needed for the profile view at all. Lesson learned: any relationship where one side can grow without bound β€” events on a user, messages in a chat, log entries for a job β€” must use a reference (separate collection with a foreign key) rather than embedding. Apply a mental cap test: "If this user were extremely active for five years, how large would this array be?" If the answer is "very large," use a reference. The bucket pattern (group events into monthly buckets, each bucket is one document) is a middle ground when you need some locality without full embedding.

Bad Shard Key β€” The Date-Field Hot Shard

A team sharding a high-traffic events collection chose { created_at: 1 } as the shard key β€” it seemed logical, since queries were often date-filtered. What they didn't consider was where new writes would land. Because created_at is monotonically increasing (today's timestamps are always greater than yesterday's), every new event document gets assigned to the chunk at the high end of the key range β€” which always lives on the same shard. That shard, which held "today's" chunk, received 100% of all write traffic. The other shards, holding older data, sat largely idle. The hot shard's CPU and disk I/O were consistently maxed out, while the cluster's aggregate capacity was barely 30% utilized. Horizontal scaling had made things worse, not better. Lesson learned: a shard key must distribute writes evenly β€” not just spread existing data. Monotonically increasing fields (timestamps, auto-incrementing counters, ObjectIds) make terrible shard keys for write-heavy collections. High-cardinality fields that queries filter by (user_id, account_id, session_id) distribute writes far more evenly. If you must shard by a monotonically increasing key, use MongoDB's hashed shard key ({ created_at: "hashed" }) to scatter documents randomly.

Resharding Nightmare β€” The Cost of a Wrong Choice

A company chose a shard key that worked at their initial scale. Eighteen months later, data growth and changing query patterns revealed that the key was creating uneven chunk distribution β€” some shards held five times the data of others, and those overloaded shards were bottlenecking write throughput. Resharding (changing the shard key) was the solution. In MongoDB versions before 5.0, resharding required a full data migration: dump the data, create a new sharded collection with the correct key, import the data, cut over the application. On a multi-terabyte collection, this meant weeks of migration planning, dual-write periods, and application downtime risk. MongoDB 5.0 introduced online resharding β€” you can now change a shard key without taking the collection offline β€” but it still requires significant I/O and time proportional to data size. Lesson learned: treat the shard key decision with the same care you'd give a database schema design. It's not easy to change. Model your expected query patterns, identify the write distribution, and run a load test before sharding in production. The right time to validate the shard key is before you have terabytes of data to move.

Long-Running Aggregations Blocking the Primary

A team ran daily reporting aggregations β€” large $group and $sort stages across millions of documents β€” directly on the primary replica. During these aggregations, which took 30–90 seconds each, the primary's CPU was saturated and WiredTiger's internal locking created write slowdowns across unrelated collections. User-facing write latency, normally under 5 ms, spiked to 200–400 ms during report generation windows. The aggregation wasn't touching the same data as the user writes, but the shared CPU and I/O resources created interference. Lesson learned: analytics queries belong on secondaries. Set readPreference: "secondary" or "secondaryPreferred" in your reporting client. Secondaries exist for exactly this separation β€” analytics read load goes there, application write traffic stays on the primary. For truly heavy analytics, consider replicating to a dedicated analytics cluster or a data warehouse; your operational MongoDB instance isn't designed to double as an OLAP engine.

Five real-world disaster patterns β€” default no-auth deployments, unbounded embedded arrays, bad shard keys (date-field hot shards), resharding cost from wrong initial key choice, and analytics aggregations blocking the primary β€” are all preventable with upfront design discipline, auth-first configuration, and workload separation between primary and secondaries.
Section 22

Performance & Best Practices Recap

Everything on this page distills into eight practices. None of these are arbitrary rules β€” each one has a clear mechanical reason rooted in how MongoDB actually works. Follow them and you'll avoid the overwhelming majority of MongoDB production problems. Skip any one of them and you're relying on luck.

MongoDB β€” 8 Best Practices Cheat Sheet 1 Β· Embed for accessed-together, reference for unbounded Single-document read = zero JOINs. Unbounded arrays hit 16 MB limit β€” use a separate collection. 2 Β· Pick shard key once, carefully High cardinality + even write distribution + query-aligned. Monotonic keys = hot shards. Resharding is expensive. 3 Β· Index for queries you actually run Every index costs write I/O on every insert/update. Use explain() to confirm index hit before adding more. 4 Β· Use $jsonSchema for shape validation Flexibility β‰  chaos. Lock down the shape once it stabilizes. Start with validationAction:"warn", then switch to "error". 5 Β· Write with w:"majority" Majority write concern is your durability guarantee. Fire-and-forget (w:0) risks silent data loss. 6 Β· Profile aggregations with explain() Slow pipelines burn CPU on the primary. Profile before deploying. Add indexes or reorder stages to improve. 7 Β· Read from secondaries for analytics readPreference:"secondary" routes heavy aggregations off the primary. Protects write latency for app traffic. 8 Β· Always enable auth + TLS Non-negotiable from Day 1. The 2017 MongoDB ransomware wave was entirely preventable defaults failures. Rules 1–2 prevent modeling disasters. Rules 3–4 keep data clean and queries fast. Rules 5–8 prevent operational and security failures.

1 Β· Embed vs Reference

Embed sub-documents and arrays when the data is always read together with the parent and won't grow unboundedly. Reference (store an ID and query separately) when the relationship is one-to-many-to-many, when the sub-document is large, or when the sub-document is updated independently. This one decision shapes your query patterns, index strategy, and document size for the life of the collection.

2 Β· Shard Key β€” One Chance

Before sharding, model your write distribution by asking: "For every new document that's inserted, which shard does it land on?" If the answer is always the same shard (because the key is date, ObjectId, or any monotonic sequence), you have a hot shard problem waiting to happen. Choose a key with high cardinality (thousands of distinct values) that queries actually filter by. Compound shard keys ({ account_id: 1, timestamp: 1 }) often hit the sweet spot between distribution and query efficiency.

3 Β· Index for Real Queries

Use explain("executionStats") to confirm that the indexes you think are being used are actually being used. Look for winningPlan.stage: "IXSCAN" (good) versus "COLLSCAN" (bad). Remove indexes that are never used β€” they cost write I/O with no read benefit. The Atlas Performance Advisor can identify both missing indexes and unused ones if you're on managed hosting.

4 Β· $jsonSchema Validators

Once your collection's shape has stabilized, add a $jsonSchema validator to enforce required fields, field types, and value ranges at the database level. This catches application bugs before they corrupt your data. Start with validationAction: "warn" to surface existing violations without breaking writes, then switch to "error" once you've cleaned up any non-conforming documents.

5 Β· Write Concern: majority

Set writeConcern: { w: "majority" } for any write that matters. This ensures the write is acknowledged only after a majority of replica set members have durably persisted it β€” so a primary failure immediately after the write won't lose the data. The latency cost is typically one round-trip to the nearest secondary (a few milliseconds in the same data center, tens of milliseconds across regions).

6 Β· Profile with explain()

Before deploying any new aggregation pipeline to production, run it with .explain("executionStats"). Check totalDocsExamined vs nReturned β€” a ratio higher than 10:1 usually means a missing index. For aggregation pipelines, check that $match stages come first (before $group or $lookup) so they can use indexes to filter early and reduce the working set.

7 Β· Secondaries for Analytics

Set readPreference: "secondary" or "secondaryPreferred" on any client that runs analytical or reporting queries. This routes CPU-heavy aggregations off the primary, which protects write latency for your application traffic. Secondary reads may return slightly stale data (typically milliseconds behind the primary) β€” make sure your analytics use case can tolerate that staleness, which is almost always the case for reporting dashboards.

8 Β· Auth + TLS: Non-Negotiable

Enable authentication from Day 1 with security.authorization: enabled in your MongoDB config. Use strong passwords and, in Atlas, IP access lists to restrict connections to known IP ranges. Enable TLS for all connections that cross a network boundary β€” this prevents credential and data sniffing. These aren't hardening steps to add before going to production; they're the baseline that every deployment starts with.

Eight MongoDB best practices β€” embed/reference by access pattern, choose shard key for write distribution, index only what queries need, enforce schema with $jsonSchema, write with w:majority for durability, profile with explain(), route analytics to secondaries, and enable auth+TLS from Day 1 β€” collectively prevent the data modeling disasters, operational failures, and security incidents that dominate MongoDB war stories.
Section 23

FAQ β€” Your MongoDB Questions Answered

These are the questions engineers most commonly ask when they start working with MongoDB in practice β€” or when they're deciding whether to use it at all. Plain English answers first, with the nuance that matters for real decisions.

MongoDB or Postgres + JSONB β€” which should I pick?

This is the most common MongoDB decision question, and the answer comes down to where the majority of your data lives. If your application is fundamentally relational β€” users have orders, orders have line items, line items reference products, and you regularly query across those relationships β€” Postgres is the right choice. Postgres + JSONB gives you a safety valve for the occasional semi-structured field (user preferences, metadata, flexible attributes) while keeping your core data normalized and JOIN-able. Choose MongoDB when documents are your primary model β€” when your data naturally forms self-contained records that you read and write as a unit, when your schema will evolve significantly over time, and when you expect to need horizontal write sharding at some point. If you're unsure, a useful heuristic: would 90% of your queries touch only one "thing" at a time (one user, one order, one product)? If yes, MongoDB fits. If 40% of your queries span multiple entities, Postgres fits better.

How do I model a many-to-many relationship?

It depends on the size of each side. If a student can take a small number of courses (under ~20) and a course has a small number of students (under ~50), you can embed IDs in both directions β€” the student document has a course_ids array, and the course document has a student_ids array. This is fast to query but requires two writes to add a relationship. If either side is large (a course with thousands of enrolled students), use a separate enrollments collection β€” one document per relationship with student_id and course_id fields, indexed on both. This is MongoDB's equivalent of a join table and is the standard approach for large many-to-many relationships.

What is the maximum document size β€” and what does hitting it mean?

The hard limit is 16 MB per document. If an insert or update would make a document exceed 16 MB, MongoDB rejects the write with a BSONObjectTooLarge error. If you're approaching this limit in production, it almost always means you're embedding data that should be referenced instead β€” unbounded arrays are the most common culprit. The 16 MB limit isn't a quirk to work around; it's a signal that your data model needs rethinking. The bucket pattern (grouping N events per document instead of all events in one document) is a useful middle ground that preserves some locality without hitting the limit.

Atlas, DocumentDB, or self-hosted β€” which should I use?

For most teams: Atlas. It handles provisioning, backups, failover, and security configuration automatically, includes built-in Performance Advisor and Atlas Search, and supports serverless tiers for small workloads. The cost is higher than self-hosted at large scale, but the operational overhead you save is usually worth it. DocumentDB (AWS) is MongoDB-compatible in API terms but is a different underlying implementation β€” it diverges from MongoDB behavior in subtle ways (aggregation pipeline gaps, transaction limitations) that can be surprising. Use it only if you're deeply AWS-committed and those gaps don't affect your use cases. Self-hosted MongoDB makes sense for compliance requirements that prohibit managed cloud services, for very large scale where Atlas costs become prohibitive, or when you have existing infrastructure and DevOps capacity to manage it. Start with Atlas; move to self-hosted if specific requirements force the change.

Should I migrate my SQL database to MongoDB?

Probably not β€” at least not all of it. The common mistake is treating "migrate to MongoDB" as a project rather than a workload-by-workload evaluation. Relational workloads (financial transactions, inventory with complex constraints, reporting that spans many entities) are better served staying on SQL. Document-shaped workloads (product catalogs with varying attributes, user profiles, content stores, activity feeds) may benefit from MongoDB. A more productive question is: "Which new feature or service should be built in MongoDB instead of adding it to our SQL database?" Start there rather than migrating existing data. Migrations are risky, expensive, and often unnecessary β€” the right tool for an existing workload is usually the one that's already working.

What's the connection limit and how do I handle it?

Atlas tiers cap connections ranging from a few hundred on the free tier to tens of thousands on large dedicated clusters. Each application server that connects to MongoDB uses connections from a pool β€” typically 5–100 connections per application instance depending on your driver configuration. The critical mistake is creating a new MongoClient per request instead of sharing one client across all requests in the process. A single MongoClient manages an internal connection pool; create one at startup and reuse it. If you have many application servers and are hitting connection limits, use a connection pooler like MongoDB's own Atlas connection pooling, or configure smaller per-client pool sizes. Check your current connection count with db.serverStatus().connections.

How does ACID work across shards?

MongoDB supports distributed (cross-shard) ACID transactions since version 4.2. They work, but they're meaningfully slower than single-shard transactions because they require a two-phase commit protocol: MongoDB coordinates between all involved shards to ensure all-or-nothing atomicity. Depending on the number of shards involved, distributed transaction latency can be 2–10Γ— higher than a single-shard transaction. The practical guidance: design your data model so that the transactions you care about touch only documents on one shard. If a user's order and their account balance always share the same user_id, and your shard key is user_id, then all the data for one user's transaction lives on one shard β€” no distributed coordination needed. Reserve distributed transactions for cases where cross-shard atomicity is genuinely unavoidable.

What about Atlas Search and Atlas Vector Search?

Atlas Search is a fully managed Lucene-based full-text search engine built directly into Atlas. Rather than running Elasticsearch separately and syncing data to it, Atlas Search indexes your MongoDB collections in place and lets you run relevance-ranked text searches, faceted filtering, autocomplete, and fuzzy matching through the aggregation pipeline with the $search stage. Atlas Vector Search extends this with approximate nearest-neighbor vector search (HNSW index) β€” you can store embedding vectors in your documents and run semantic similarity queries. This means for many use cases (semantic search, recommendation, RAG retrieval), you don't need a separate vector database like Pinecone alongside your MongoDB deployment. The trade-off is that Atlas Search is Atlas-only; self-hosted MongoDB doesn't have it, and you'd need to bring your own Elasticsearch or vector database.

Eight FAQ answers β€” MongoDB vs Postgres JSONB decision, many-to-many modeling, the 16 MB limit meaning, Atlas vs DocumentDB vs self-hosted, SQL migration reality, connection limits and pooling, distributed ACID across shards, and Atlas Search plus Vector Search β€” cover the practical gaps engineers encounter when moving from MongoDB theory to real-world decisions.