Mert Özen Further With Every Line
Database MongoDB — #03

CRUD in MongoDB: find, Projection, Update Operators, Upsert and Array Queries

Mert Özen Nov 14, 2026 18 min 8 views
CRUD in MongoDB: find, Projection, Update Operators, Upsert and Array Queries

The core of safe CRUD in MongoDB is combining the read, the check and the write in one atomic update: a conditional $inc never takes stock below zero, while read-then-write sells the same stock several times. With real output: orders matched wrongly without $elemMatch, ordered and unordered insertMany, projection, the cost of skip, update operators, positional and filtered positional updates, upsert, findOneAndUpdate, a stock race with 50 concurrent buyers and the replaceOne trap.

Version note: The examples in this post were run with MongoDB 9.0.8 Community Server, mongosh 2.6, .NET 10 and MongoDB.Driver 3.14. All scripts gave the same output on MongoDB 8.0.38.

Guess First

The lab from the previous post holds the orders of Bookstore, the online bookstore used as the shared example across this site's series; each order keeps its items in the items array. The campaign team asks for orders with at least 2 copies of Dune (bookId 26). The first query that comes to mind writes the two conditions side by side with dot notation:

db.orders.countDocuments({ "items.bookId": 26, "items.quantity": { $gte: 2 } })

There are 27 orders containing Dune in the lab. How many does this query return, and do all of them really contain 2 copies of Dune?

// 03 - guess first: orders with at least 2 copies of Dune (bookId 26)
const dotted = db.orders.countDocuments({ "items.bookId": 26, "items.quantity": { $gte: 2 } });
const elemMatch = db.orders.countDocuments({ items: { $elemMatch: { bookId: 26, quantity: { $gte: 2 } } } });
print("dot notation:", dotted, " $elemMatch:", elemMatch);

// The extra matches: different elements satisfy the two conditions
printjson(db.orders.find(
    {
        "items.bookId": 26,
        "items.quantity": { $gte: 2 },
        items: { $not: { $elemMatch: { bookId: 26, quantity: { $gte: 2 } } } }
    },
    { _id: 1, "items.bookId": 1, "items.quantity": 1 }
).toArray());
dot notation: 7  $elemMatch: 5
[
  {
    _id: 10,
    items: [
      {
        bookId: 22,
        quantity: 2
      },
      {
        bookId: 26,
        quantity: 1
      }
    ]
  },
  {
    _id: 361,
    items: [
      {
        bookId: 26,
        quantity: 1
      },
      {
        bookId: 8,
        quantity: 1
      },
      {
        bookId: 19,
        quantity: 1
      },
      {
        bookId: 53,
        quantity: 2
      }
    ]
  }
]

Seven orders came back, but two are wrong. Each condition written with dot notation can be satisfied by any element of the array, and the two conditions do not have to be satisfied by the same element. Order 10 has 1 copy of Dune; the "at least 2" condition is satisfied by the item for book 22. If you want both conditions to be true on the same element, you need $elemMatch. In this post we go through CRUD end to end on the Bookstore data, with details of this kind. Every script is in the repo's scripts folder; for example docker compose exec mongo mongosh "mongodb://bookstore_app@localhost:27017/bookstore?authSource=bookstore" --quiet /lab/scripts/03-guess.js -p. The scripts undo the data they change at the end; if you still want a clean lab, just run 02-load.sh from post 2 again.

insertOne and insertMany: Ordered and Unordered

There is insertOne for a single document and insertMany for several. insertMany is ordered by default: it writes the documents in order and stops at the first error. Let's try a batch of three books, where the middle one's _id collides with the existing Dune, in both modes:

// 03 - insertMany: ordered and unordered
db.books.deleteMany({ _id: { $in: [101, 102, 103] } });
const batch = [
    { _id: 101, isbn: "9798900001012", title: "Persuasion", listPrice: Decimal128("310.00") },
    { _id: 26, isbn: "9798900000268", title: "Dune", listPrice: Decimal128("390.50") },
    { _id: 102, isbn: "9798900001029", title: "Mansfield Park", listPrice: Decimal128("295.00") }
];

for (const ordered of [true, false]) {
    db.books.deleteMany({ _id: { $in: [101, 102] } });
    try {
        db.books.insertMany(batch, { ordered });
    } catch (e) {
        print(`ordered=${ordered}: ${e.name}, inserted=${e.result.insertedCount}, ` +
              `errors=${e.writeErrors.length} (index ${e.writeErrors[0].index}, code ${e.writeErrors[0].code})`);
    }
    print("  101/102 in collection:", db.books.countDocuments({ _id: { $in: [101, 102] } }));
}
db.books.deleteMany({ _id: { $in: [101, 102] } });
ordered=true: MongoBulkWriteError, inserted=1, errors=1 (index 1, code 11000)
  101/102 in collection: 1
ordered=false: MongoBulkWriteError, inserted=2, errors=1 (index 1, code 11000)
  101/102 in collection: 2

In both modes the error code is 11000, a duplicate key. In ordered mode Persuasion was written, the batch stopped at Dune and Mansfield Park was never tried. In unordered mode the server skipped the failing document and carried on, so two books were written; it may also write the documents in parallel and in an order of its own choosing. Which one to choose depends on what the batch means: ordered for a load where order matters and which you restart from the beginning if it stops halfway; unordered for bulk loading independent records, which is faster and makes it easier to report the failed records afterwards. In neither mode is a half-finished batch rolled back: whatever was written stays written. If you need all or nothing, you need a transaction, which is the topic of post 10.

find: Filter, Projection, Sort and Limit

// 03 - find: filter, projection, sort and limit
printjson(db.books.find(
    { "category.name": "Science Fiction", listPrice: { $lt: Decimal128("500") } },
    { _id: 0, title: 1, listPrice: 1, "authors.name": 1 }
).sort({ listPrice: -1 }).limit(3).toArray());

print("$in      :", db.books.countDocuments({ "category.name": { $in: ["Dystopian", "Science Fiction"] } }));
print("not ex.  :", db.books.countDocuments({ publishedYear: { $exists: false } }));
[
  {
    title: 'Neuromancer',
    authors: [
      {
        name: 'William Gibson'
      }
    ],
    listPrice: Decimal128('480.50')
  },
  {
    title: 'The War of the Worlds',
    authors: [
      {
        name: 'H. G. Wells'
      }
    ],
    listPrice: Decimal128('450.50')
  },
  {
    title: 'Dune',
    authors: [
      {
        name: 'Frank Herbert'
      }
    ],
    listPrice: Decimal128('390.50')
  }
]
$in      : 13
not ex.  : 3

The first argument of find is the filter and the second is the projection. In the filter, a comma between fields means AND; fields in subdocuments are reached with dot notation such as "category.name". The comparison operators follow the same logic as their SQL counterparts: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin. Since the price is a Decimal128, we gave the bound as Decimal128("500") too; MongoDB can compare numeric types with each other, but staying with one type makes both reading and index use easier. $exists: false finds documents where the field does not exist at all; these are the three books whose NULL publication year we did not write when moving the data from SQL.

In a projection, 1 includes a field and 0 leaves it out; _id always comes back unless you say 0 explicitly. Apart from _id, inclusion and exclusion cannot be mixed in the same projection. "authors.name": 1 returned only name from each element of the array. The field order in the output follows the document, not the projection. A projection makes the data sent over the wire smaller, but it does not stop the server from reading the document; what that means for model decisions is in post 4.

Two costs are worth seeing early. The first is a case-insensitive $regex:

// $regex: case-insensitive search; without an index every document is scanned
const regexStats = db.books.find({ title: { $regex: "^the", $options: "i" } })
    .explain("executionStats").executionStats;
print("regex    : returned", regexStats.nReturned, "docsExamined", regexStats.totalDocsExamined,
      "stage", regexStats.executionStages.stage);
regex    : returned 22 docsExamined 77 stage COLLSCAN

All 77 books were read to find 22 (COLLSCAN, a scan of the whole collection). There is no index on title here, but one would not change much: according to the documentation, $regex is not collation-aware, so it cannot use an index efficiently for a case-insensitive search. A case-sensitive prefix search starting with ^, on the other hand, can run as a range scan on an index. The right way to do case-insensitive search and sorting with Turkish letters is an index with a collation, the topic of post 6.

The second is skip. Let's read orders from newest to oldest in pages of 20 and see how much work is done on different pages:

// skip: skipped entries are still read (ordered read through the _id index)
for (const skip of [0, 60, 300]) {
    const s = db.orders.find({}, { status: 1 }).sort({ _id: -1 }).skip(skip).limit(20)
        .explain("executionStats").executionStats;
    print(`skip ${String(skip).padStart(3)} : returned ${s.nReturned}, keysExamined ${s.totalKeysExamined}, docsExamined ${s.totalDocsExamined}`);
}
skip   0 : returned 20, keysExamined 20, docsExamined 20
skip  60 : returned 20, keysExamined 80, docsExamined 20
skip 300 : returned 20, keysExamined 320, docsExamined 20

Each page returns 20 documents, but for page 16 the server went through 320 index entries. Even though it does not fetch the skipped documents, it still has to count them one by one in the index; the work grows linearly with the page number. If the sort could not use an index it would be worse: the server would read every order and sort them in memory. For deep paging, keyset pagination, which continues from the last value seen, is used instead of skip; details are in the Çözüm Desenleri series post "Pagination Stratejileri Derinlemesine: Offset, Keyset ve Opaque Cursor Token".

Update Operators: $set, $inc, $unset, $addToSet and $pull

In MongoDB an update is not sending the whole document again; it tells the server "change this field like this". Let's give Dune a temporary campaign price and some tags:

// 03 - update operators
db.books.updateOne({ _id: 26 }, { $unset: { tags: "", promo: "" }, $set: { listPrice: Decimal128("390.50") } });

let r = db.books.updateOne(
    { _id: 26 },
    {
        $set: { listPrice: Decimal128("351.45"), promo: { label: "SciFi Week", endsAt: ISODate("2026-07-07T00:00:00Z") } },
        $addToSet: { tags: { $each: ["classic", "space-opera", "classic"] } }
    }
);
print("matched", r.matchedCount, "modified", r.modifiedCount);
r = db.books.updateOne({ _id: 26 }, { $addToSet: { tags: "classic" } });
print("same $addToSet again -> modified", r.modifiedCount);
db.books.updateOne({ _id: 26 }, { $pull: { tags: "space-opera" }, $unset: { promo: "" } });
printjson(db.books.findOne({ _id: 26 }, { _id: 0, listPrice: 1, tags: 1, promo: 1 }));
matched 1 modified 1
same $addToSet again -> modified 0
{
  listPrice: Decimal128('351.45'),
  tags: [
    'classic'
  ]
}

$set writes a field and creates it if it is missing; $unset removes a field. $addToSet adds a value to an array only if it is not there, takes several values with $each, and did not add the second classic in the list. When the same $addToSet ran again, matchedCount was 1 but modifiedCount was 0: the document was found, but there was nothing to change. The difference between these two numbers matters when you need to tell "did the operation run" from "did anything change". $pull removes every element matching a condition from an array. One update can use several operators, and all of them are applied atomically to the single document: a reader never sees the price changed while the tags are not yet added. The only restriction is that the same field cannot be changed by two operators at once. For numeric fields there are also $inc, $mul, $min and $max, and $currentDate for dates; we use them on stock below.

Updating Array Elements: $, $[] and $[<id>]

Because an order's items live in an array, "increase the quantity of this book" means finding the right element inside the array. In pending order 379 the customer wants one more copy of Dracula (book 20):

// Positional $: updates the first matching array element (pending order 379, book 20)
db.orders.updateOne(
    { _id: 379, status: "Pending", "items.bookId": 20 },
    { $inc: { "items.$.quantity": 1, total: Decimal128("440.00") } }
);
printjson(db.orders.findOne({ _id: 379 }, { _id: 0, items: 1, total: 1 }));
{
  items: [
    {
      bookId: 20,
      title: 'Dracula',
      quantity: 2,
      unitPrice: Decimal128('440.00')
    }
  ],
  total: Decimal128('880.00')
}

items.$ points to the first array element matched by the filter. The same update changed the item quantity and the order total together; since both are in the same document, there is no inconsistent moment in between. The status: "Pending" in the filter is deliberate too: this update never changes an item of a delivered order. To change every element that matches a condition there is the filtered positional operator $[<id>] with arrayFilters. In delivered order 372 the customer opens a return request for two books:

// Filtered positional $[it]: ALL elements that match (return request)
r = db.orders.updateOne(
    { _id: 372 },
    { $set: { "items.$[it].returnRequested": true } },
    { arrayFilters: [{ "it.bookId": { $in: [53, 43] } }] }
);
printjson(db.orders.findOne({ _id: 372 }, { _id: 0, items: { bookId: 1, quantity: 1, returnRequested: 1 } }));
{
  items: [
    {
      bookId: 53,
      quantity: 2,
      returnRequested: true
    },
    {
      bookId: 43,
      quantity: 1,
      returnRequested: true
    },
    {
      bookId: 48,
      quantity: 1
    }
  ]
}

it is a name; inside arrayFilters we say which elements are selected under that name. To change every element without a condition there is $[]; at the end of the script we remove the return flag from all of them with $unset: { "items.$[].returnRequested": "" }. Adding to an array has its own fine-tuning as well: $push takes several values with $each and, with $slice, the maximum number of elements the array keeps. To keep the customer's three most recently viewed books:

db.customers.updateOne({ _id: 3 }, { $unset: { recentlyViewed: "" } });
for (const bookId of [26, 3, 42, 64]) {
    db.customers.updateOne(
        { _id: 3 },
        { $push: { recentlyViewed: { $each: [bookId], $slice: -3 } } }
    );
}
printjson(db.customers.findOne({ _id: 3 }, { _id: 0, firstName: 1, recentlyViewed: 1 }));
{
  firstName: 'Grace',
  recentlyViewed: [
    3,
    42,
    64
  ]
}

Four books were added, $slice: -3 kept the last three, and Dune, viewed first, dropped out. The array never grows without bound; in post 4 we measure why that matters.

upsert and findOneAndUpdate

A cart is a document created when the first book is added. For "update if it exists, create it otherwise", instead of reading first and then choosing between insert and update, you use a single upsert:

// 03 - a cart with upsert; recently viewed with $push + $each + $slice
db.carts.deleteMany({ customerId: 3 });
for (const bookId of [26, 42]) {
    const r = db.carts.updateOne(
        { customerId: 3 },
        {
            $setOnInsert: { createdAt: ISODate("2026-06-30T09:00:00Z") },
            $push: { items: { bookId, quantity: 1 } }
        },
        { upsert: true }
    );
    print(`book ${bookId}: matched ${r.matchedCount}, upserted ${r.upsertedCount}`);
}
printjson(db.carts.findOne({ customerId: 3 }, { _id: 0 }));
book 26: matched 0, upserted 1
book 42: matched 1, upserted 0
{
  customerId: 3,
  createdAt: ISODate('2026-06-30T09:00:00.000Z'),
  items: [
    {
      bookId: 26,
      quantity: 1
    },
    {
      bookId: 42,
      quantity: 1
    }
  ]
}

On the first call no document matched; the server built a new document from the equality conditions in the filter (customerId: 3), applied the update to it and gave it an ObjectId as _id. $setOnInsert only runs at that moment of creation, so createdAt did not change on the second call. One warning: if two upserts for the same customer arrive at exactly the same time, both may see "no document" and create two carts. What prevents this is a unique index on customerId. According to the documentation, with a unique index only one upsert inserts; if the filter consists of equality conditions on that index's fields, the others update the new document, otherwise they get a duplicate key error. Indexes are in post 6.

updateOne does not return the changed document. If you need the updated value, use findOneAndUpdate instead of a separate find; the change and the read are one atomic operation. We use it on stock in the next section.

Bookstore: Decreasing Stock Without Going Negative

A book's stock is in the stock.onHand field. The right way to decrease stock when an order comes in is to put the check in the filter: "decrease it only if stock is at least the quantity requested".

// 03 - conditional $inc: stock never goes negative; findOneAndUpdate returns the new value
db.books.updateOne({ _id: 26 }, { $set: { "stock.onHand": 3 } });

for (const quantity of [2, 2]) {
    const r = db.books.updateOne(
        { _id: 26, "stock.onHand": { $gte: quantity } },
        { $inc: { "stock.onHand": -quantity } }
    );
    print(`take ${quantity}: matched ${r.matchedCount}, modified ${r.modifiedCount}`);
}

const after = db.books.findOneAndUpdate(
    { _id: 26, "stock.onHand": { $gte: 1 } },
    { $inc: { "stock.onHand": -1 }, $currentDate: { "stock.updatedAt": true } },
    { returnDocument: "after", projection: { _id: 0, "stock.onHand": 1 } }
);
printjson(after);
print("next call:", db.books.findOneAndUpdate(
    { _id: 26, "stock.onHand": { $gte: 1 } },
    { $inc: { "stock.onHand": -1 } },
    { returnDocument: "after" }
));
take 2: matched 1, modified 1
take 2: matched 0, modified 0
{
  stock: {
    onHand: 0
  }
}
next call: null

With 3 in stock, the first request took 2 and the second did not match, because the remaining 1 is less than 2. The application reads a matchedCount of 0 as "not enough stock". findOneAndUpdate took the last copy and returned the new value in one go; the call after the stock ran out returned null. The real strength of this approach shows up under concurrency. The stock-race command in the lab project sets Dune's stock to 10 and sends 50 buyers at the same time, first with read-check-write and then with a conditional $inc:

// Wrong: read, check in the application, then write the new value
private static async Task<bool> ReadThenWriteAsync(IMongoCollection<BsonDocument> books)
{
    BsonDocument book = await books.Find(new BsonDocument("_id", BookId)).FirstAsync();
    int onHand = book["stock"]["onHand"].AsInt32;
    if (onHand < 1)
    {
        return false;
    }

    await Task.Delay(5); // application work such as price and campaign checks
    await books.UpdateOneAsync(new BsonDocument("_id", BookId),
        new BsonDocument("$set", new BsonDocument("stock.onHand", onHand - 1)));
    return true;
}

// Right: the check and the decrement in one atomic update
private static async Task<bool> ConditionalIncAsync(IMongoCollection<BsonDocument> books)
{
    UpdateResult result = await books.UpdateOneAsync(
        new BsonDocument { { "_id", BookId }, { "stock.onHand", new BsonDocument("$gte", 1) } },
        new BsonDocument("$inc", new BsonDocument("stock.onHand", -1)));
    return result.ModifiedCount == 1;
}
dotnet run --project src/Bookstore.MongoLab -- stock-race
read-then-write  : sold 50 of 10, stock.onHand = 4
conditional $inc : sold 10 of 10, stock.onHand = 0

Read-then-write made 50 sales from a stock of 10 and left the stock at 4. Most buyers read the same stock value, each wrote the value it had computed with $set, and they overwrote each other: a classic lost update. Over three runs that number was 4, 8 and 7, so the result differs every time. The conditional $inc made exactly 10 sales on every run and stopped at 0, because the check and the decrement are one atomic operation on the server, on the same document. This code uses the driver in its rawest form, with BsonDocument; typed collections and Builders are in post 9. Decreasing the stock of several books in one order means several documents; the ways to do that (a transaction or a reservation) are in posts 4 and 10.

The same idea works for "helpful" votes on reviews. A customer must not be able to vote for the same review twice, and the counter must stay consistent with the voters:

// 03 - "helpful" vote: the same customer is not counted twice
const review = db.reviews.findOne({ bookId: 26, body: { $exists: true } }, { _id: 1 });
db.reviews.updateOne({ _id: review._id }, { $unset: { helpfulVoters: "", helpfulCount: "" } });

for (const customerId of [7, 7, 12]) {
    const r = db.reviews.updateOne(
        { _id: review._id, helpfulVoters: { $ne: customerId } },
        { $addToSet: { helpfulVoters: customerId }, $inc: { helpfulCount: 1 } }
    );
    print(`customer ${customerId}: modified ${r.modifiedCount}`);
}
printjson(db.reviews.findOne({ _id: review._id }, { _id: 0, helpfulVoters: 1, helpfulCount: 1 }));
customer 7: modified 1
customer 7: modified 0
customer 12: modified 1
{
  helpfulCount: 2,
  helpfulVoters: [
    7,
    12
  ]
}

The helpfulVoters: { $ne: customerId } in the filter matches only if the customer is not in the list; on the second vote the document did not match and neither the list nor the counter changed. With $addToSet alone the list would stay correct, but $inc would bump the counter every time. This works well as long as the number of voters per review stays reasonable; for cases where it could grow without bound, see the unbounded array discussion in post 4.

Array Queries: $elemMatch, $size and $all

The rule from "Guess First" in short: with a single condition on an array field ("items.bookId": 26), dot notation is enough and correct. With several conditions that must hold on the same element, $elemMatch is required. A few more array operators:

// 03 - array queries: $size and $all
printjson(db.books.find({ authors: { $size: 0 } }, { _id: 0, title: 1 }).toArray());
printjson(db.books.find(
    { "authors.name": { $all: ["Neil Gaiman", "Terry Pratchett"] } },
    { _id: 0, title: 1, "authors.name": 1 }
).toArray());
[
  {
    title: 'Beowulf'
  },
  {
    title: 'One Thousand and One Nights'
  }
]
[
  {
    title: 'Good Omens',
    authors: [
      {
        name: 'Terry Pratchett'
      },
      {
        name: 'Neil Gaiman'
      }
    ]
  }
]

$size matches the exact length of an array; it found the two classics with no known author. $size does not take a range: for "more than three authors" you need a check on an index position such as { "authors.3": { $exists: true } }, or an aggregation. $all requires every given value to be in the array and ignores order; in the query Gaiman comes first, in the document Pratchett. Comparing an array directly with an array ({ tags: ["a", "b"] }), on the other hand, looks for exact equality: the same elements in the same order.

In Production

Check the result counts. Every update returns matchedCount and modifiedCount; in conditional updates these numbers are the outcome of the business rule. As in the stock example, 0 is not an error but an answer, and the application should turn it into a meaningful message for the user.

Move the rule closer to the database. Every flow that reads, decides in the application and then writes is a candidate to break under concurrency. Put the check in the filter and the calculation in an update operator ($inc, $min, $max); for rules that do not fit in a single document, transactions or the model itself are in posts 4 and 10.

For bulk loads, keep batches small and use unordered. Instead of passing hundreds of thousands of documents to a single insertMany, batches of a few thousand documents are easier on both memory and error handling. The default write concern is majority; the trade-off between durability and speed of a write is in post 12.

Common Pitfalls

Overwriting a document with a replacement. If someone who only wants to change the price uses replaceOne or a driver's replace method by mistake:

// Replacing the whole document when only the price should change
db.books.replaceOne({ _id: 103 }, { listPrice: Decimal128("260.00") });
printjson(db.books.findOne({ _id: 103 }));

try {
    db.books.updateOne({ _id: 103 }, { listPrice: Decimal128("250.00") });
} catch (e) {
    print(e.name + ":", e.message);
}
{
  _id: 103,
  listPrice: Decimal128('260.00')
}
MongoInvalidArgumentError: Update document requires atomic operators

The book's title, author and stock are gone; only _id and the price remain. That is exactly what replaceOne does: it replaces everything except _id with the document you give it. updateOne, on the other hand, rejects a document without operators; the old mongo shell's update() would replace the document in this case too, which is why this mistake is common in old scripts and examples. The rule is simple: a partial change is always an update with operators.

Confusing null with a missing field. In the data we moved from SQL, books without a subtitle have no subtitle field:

// null matches both a null value and a missing field
print("subtitle: null      ->", db.books.countDocuments({ subtitle: null }));
print("subtitle: $exists   ->", db.books.countDocuments({ subtitle: { $exists: false } }));
print("subtitle: $type null->", db.books.countDocuments({ subtitle: { $type: "null" } }));
subtitle: null      -> 71
subtitle: $exists   -> 71
subtitle: $type null-> 0

{ subtitle: null } matches both documents whose value is null and those without the field at all; here they are all of the second kind. When the difference matters, use $exists or $type: "null". Not using two representations for the same meaning in one collection (sometimes null, sometimes missing) is the cleanest solution.

Generalizing single-document atomicity to many documents. updateMany updates each document individually and atomically, but the operation as a whole is not atomic: a reader may see some documents in their new state and others in the old one while it runs, and if the operation is interrupted the documents already updated are not rolled back. A requirement such as "change the price of the 9 books in the campaign at the same moment" needs either a transaction or a different model that keeps the campaign in a single document.

A Small Experiment

★☆☆ List the 5 orders containing at least 2 copies of Dune (bookId 26) with $elemMatch, return only _id, the customer name and the order date, sorted from newest to oldest.

★★☆ Write an upsert that increases the quantity of the existing item instead of adding a new one when the same book is added to the cart a second time. Hint: it may take two steps; first try $inc with positional $ on a cart that contains the item, and if nothing matches, do the upsert with $push. Think about what can race between the two steps.

★★★ Add a variant of the stock-race command where each buyer asks for 1 to 3 copies. Build the conditional $inc with { "stock.onHand": { $gte: quantity } } and show that the total number of copies sold never exceeds 10 on any run.

In this post we took the shape of the data as given: items in the order, stock in the book, reviews separate. In the next post we go back to those decisions themselves: what to embed, what to reference, and how the 16 MB limit, arrays that grow without bound and the cost of updating duplicated data affect that choice.