LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

MongoDB Done Right

mongodbdatabasesnosqldata-modelingshardingbackend

MongoDB spent the 2010s as the database everyone loved to mock — “/dev/null is web scale,” schemaless data that rotted into mush, a default configuration that lost writes. A decade later most of those jokes are about a product that no longer exists. Modern MongoDB has multi-document ACID transactions, schema validation, a real query optimizer, and durability defaults that acknowledge to a majority of nodes. The hype died and the database grew up, and the interesting question now is not “is MongoDB a toy?” but “what is it genuinely good at, and how do you avoid the specific ways it still bites people?”

This post is the honest operator’s guide. The thesis up front: MongoDB rewards good data modeling more than almost any database, and punishes bad modeling more than almost any database. The relational world lets a mediocre schema limp along behind a query planner that joins its way out of trouble. MongoDB does not. Get the document design right and it is fast, scalable, and pleasant; get it wrong and you will meet the 16 MB limit and the unbounded-array footgun in production, at the worst possible time. So most of this is about modeling, and the rest is about the operational knobs that decide whether your data survives a node dying.


The model: documents, not rows

A MongoDB document is a BSON object — binary JSON with a few extra types like dates and 64-bit ints — stored in a collection, which is roughly a table with no enforced schema. Every document gets an _id primary key. That is the whole data model, and its power is that a document can be nested: arrays, sub-objects, arrays of sub-objects, to whatever depth your data wants.

The mental shift from SQL is the one that matters. In a relational database you normalize: you shred an entity across tables and reassemble it at read time with joins. In MongoDB you ask a different question — what is read and written together? — and you store that as one document. An order and its line items are one object you fetch in a single read, no join, because in your application they live and die together.

   Relational (normalize, join at read)        Document (store together, read whole)
   ─────────────────────────────────────       ─────────────────────────────────────
   orders        order_items                    {
   ┌────┐        ┌────┬─────┐                      _id: 42, customer: "alice",
   │ 42 │◀──────▶│ 42 │ A1  │                       status: "shipped",
   └────┘        │ 42 │ B7  │                       items: [
                 └────┴─────┘                          { sku: "A1", qty: 2, price: 9.99 },
   SELECT ... JOIN ... ON order_id                    { sku: "B7", qty: 1, price: 49.0 } ],
                                                       total: 68.98
                                                    }   // one read, no join

This is not “schemaless.” It is schema-on-read with the schema living in your application and, increasingly, in MongoDB’s own $jsonSchema validators. The discipline did not go away; it moved.


The central skill: embed vs reference

Every MongoDB schema decision reduces to one repeated question: for this relationship, do I embed the related data inside the document, or reference it by _id and fetch separately? Getting this right is 80% of using MongoDB well, and the heuristics are learnable:

Situation Choose Why
One-to-one, or one-to-few and read together Embed One read gets everything; the data is bounded and co-accessed (order → its items).
One-to-many with unbounded growth Reference Embedding an ever-growing array is the cardinal sin — it walks toward the 16 MB limit and makes every update rewrite a huge document.
Many-to-many Reference Duplicating across both sides rots; reference and resolve with $lookup or app-side joins.
The embedded data is large and rarely needed Reference Do not drag a megabyte of detail into every summary read.
The data changes frequently and is shared Reference One update in one place beats updating a copy in ten thousand documents.

The rule of thumb worth memorizing: embed for “contains,” reference for “relates to,” and never embed anything that grows without bound. A blog post embeds its handful of tags; it references its comments, because comments are unbounded. A user embeds their address; they reference their orders.

And here is the part that scares relational veterans: deliberate denormalization is correct in MongoDB, not a smell. Storing a product’s name alongside each order line item — duplicating it — is fine and often right, because you read order lines far more than product names change, and the duplication buys you a join-free read. The trick to denormalization that does not rot is to duplicate only data that is immutable or rarely-changing (a name at time of purchase, a price snapshot), and to reference anything you need a single source of truth for. Denormalize facts; reference state.


When MongoDB genuinely beats Postgres JSONB — and when it does not

This is the comparison that actually matters in 2026, because Postgres’s JSONB lets you store and index documents inside a relational database, which on paper erases MongoDB’s whole reason to exist. It does not, quite — but the honest answer is narrower than either camp admits. The PostgreSQL-for-developers post covers the relational side; here is the split.

MongoDB wins when:

  • Your data is genuinely document-shaped — deeply nested, variable from record to record, accessed as whole objects. Postgres JSONB can store this but you fight the impedance between rows and documents the whole way.
  • You need horizontal write scaling as a first-class, built-in feature. Native sharding distributes writes across machines automatically; Postgres scales writes horizontally only with bolt-ons (Citus and friends).
  • Schema evolves rapidly and you want flexibility without a migration for every new field.
  • The aggregation pipeline maps cleanly to your read patterns and you like operating one system end to end.

Postgres (relational + JSONB) wins when:

  • You need joins and transactions across many entities as the normal case. Postgres does relational integrity, foreign keys, and multi-table ACID natively and better.
  • Your workload is analytical — complex joins, window functions, ad-hoc reporting. Postgres’s planner and SQL are simply stronger here.
  • You have mixed data: mostly relational with a few flexible-schema columns. JSONB lets you keep the relational core and sprinkle documents where useful, in one engine.
  • You want the broadest tooling, the deepest operational knowledge pool, and SQL everyone already speaks.

The blunt version: if you reach for MongoDB and then immediately wish you had joins and transactions across collections, you wanted Postgres. If you reach for Postgres and find yourself stuffing everything into one giant JSONB column and never using a join, you wanted MongoDB. Choose the engine your access patterns point to, not the one your résumé prefers.


The aggregation pipeline

MongoDB’s analytical engine is the aggregation pipeline: an array of stages, each transforming a stream of documents and piping the result to the next, exactly like Unix pipes. It is verbose compared to SQL but extremely composable, and once you think in stages it is genuinely pleasant.

db.orders.aggregate([
  { $match:  { status: "shipped" } },                       // filter (use an index!)
  { $unwind: "$items" },                                     // explode the array to one doc per item
  { $group:  { _id: "$items.sku",
               revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } },
  { $sort:   { revenue: -1 } },
  { $limit:  10 }                                            // top 10 SKUs by revenue
])

The stages you will actually use most: $match (filter — put it first so it can use an index), $group (aggregate), $project (reshape/select fields), $sort, $limit, $unwind (flatten arrays), $lookup (a left-outer join to another collection — yes, MongoDB has joins now), and $setWindowFields (window functions, added in 5.0, the thing people insisted MongoDB could never do). The performance rule is simple and unforgiving: $match and $sort early, while an index can still help; once you $group or $unwind, you are working in memory and the indexes are behind you.


Indexing and the working-set rule

MongoDB indexes are B-trees, same as everyone’s, and a query without a usable index does a collection scan — reads every document — which is the single most common cause of a MongoDB instance that “got slow for no reason.” It had a reason: a missing index and a table that finally got big.

Two ideas carry most of the weight. First, compound index order follows ESR: Equality, Sort, Range. A query that filters customer by equality, sorts by orderDate, and ranges over total wants its index fields in exactly that order:

db.orders.createIndex({ customer: 1, orderDate: -1, total: 1 })
//                       ^Equality    ^Sort         ^Range

Get ESR wrong and the index still “works” but the database does extra in-memory sorting or scanning; get it right and the query is a clean index traversal.

Second, and more important operationally: the working set must fit in RAM. MongoDB’s WiredTiger storage engine caches data and indexes in memory (the cache defaults to about half your RAM). Your working set — the indexes plus the documents you actually touch frequently — needs to live in that cache. When it does, MongoDB is fast. When the working set spills to disk, performance falls off a cliff, because every query starts paging from storage. This is the capacity-planning rule for MongoDB: size RAM to your working set, watch the cache eviction and page-fault metrics, and treat “working set no longer fits in RAM” as the signal to scale up or shard out. Almost every “MongoDB is slow at scale” story is this rule, violated.


Replica sets: durability and failover

A production MongoDB is never a single node; it is a replica set — typically one primary that takes all writes and two or more secondaries that replicate the primary’s operation log (the oplog) and stand ready to take over. If the primary dies, the surviving members hold an election (a Raft-derived protocol) and promote a secondary to primary automatically, usually within seconds. Use an odd number of voting members so elections can always reach a majority; if you cannot afford a third data-bearing node, an arbiter can vote without holding data (though three real nodes is better).

The thing to internalize is that replica sets are not just for availability — they are the substrate for MongoDB’s tunable durability and consistency, which is the next section. The replication-and-HA ideas from the Postgres world map closely: a primary, streaming replication, automatic failover, and the same fundamental trade-off between acknowledging writes fast and acknowledging them durably.

Read and write concern: the consistency dial

MongoDB does not force one consistency model on you; it gives you knobs, per operation. The two that matter:

  • Write concern — how many nodes must acknowledge a write before it is considered done.
    • w: 1 — only the primary acked. Fast, but a primary crash before replication loses the write. This was the infamous old default, and it is why MongoDB had its reputation.
    • w: "majority" — a majority of the replica set has the write. This survives a primary failure and is the sane modern default. Add j: true to also require it hit the on-disk journal.
db.orders.insertOne(doc, { writeConcern: { w: "majority", j: true } })
  • Read concern — what consistency guarantee a read gives you. local (whatever the node has, possibly not yet durable), majority (only data acknowledged by a majority, so it can’t be rolled back), linearizable (strongest, reflects all prior majority writes), and snapshot (for transactions). Paired with a read preference (primary, secondaryPreferred, nearest, …) you decide whether reads hit the primary for freshness or fan out to secondaries for scale.

The mental model: w:1/read local is fast and loose; w:majority/read majority is the durable, won’t-lose-your-data default you should start from and only relax with a reason. The flexibility is a feature, but the old reputation came from a bad default, not a bad design — modern deployments choose majority and move on.


Sharding: where the shard key is the entire game

When one replica set can no longer hold your data or your write throughput, MongoDB shards: it partitions a collection across multiple replica sets by a shard key, with mongos routers directing each query and a config-server replica set holding the map. Adding shards adds both capacity and write throughput, and the cluster keeps serving partial reads and writes even if a whole shard goes down.

Everything good or catastrophic about a sharded cluster traces to one decision: the shard key. A good key spreads reads and writes evenly across shards and lets common queries target a single shard. A bad key creates hotspots — a monotonically increasing key (like a timestamp or default ObjectId) sends every new write to the same shard, turning your expensive cluster into one overloaded node with spectators. The guidance:

  • Pick a key with high cardinality and even access distribution; hashed shard keys spread monotonic values but cost you efficient range queries.
  • Choose a key that lets your most common queries target one shard rather than scatter-gathering across all of them.
  • Modern MongoDB (5.0+) lets you reshard and refine keys, which softened what used to be an irreversible mistake — but resharding a large collection is still a heavy operation. Choose well the first time.

Sharding is powerful and it is the reason people pick MongoDB for write-heavy scale — but do not shard before you need to. A well-indexed replica set with the working set in RAM serves enormous workloads. Sharding adds real operational complexity; reach for it when one node genuinely cannot keep up, not preemptively.


The footguns, named

Forewarned is forearmed. The specific ways MongoDB bites:

  • Unbounded arrays. Embedding an array that grows forever — every event for a user, every comment on a post — is the number-one MongoDB design failure. The document bloats, every update rewrites the whole thing, and you eventually slam into the size limit. Use the bucket pattern or reference into a separate collection.
  • The 16 MB BSON document limit. A hard cap, no exceptions. If a document can approach it, your model is wrong. (For genuinely large blobs, GridFS chunks them — but usually the answer is “don’t put that in one document.”)
  • Missing-index collection scans. No planner will save you; a query with no usable index reads everything. Profile with the slow-query log and explain(), and index for your real query shapes.
  • The old write-concern reputation. If you (or an old driver) run with w:1, you can lose acknowledged-looking writes on failover. Use w:"majority".
  • Treating “schemaless” as “no schema.” Without validators and discipline, three years of careless writes leave a collection where every document has subtly different shapes and your application is a minefield of optional-field checks. Use $jsonSchema validation and mean it.

The verdict

MongoDB earned its early mockery and then quietly outgrew it. In 2026 it is a genuinely good database for genuinely document-shaped data — variable, nested, read and written as whole objects — and for workloads that need built-in horizontal write scaling. It is the wrong database for relational data with lots of cross-entity joins and transactions, where Postgres (with or without JSONB) is simply better, and choosing MongoDB there means reimplementing joins by hand and resenting it. The decision is not ideological; it is a data-modeling question you answer by looking honestly at how your application reads and writes.

Done right, MongoDB means: embed what is contained and bounded, reference what relates or grows, denormalize immutable facts on purpose, keep the working set in RAM, default to majority write concern, and choose your shard key like the load-bearing decision it is. Do those things and MongoDB is fast, durable, and a pleasure. Skip them and you will rediscover, personally, every joke from 2013 — except this time they will be your fault, not the database’s.


Sources

Comments