When does a document store like MongoDB fit better than a relational database — and when does it bite you?
Document databases trade joins and rigid schemas for embedded, read-optimized shapes — a great fit until your data's relationships outgrow a single document.
LEARN BY DOINGPut your intuition to work3 steps
When does a document store like MongoDB fit better than a relational database — and when does it bite you?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The core trade-off: normalize at write time, or denormalize at read time?
A relational database and a document store are both perfectly capable of storing the same information — the difference is where you pay the cost of stitching related data back together. A relational schema normalizes: an order lives in one table, its line items in another, linked by a foreign key. A document store like MongoDB usually denormalizes: an order and its line items live in one BSON document, so reading "give me order #4471" is a single lookup instead of a join.
That single design choice explains almost everything else people say about document databases — the flexible schema, the fast reads for whole-object access, and the pain when relationships turn out to be more tangled than you assumed on day one.
#Where a document store genuinely wins
- The data is naturally hierarchical. A product with variants, a user profile with nested preferences, a CMS page with blocks and metadata — these map onto a tree far more naturally than a dozen normalized tables.
- Reads are aggregate-shaped. If 90% of your queries are "fetch this one thing with everything it needs to render," embedding avoids N+1 joins entirely — you read once and you're done.
- The schema is still moving. Early in a product's life, or for genuinely polymorphic data (different document "types" living in the same collection with different fields), an enforced schema is friction you don't need yet.
- You need to shard write-heavy workloads horizontally without redesigning around foreign keys — a well-chosen shard key gets you there more directly than sharding a relational schema.
#Where it bites you
- Many-to-many relationships queried from both sides. Embed a list of tags into each article and you also want "all articles for a tag" — now you're either duplicating data both ways or falling back to references and
$lookup, which is MongoDB's join and does not get the decades of relational query-planner optimization behind it. - Multi-document transactions exist, but they're not the natural mode. Since MongoDB 4.0 you can wrap multiple documents (even across shards since 4.2) in an ACID transaction, but it costs more than a single-document write, and the data model still nudges you toward denormalizing precisely so you rarely need one.
- Unbounded arrays. A document has a hard 16MB size ceiling, and an array that grows without bound (comments on a post, events on a user) will eventually hit it — and long before that, it will make every read and every in-place update slower.
- No enforced referential integrity. Delete a customer and every order that embeds a stale copy of their name doesn't know it. Orphaned references are a fact of life you now handle in application code, not a constraint the database rejects for you.
- Ad hoc analytics. "Join everything and slice it every which way" is what a relational engine and its optimizer were built for. A document store's aggregation pipeline can do it, but you're hand-writing what a
JOIN+GROUP BYgets for free.
#A concrete example
Imagine an orders collection where each document embeds its line items:
{_id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),customerId: ObjectId("64f1a2b3c4d5e6f7a8b9c0aa"),status: "fulfilled",createdAt: ISODate("2026-06-02T14:03:00Z"),items: [{ sku: "SKU-1001", name: "Mechanical Keyboard", qty: 1, priceCents: 8900 },{ sku: "SKU-2044", name: "USB-C Hub", qty: 2, priceCents: 2400 }]}
Reading one order is a single findOne. But "total revenue per SKU across all orders this month" needs the aggregation pipeline to unwind the embedded array before it can group:
db.orders.aggregate([{ $match: { createdAt: { $gte: ISODate("2026-06-01T00:00:00Z") } } },{ $unwind: "$items" },{ $group: {_id: "$items.sku",unitsSold: { $sum: "$items.qty" },revenueCents: { $sum: { $multiply: ["$items.qty", "$items.priceCents"] } }} },{ $sort: { revenueCents: -1 } }]);
And if you need the customer's current email on every order line for a report — data that lives in a separate customers collection because customers are updated independently — you reach for $lookup, MongoDB's join stage:
db.orders.aggregate([{ $match: { status: "fulfilled" } },{ $lookup: {from: "customers",localField: "customerId",foreignField: "_id",as: "customer"} },{ $unwind: "$customer" },{ $project: { _id: 1, "customer.email": 1, items: 1 } }]);
That query works, but it's the tell: the moment your document model needs a $lookup to answer a common question, you're paying for the join you tried to design away — just without the index-aware optimizer a relational engine would bring to it.
🧠 Check your understanding
0/2 · 0/2 answered1. A social app stores posts with an embedded array of comments. Comments have no upper bound and the app is starting to see slow reads on old, heavily-commented posts. What's the most likely cause?
2. Why does a `$lookup` stage in MongoDB's aggregation pipeline typically not perform as well as a JOIN in a mature relational engine, all else equal?