beginner~3h

MongoDB — Documents, CRUD & the Aggregation Framework

Every MongoDB skill covered here — indexing, transactions, schema design, Spring Data — sits on top of one idea: a document is not a row. This chapter builds that idea from scratch, then takes you through CRUD and your first real, multi-stage aggregation pipeline.

Learning objectives

  • Explain why MongoDB models data as documents rather than normalized rows, and what that trades off.
  • Perform CRUD operations correctly, including the $set trap on updateOne.
  • Use comparison, logical, and array query operators, including the $elemMatch same-element trap.
  • Build a real multi-stage aggregation pipeline combining $match, $unwind, $group, $project, $lookup, and $sort.

◆ Story

A relational "order" honestly needs at least three tables — orders, order_items, and usually a shipping_addresses table — joined back together every single time you want to show one order on a screen. A MongoDB order is, most of the time, one document: the line items array, the shipping address, and the totals all live inside the same structure, because they are only ever read and written together. This isn't MongoDB being less rigorous than a relational schema — it's optimizing storage shape for the actual access pattern of "fetch or update one whole order at a time," which the Schema Design chapter goes much deeper on.

MongoDB stores documents as BSON — Binary JSON — which is why you get a few extra types JSON itself doesn't have: ObjectId (a 12-byte identifier, auto-generated for _id if you don't supply one), Date, and binary data, alongside the usual strings, numbers, booleans, arrays, and nested objects. Every document lives inside a collection (MongoDB's rough equivalent of a table), and a collection has no fixed schema by default — two documents in the same collection can have different fields, though in practice a real application enforces its own consistent shape at the code or validation-rule level, not because the database forces it.

{ "_id": ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), "customerId": "cust_4521", "status": "SHIPPED", "items": [ { "sku": "SKU-100", "name": "Wireless Mouse", "qty": 2, "price": 799 }, { "sku": "SKU-204", "name": "USB-C Cable", "qty": 1, "price": 349 } ], "shippingAddress": { "line1": "221B Baker Street", "city": "Bengaluru", "pincode": "560001" }, "total": 1947, "createdAt": ISODate("2026-01-14T10:32:00Z") }

Every field in this document — including the nested shippingAddress object and the items array — is part of one BSON document, retrieved and written as a single unit. That single-unit property is not a convenience; it's the foundation Chapter 03 (Transactions) builds on: a write to one document, however many nested fields it touches, is always atomic in MongoDB, with no special syntax required.

💻 Code example

{ "_id": ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), "customerId": "cust_4521", "status": "SHIPPED", "items": [ { "sku": "SKU-100", "name": "Wireless Mouse", "qty": 2, "price": 799 }, { "sku": "SKU-204", "name": "USB-C Cable", "qty": 1, "price": 349 } ], "shippingAddress": { "line1": "221B Baker Street", "city": "Bengaluru", "pincode": "560001" }, "total": 1947, "createdAt": ISODate("2026-01-14T10:32:00Z") }
OperationSingleMany
CreateinsertOne(doc)insertMany([doc1, doc2, ...])
ReadfindOne(filter)find(filter) — returns a cursor
UpdateupdateOne(filter, update)updateMany(filter, update)
DeletedeleteOne(filter)deleteMany(filter)
db.orders.insertOne({ customerId: "cust_4521", status: "PENDING", items: [{ sku: "SKU-100", qty: 2, price: 799 }], total: 1598 }); db.orders.findOne({ customerId: "cust_4521" }); db.orders.updateOne( { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") }, { $set: { status: "SHIPPED" }, $inc: { version: 1 } } ); db.orders.deleteOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") });

▲ Common mistake

updateOne(filter, { status: "CANCELLED" }) — without a $set — does not update the status field. It replaces the entire matched document with { status: "CANCELLED" }, silently deleting every other field the document had. This is a real, repeatable production bug: any update operator call ($set, $inc, $push, $unset, ...) is a partial update; a bare document with no operators is a full-document replacement. If you want to change one field, you must use $set.

find() returns a cursor, not an array — the driver fetches results in batches as you iterate, not all at once. For a small result set this distinction rarely matters; for a query that could match millions of documents, iterating the cursor (rather than materializing it into a list up front) is what keeps memory bounded on both the client and the server.

💻 Code example

db.orders.insertOne({ customerId: "cust_4521", status: "PENDING", items: [{ sku: "SKU-100", qty: 2, price: 799 }], total: 1598 }); db.orders.findOne({ customerId: "cust_4521" }); db.orders.updateOne( { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") }, { $set: { status: "SHIPPED" }, $inc: { version: 1 } } ); db.orders.deleteOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") });
CategoryOperatorsMeaning
Comparison$eq, $ne, $gt, $gte, $lt, $lteStandard comparisons against a field's value
Membership$in, $ninField's value is/isn't one of a list
Logical$and, $or, $not, $norCombine multiple conditions
Existence$existsField is present (or absent) on the document at all
Array$elemMatch, $all, $sizeMatch against elements inside an array field
// total > 1000 AND status in [SHIPPED, DELIVERED] — multiple top-level fields in // one filter object are an IMPLICIT $and; $or must be written explicitly db.orders.find({ total: { $gt: 1000 }, status: { $in: ["SHIPPED", "DELIVERED"] } }); // at least one item with qty > 1 AND sku "SKU-100" — on the SAME array element, // not just "any qty > 1 somewhere and any SKU-100 somewhere" db.orders.find({ items: { $elemMatch: { qty: { $gt: 1 }, sku: "SKU-100" } } });

▲ Common mistake

Writing { "items.qty": { $gt: 1 }, "items.sku": "SKU-100" } instead of using $elemMatch looks equivalent but isn't: without $elemMatch, MongoDB is satisfied if any array element has qty > 1 and any (possibly different) array element has sku: "SKU-100" — a two-item cart with one high-quantity item and one unrelated SKU-100 item would incorrectly match. $elemMatch requires both conditions to be true of the same array element.

💻 Code example

// total > 1000 AND status in [SHIPPED, DELIVERED] — multiple top-level fields in // one filter object are an IMPLICIT $and; $or must be written explicitly db.orders.find({ total: { $gt: 1000 }, status: { $in: ["SHIPPED", "DELIVERED"] } }); // at least one item with qty > 1 AND sku "SKU-100" — on the SAME array element, // not just "any qty > 1 somewhere and any SKU-100 somewhere" db.orders.find({ items: { $elemMatch: { qty: { $gt: 1 }, sku: "SKU-100" } } });

◆ The problem

SQL answers "group and summarize" with one statement — SELECT ... GROUP BY ... HAVING ... — because a relational query planner is free to reorder and optimize the whole thing internally. MongoDB's document API has no single flat table to write that kind of statement against, so aggregate() instead takes an explicit, ordered array of stages — each stage receives the output of the previous one and passes its own output to the next, like a Unix pipe.

StageWhat it does
$matchFilters documents — same query syntax as find()
$groupGroups documents by a key and computes per-group values ($sum, $avg, $max, ...)
$projectReshapes each document — include, exclude, rename, or compute fields
$sort / $limit / $skipOrdering and pagination, same idea as in find()
$unwindTurns one document with an array field into one document per array element
$lookupA left-outer join against another collection

◆ Under the hood

aggregate() does not pull every document out of the collection into application memory before doing anything — the pipeline executes on the MongoDB server itself, and early stages can use indexes exactly the way find() does. This has a direct, practical payoff: put $match (and $sort, if it can use an index) as early in the pipeline as possible, before any $group or $project reshaping — an early $match lets the server use an index scan to skip most of the collection before the expensive stages even run, instead of grouping the whole collection and filtering afterward.

A reporting task: "the 10 customers who spent the most on delivered orders this month, with their name attached." This genuinely needs a filter, a per-customer sum across a nested array, a join to another collection, and a sort — a good showcase of stages actually composing.

db.orders.aggregate([ // 1. filter down to what we care about FIRST — uses an index on {status, createdAt} { $match: { status: "DELIVERED", createdAt: { $gte: ISODate("2026-01-01T00:00:00Z") } }}, // 2. one order document with 3 items becomes 3 documents, one per item { $unwind: "$items" }, // 3. group back up, but now by customer, summing item price*qty across the WHOLE month { $group: { _id: "$customerId", totalSpent: { $sum: { $multiply: ["$items.price", "$items.qty"] } }, orderIds: { $addToSet: "$_id" } }}, // 4. reshape — rename _id back to something readable, count distinct orders { $project: { _id: 0, customerId: "$_id", totalSpent: 1, ordersCount: { $size: "$orderIds" } }}, // 5. join in the customer's name from the customers collection { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" }}, { $unwind: "$customer" }, { $sort: { totalSpent: -1 } }, { $limit: 10 } ]);

▲ Common mistake

$lookup in its simple localField/foreignField form is an equi-join only — it matches where the two fields are equal, nothing more expressive. If you need a join condition with additional logic (e.g. "only line items placed before the customer's account was flagged"), you need $lookup's alternate pipeline + let syntax, which runs a sub-pipeline per joined document instead of a flat equality match. Reaching for a $match stage after a simple $lookup to filter the joined results still works, but does the join first and discards afterward — often more expensive than filtering inside the $lookup itself when the discard rate is high.

💻 Code example

db.orders.aggregate([ // 1. filter down to what we care about FIRST — uses an index on {status, createdAt} { $match: { status: "DELIVERED", createdAt: { $gte: ISODate("2026-01-01T00:00:00Z") } }}, // 2. one order document with 3 items becomes 3 documents, one per item { $unwind: "$items" }, // 3. group back up, but now by customer, summing item price*qty across the WHOLE month { $group: { _id: "$customerId", totalSpent: { $sum: { $multiply: ["$items.price", "$items.qty"] } }, orderIds: { $addToSet: "$_id" } }}, // 4. reshape — rename _id back to something readable, count distinct orders { $project: { _id: 0, customerId: "$_id", totalSpent: 1, ordersCount: { $size: "$orderIds" } }}, // 5. join in the customer's name from the customers collection { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" }}, { $unwind: "$customer" }, { $sort: { totalSpent: -1 } }, { $limit: 10 } ]);

Want a visual for this concept?

Generate a diagram tailored to “MongoDB — Documents, CRUD & the Aggregation Framework” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to MongoDB — Indexing, Replica Sets & Sharding← Back to all MongoDB chapters