Database NotesRohit’s interview study guide
Chapter 18

Updating Documents

Changing documents in place: updateOne/updateMany with $set, removing and renaming fields, arithmetic operators, upserts, replacing and find-and-modify, array operators, the positional $, $[] with arrayFilters, and pipeline updates.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#updateOne and updateMany with $set

An update has two parts: a filter that picks documents (exactly the same language as find) and an update document of update operators that say what to change. updateOne(filter, update) changes the first match; updateMany(filter, update) changes every match. The most common operator is $set, which sets fields to values — adding them if they don't exist.

The mental model: an update is a set of edit instructions applied to each matching document on the server, not a "read, change in your code, write back". That matters because each single-document update is atomic: two clients doing $inc on the same counter can't lose each other's increments, and nobody can see a half-applied update. Both methods return a result with matchedCount (how many documents the filter found) and modifiedCount (how many actually changed) — and those two numbers are not always the same.

Every topic in this chapter starts from the same small users collection, reset before each topic:

mongosh
db.users.insertMany([
  { _id: 1, name: "Alice",   age: 28, city: "Toronto",     tags: ["vip"],
    activities: [ { type: "swimming", status: "done" }, { type: "chess", status: "pending" }, { type: "hiking", status: "pending" } ] },
  { _id: 2, name: "Bob",     age: 45, city: "New York",
    activities: [ { type: "golf", status: "done" } ] },
  { _id: 3, name: "Chandra", age: 52, city: "Mumbai",
    activities: [ { type: "swimming", status: "pending" }, { type: "cricket", status: "done" } ] },
  { _id: 4, name: "Dana",    age: 35, city: "Los Angeles" },
  { _id: 5, name: "Elif",    age: 41, city: "Los Angeles", activities: [] }
]).insertedIds

db.users.updateOne({ name: "Alice" }, { $set: { age: 11 } })
db.users.updateOne({ name: "Alice" }, { $set: { age: 11 } })
db.users.findOne({ _id: 1 }, { name: 1, age: 1 })
Result
{ '0': 1, '1': 2, '2': 3, '3': 4, '4': 5 }

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 0,
  upsertedCount: 0
}
{ _id: 1, name: 'Alice', age: 11 }
What’s happening
  1. Five users go in. Alice has three activities, Bob one, Chandra two; Dana has no activities field and Elif has an empty array — later topics rely on those differences.
  2. My notes' first update: "update user by name, set age to 11 using $set". The filter { name: "Alice" } finds one document and $set changes age from 28 to 11: matchedCount: 1, modifiedCount: 1.
  3. Running the same update again finds Alice (matchedCount: 1) but changes nothing (modifiedCount: 0): her age is already 11, and MongoDB detects that the update is a no-op and doesn't write. That's how you tell "not found" from "already in that state".
  4. insertedId: null and upsertedCount: 0 belong to upserts; with a normal update they're always null and 0.
  5. findOne confirms the stored value. Only age changed: $set touches only the fields it names, and every other field — including the whole activities array — is untouched.
AdvancedupdateMany, $in and $and

My notes' multi-document updates — everyone over 40, a $in on city, and a filter with $and:

mongosh
db.users.updateMany({ age: { $gt: 40 } }, { $set: { ageGroup: "40+" } })
db.users.updateMany({ city: { $in: ["New York", "Los Angeles"] } }, { $set: { status: "active" } })
db.users.updateMany({ $and: [ { city: "Los Angeles" }, { age: { $lt: 40 } } ] }, { $set: { promo: "LA-under-40" } })
db.users.find({}, { _id: 0, name: 1, age: 1, city: 1, ageGroup: 1, status: 1, promo: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 3,
  modifiedCount: 3,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 3,
  modifiedCount: 3,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[
  { name: 'Alice', age: 28, city: 'Toronto' },
  {
    name: 'Bob',
    age: 45,
    city: 'New York',
    ageGroup: '40+',
    status: 'active'
  },
  { name: 'Chandra', age: 52, city: 'Mumbai', ageGroup: '40+' },
  {
    name: 'Dana',
    age: 35,
    city: 'Los Angeles',
    status: 'active',
    promo: 'LA-under-40'
  },
  {
    name: 'Elif',
    age: 41,
    city: 'Los Angeles',
    ageGroup: '40+',
    status: 'active'
  }
]
What’s happening
  1. Alice's age was put back to 28 off-page first. My notes set a name here; a label field is the more realistic case. updateMany({ age: { $gt: 40 } }, …) then matches Bob (45), Chandra (52) and Elif (41) and adds a new field ageGroup to each: matchedCount: 3, modifiedCount: 3. $set creates a field that doesn't exist.
  2. The $in update — "city is New York or Los Angeles → status active" — matches Bob, Dana and Elif.
  3. The $and filter picks users in Los Angeles and under 40: only Dana. (Here the implicit form { city: "Los Angeles", age: { $lt: 40 } } would do the same; $and is required only for repeated keys, as in Logical operators.)
  4. The final find shows the result: fields appear only on the documents each update matched. Alice and Chandra have no status; only Dana has promo.
  5. The filter is the whole safety story: updateMany with a filter that's too broad silently changes more documents than you meant. Check it with countDocuments(filter) first.
AdvancedAdding a field everywhere, and nested fields

My notes: "add a hobbies array field to all documents", and "update all records where target >= 200, add a new field":

mongosh
db.users.updateMany({}, { $set: { hobbies: [] } })
db.sales.updateMany({ target: { $gte: 200 } }, { $set: { reviewed: true } })
db.sales.find({}, { _id: 0, item: 1, target: 1, reviewed: 1 })

db.users.updateOne({ _id: 1 }, { $set: { "address.city": "Toronto", "address.zip": "M5H" } })
db.users.findOne({ _id: 1 }, { _id: 0, address: 1, hobbies: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 5,
  modifiedCount: 5,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 2,
  modifiedCount: 2,
  upsertedCount: 0
}
[
  { item: 'laptops', target: 200, reviewed: true },
  { item: 'phones', target: 220, reviewed: true },
  { item: 'books', target: 80 }
]

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ hobbies: [], address: { city: 'Toronto', zip: 'M5H' } }
What’s happening
  1. An empty filter {} matches every user, so updateMany({}, { $set: { hobbies: [] } }) gives all five an empty hobbies array: matchedCount: 5, modifiedCount: 5.
  2. On a sales collection (laptops target 200, phones 220, books 80), { target: { $gte: 200 } } matches laptops and phones, which get reviewed: true. Books is untouched, so it has no reviewed field at all.
  3. $set with dot notation reaches into embedded documents. Alice had no address, so MongoDB creates the embedded document and puts both fields in it: address: { city: 'Toronto', zip: 'M5H' }.
  4. Dot notation in $set never replaces the whole embedded document — $set: { "address.zip": "M5V" } later would change only the zip. $set: { address: { zip: "M5V" } } (no dot) would replace the whole address and lose city.
AdvancedPitfall: forgetting the update operator
AdvancedCorrecting my notes: update() is legacy

#$unset and $rename

$unset removes fields from a document: { $unset: { age: "" } }. The value you give is ignored — "", 1 and true all mean "remove" — the convention is an empty string. $rename changes a field's name: { $rename: { city: "town" } }, keeping its value.

Together with $set these are MongoDB's schema migration tools: there's no ALTER TABLE, so adding, dropping and renaming "columns" are updateMany calls over the collection. Mental model: $unset is tearing a label off a box and throwing the contents away; $rename is peeling the label off and writing a new one, contents untouched.

mongosh
db.users.updateMany({}, { $unset: { age: "" } })
db.users.updateMany({}, { $rename: { city: "town" } })
db.users.find({}, { _id: 0, name: 1, age: 1, city: 1, town: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 5,
  modifiedCount: 5,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 5,
  modifiedCount: 5,
  upsertedCount: 0
}
[
  { name: 'Alice', town: 'Toronto' },
  { name: 'Bob', town: 'New York' },
  { name: 'Chandra', town: 'Mumbai' },
  { name: 'Dana', town: 'Los Angeles' },
  { name: 'Elif', town: 'Los Angeles' }
]
What’s happening
  1. My notes' example: updateMany({}, { $unset: { age: "" } }) — "this will remove the age from all the documents". All five match and all five change.
  2. $rename: { city: "town" } renames the field in every document. The value moves with it: Alice's town is 'Toronto'.
  3. The projection asks for age, city and town; only name and town come back, because age and city no longer exist anywhere.
  4. Run either update again and you get matchedCount: 5, modifiedCount: 0: removing a missing field or renaming a missing field is a no-op, not an error. That makes both safe to re-run in migration scripts.
Advanced$rename into and out of embedded documents
mongosh
db.users.updateOne({ _id: 1 }, { $rename: { town: "address.city" } })
db.users.findOne({ _id: 1 }, { _id: 0, name: 1, town: 1, address: 1 })
db.users.updateOne({ _id: 1 }, { $rename: { "activities.0.type": "activities.0.kind" } })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ name: 'Alice', address: { city: 'Toronto' } }
MongoServerError: Plan executor error during update :: caused by :: The source field cannot be an array element, 'activities.0.type' in doc with _id: 1 has an array field called 'activities'
What’s happening
  1. Renaming town to address.city moves the value into an embedded document, creating address because Alice didn't have one.
  2. The findOne shows address: { city: 'Toronto' } and no town.
  3. $rename doesn't work through arrays: renaming a field inside activities.0 fails with The source field cannot be an array element (the full message names the field). To rename a field inside every array element, use an update pipeline with $map, or $set the new name and $unset the old one with $[].
AdvancedEdge case: $unset on an array element

#$inc, $min, $max and $mul

These operators change a value relative to what's already stored, on the server, atomically. $inc adds a number (negative to subtract). $mul multiplies. $min sets the field to the given value only if it's smaller than the current one; $max only if it's larger. If the field doesn't exist, $inc and $min/$max create it with the given value, and $mul creates it as 0.

Why not read the value, add in your code and $set it back? Because two clients doing that at the same moment both read 10 and both write 11, losing one increment. $inc happens inside the server's single-document atomic write, so concurrent increments all count. Mental model: $inc is the tally counter clicker at a venue door — a dozen staff can click their own clickers on the same total and none is lost; $min/$max are the "lowest/highest so far" pegs on a thermometer that only move one way.

mongosh
db.users.updateOne({ name: "Bob" }, { $inc: { age: 3 } })
db.users.updateOne({ name: "Bob" }, { $inc: { age: -1, logins: 1 } })
db.users.findOne({ name: "Bob" }, { _id: 0, name: 1, age: 1, logins: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ name: 'Bob', age: 47, logins: 1 }
What’s happening
  1. My notes: "increment age by 3". Bob's age goes 45 → 48.
  2. One update can carry several fields: age: -1 decrements (48 → 47) and logins: 1 increments a field that doesn't exist yet, so MongoDB creates it with the value 1.
  3. The final document has age: 47, logins: 1. Both changes were applied in one atomic write.
  4. This is the standard way to keep counters — page views, stock levels, likes — and $inc combined with a filter like { stock: { $gte: 1 } } is how you decrement stock without ever going negative.
Advanced$min and $max only move one way
mongosh
db.scores.updateOne({ _id: 1 }, { $max: { best: 95 }, $min: { worst: 55 } })
db.scores.findOne({ _id: 1 })
db.scores.updateOne({ _id: 1 }, { $max: { best: 70 }, $min: { worst: 30 } })
db.scores.findOne({ _id: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ _id: 1, player: 'Alice', best: 95, worst: 40 }
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ _id: 1, player: 'Alice', best: 95, worst: 30 }
What’s happening
  1. Alice starts with best: 80, worst: 40. A new game scores 95 and 55.
  2. $max: { best: 95 } replaces 80 because 95 is larger. $min: { worst: 55 } does nothing, because 55 isn't smaller than 40. One field changed, so modifiedCount: 1.
  3. The next game scores 70 and 30. $max: { best: 70 } does nothing (70 < 95); $min: { worst: 30 } replaces 40.
  4. The final document is best: 95, worst: 30. Without these operators you'd need a read, a comparison in your code and a conditional write — and a race between two games finishing at once. Typical uses: high scores, "lowest price seen", lastSeenAt with $max so a delayed event can't move it backwards.
Advanced$mul on decimals, and type errors
mongosh
db.prices.updateMany({}, { $mul: { price: NumberDecimal("1.10") } })
db.prices.find()
db.prices.updateOne({ _id: 1 }, { $mul: { discount: 2 } })
db.prices.findOne({ _id: 1 })
db.users.updateOne({ _id: 1 }, { $inc: { name: 1 } })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 2,
  modifiedCount: 2,
  upsertedCount: 0
}
[
  { _id: 1, item: 'USB-C Charger', price: Decimal128('31.90') },
  { _id: 2, item: 'Pixel 10', price: Decimal128('878.9000') }
]
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  _id: 1,
  item: 'USB-C Charger',
  price: Decimal128('31.90'),
  discount: 0
}
MongoServerError: Plan executor error during update :: caused by :: Cannot apply $inc to a value of non-numeric type. {_id: 1} has the field 'name' of non-numeric type string
What’s happening
  1. A 10% price rise: $mul by NumberDecimal("1.10") on both products.
  2. The USB-C Charger's price was the int 29; multiplying by a Decimal128 gives Decimal128('31.90'). The Pixel's Decimal128 799.00 becomes Decimal128('878.9000') — exact decimal arithmetic, though the scale grows (round it with $round in an update pipeline if you need two places). Multiplying the int by the double 1.1 instead would have produced a double like 31.900000000000002.
  3. $mul on a missing field (discount) creates it with the value 0, whatever you multiply by — a surprise if you expected "missing" to mean "leave alone".
  4. Arithmetic on a non-number is an error: $inc on Alice's name fails with Cannot apply $inc to a value of non-numeric type, naming the document and the field type.
Predict the result: two clients, one counter
mongosh
// Client A and client B both run this at the same moment on { _id: "home", views: 10 }:
db.pages.updateOne({ _id: "home" }, { $inc: { views: 1 } })
Show answer

views ends at 12.

What’s happening
  1. Each updateOne is applied by the server as one atomic operation on the document: read the current value, add 1, write — with the document locked against other writers in between.
  2. Whichever update goes first takes views from 10 to 11; the second then sees 11 and takes it to 12.
  3. Compare the read-modify-write version in application code: both clients findOne and see 10, both compute 11, both $set: { views: 11 } — one increment is lost. That "lost update" is exactly what $inc prevents.

#Upserts

An upsert is "update if it exists, insert if it doesn't": pass { upsert: true } to updateOne, updateMany or replaceOne. My notes describe it well: "for name ankit, set age to 90 with upsert true: if it's not present it will be inserted, else updated."

When the filter matches nothing, MongoDB builds a new document from the filter's equality conditions plus the update's changes, and inserts it. Mental model: an upsert is a hotel check-in: if you have a reservation (a matching document), they update it; if not, they create a booking on the spot using the name you gave at the desk (the filter) and the details you asked for (the update).

mongosh
db.users.updateOne({ name: "ankit" }, { $set: { age: 90 } }, { upsert: true })
db.users.findOne({ name: "ankit" })
db.users.updateOne({ name: "ankit" }, { $set: { age: 91 } }, { upsert: true })
db.users.countDocuments({ name: "ankit" })
Result
{
  acknowledged: true,
  insertedId: ObjectId('6ad24856302be24bfe35e2ba'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}
{ _id: ObjectId('6ad24856302be24bfe35e2ba'), name: 'ankit', age: 90 }
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
1
What’s happening
  1. No user is called ankit, so the first upsert inserts: matchedCount: 0, modifiedCount: 0, upsertedCount: 1, and upsertedId is the new document's generated ObjectId.
  2. The new document is { _id: ObjectId(…), name: 'ankit', age: 90 }. name came from the filter (an equality condition), age from the $set, and _id was generated because neither supplied one.
  3. The second upsert finds ankit and updates him to 91: matchedCount: 1, modifiedCount: 1, upsertedCount: 0, upsertedId: null.
  4. There is still exactly one ankit. The same call can be run safely any number of times — upserts make writes idempotent ("make it so"), which is why they are used for syncing data from another system.
Advanced$setOnInsert: fields only for new documents
mongosh
db.users.updateOne(
  { name: "zoe" },
  { $set: { lastLogin: ISODate("2026-10-16T09:00:00Z") }, $setOnInsert: { createdAt: ISODate("2026-10-16T09:00:00Z"), plan: "free" } },
  { upsert: true }
).upsertedCount
db.users.updateOne(
  { name: "zoe" },
  { $set: { lastLogin: ISODate("2026-10-17T18:30:00Z") }, $setOnInsert: { createdAt: ISODate("2026-10-17T18:30:00Z"), plan: "free" } },
  { upsert: true }
).modifiedCount
db.users.findOne({ name: "zoe" }, { _id: 0 })
Result
1
1
{
  name: 'zoe',
  createdAt: ISODate('2026-10-16T09:00:00.000Z'),
  lastLogin: ISODate('2026-10-17T18:30:00.000Z'),
  plan: 'free'
}
What’s happening
  1. The first call inserts zoe (upsertedCount is 1). On an insert, both $set and $setOnInsert apply: lastLogin, createdAt and plan are all written.
  2. The second call matches zoe and updates her (modifiedCount is 1). Now only $set applies; $setOnInsert is ignored, so createdAt keeps the first login's date and plan isn't reset.
  3. The final document shows lastLogin from the second call and createdAt from the first. This is the standard "record a login, and remember when we first saw this user" pattern in one round trip.
AdvancedWhich filter fields reach the new document
mongosh
db.users.updateOne({ name: "yuki", age: { $gt: 30 } }, { $set: { city: "Osaka" } }, { upsert: true }).upsertedCount
db.users.findOne({ name: "yuki" }, { _id: 0 })
Result
1
{ name: 'yuki', city: 'Osaka' }
What’s happening
  1. The filter has an equality (name: "yuki") and a range (age > 30). Nothing matches, so a document is inserted.
  2. Only the equality condition is copied into it: { name: 'yuki', city: 'Osaka' }. MongoDB can't invent an age from "greater than 30", so it doesn't try.
  3. If a field must be set on insert, put it in $set or $setOnInsert, not only in the filter.
AdvancedPitfall: concurrent upserts and duplicates

#replaceOne and findOneAndUpdate

Added

Two relatives of updateOne. replaceOne(filter, replacement) swaps the whole document for a new one, keeping only its _id: the replacement is a plain document, with no update operators. findOneAndUpdate(filter, update, options) applies an update to one document and returns that document — by default the version before the update, or after it with returnDocument: "after". Its siblings are findOneAndReplace and findOneAndDelete.

When to use which: updateOne when you want to change some fields and don't need the document back; replaceOne when your application holds the full new version of the document (an editor's "save" button, a sync from another system); findOneAndUpdate when you need the result of the change atomically — claim the next job, get the next sequence number, decrement stock and return what's left. Mental model: updateOne is editing a form in place, replaceOne is handing in a fresh form, and findOneAndUpdate is editing the form and getting a photocopy of it, all at the counter in one visit.

mongosh
db.users.replaceOne({ _id: 4 }, { name: "Dana", city: "San Diego", email: "dana@example.com" })
db.users.findOne({ _id: 4 })
db.users.replaceOne({ _id: 4 }, { $set: { city: "LA" } })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ _id: 4, name: 'Dana', city: 'San Diego', email: 'dana@example.com' }
MongoInvalidArgumentError: Replacement document must not contain atomic operators
What’s happening
  1. replaceOne finds Dana by _id and replaces her document with the new one: matchedCount: 1, modifiedCount: 1.
  2. The stored document is now exactly the replacement plus the original _id: 4. Her age is gone, because the replacement didn't include it — that's the difference from $set, which would have kept every field it didn't mention.
  3. A replacement containing update operators is rejected before it's sent: Replacement document must not contain atomic operators. The two methods refuse each other's input on purpose.
  4. replaceOne also accepts upsert: true, and since MongoDB 8.0 a sort option to choose which document is replaced when several match.
AdvancedfindOneAndUpdate: get the document back
mongosh
db.users.findOneAndUpdate({ _id: 2 }, { $inc: { age: 1 } }, { projection: { _id: 0, name: 1, age: 1 } })
db.users.findOneAndUpdate({ _id: 2 }, { $inc: { age: 1 } }, { projection: { _id: 0, name: 1, age: 1 }, returnDocument: "after" })
db.users.findOneAndUpdate({ _id: 99 }, { $inc: { age: 1 } })
Result
{ name: 'Bob', age: 45 }
{ name: 'Bob', age: 47 }
null
What’s happening
  1. The first call increments Bob's age from 45 to 46 and returns the document as it was before: age: 45. That default surprises people; it exists so you can see what you changed.
  2. With returnDocument: "after" the second call returns the updated document: 46 → age: 47. (mongosh also accepts the older spelling returnNewDocument: true.)
  3. The projection option limits the returned fields, like find's second argument; sort (not shown) picks which document to modify when several match.
  4. When nothing matches, the result is null — and with upsert: true it would insert instead.
AdvancedA sequence generator with findOneAndUpdate

The pattern mentioned in _id and ObjectId for when you truly need sequential numbers:

mongosh
const nextInvoice = () => db.counters.findOneAndUpdate(
  { _id: "invoice" },
  { $inc: { seq: 1 } },
  { upsert: true, returnDocument: "after" }
).seq

nextInvoice()
nextInvoice()
nextInvoice()
Result
1
2
3
What’s happening
  1. The first call finds no invoice counter, so the upsert inserts { _id: 'invoice', seq: 1 } ($inc on a missing field starts from 0) and returnDocument: "after" returns it: 1.
  2. Each later call increments and returns the new value: 2, then 3.
  3. Because increment-and-read is one atomic operation, two concurrent callers can never get the same number.
  4. The cost is that every invoice now writes to the same single document, so it's a contention point under heavy load, and numbers can still have gaps if a later step fails after the number was taken. Use it where sequential numbers are a business requirement, not as a general primary-key strategy.

#Arrays: $push, $addToSet, $pull and $pop

MongoDB has a set of operators that change arrays in place, without rewriting the whole array from your application:

  • $push appends a value (or several, with $each), optionally sorting and trimming the array in the same step.
  • $addToSet appends only if the value isn't already there — in my notes' words, it adds elements "only if they don't already exist in the array… much like a set".
  • $pull removes every element equal to a value or matching a condition; $pullAll removes every element equal to any of a list.
  • $pop removes the first (-1) or last (1) element — "remove element from last or first without any filters", as my notes put it.

Mental model: the array is a queue of people. $push adds someone at the back; $addToSet checks the queue first and only adds them if they're not already in it; $pull asks everyone matching a description to leave; $pop removes whoever is at the front or the back, no questions asked.

mongosh
db.users.updateOne({ name: "Bob" }, { $push: { activities: { type: "badminton", status: "pending" } } })
db.users.findOne({ name: "Bob" }, { _id: 0, activities: 1 })

db.users.updateMany({}, { $pull: { activities: { type: "swimming" } } })
db.users.find({ _id: { $in: [1, 3] } }, { _id: 0, name: 1, activities: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  activities: [
    { type: 'golf', status: 'done' },
    { type: 'badminton', status: 'pending' }
  ]
}

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 5,
  modifiedCount: 2,
  upsertedCount: 0
}
[
  {
    name: 'Alice',
    activities: [
      { type: 'chess', status: 'pending' },
      { type: 'hiking', status: 'pending' }
    ]
  },
  {
    name: 'Chandra',
    activities: [ { type: 'cricket', status: 'done' } ]
  }
]
What’s happening
  1. My notes: "Bob gets another activity, type badminton, status pending". $push appends the new embedded document to the end of Bob's activities, which goes from one element to two.
  2. My notes' $pull: "remove activity where type is swimming". With updateMany({}, …) it runs on every user. The value { type: "swimming" } is treated as a condition on each element (like a query), so any activity whose type is swimming is removed, whatever its status.
  3. Alice and Chandra each lose their swimming activity: matchedCount: 5 (every user was matched by {}) but modifiedCount: 2, because only two documents had anything to pull.
  4. Dana has no activities field at all, and $pull on a missing field is simply a no-op, not an error.
  5. Each of these is a single atomic write on the document, so concurrent pushes from two clients both land.
Advanced$addToSet: push only if absent

My notes' definition, with the modern method name:

mongosh
db.users.updateOne({ _id: 1 }, { $addToSet: { tags: "vip" } })
db.users.updateOne({ _id: 1 }, { $addToSet: { tags: "early-adopter" } })
db.users.updateOne({ _id: 1 }, { $addToSet: { tags: { $each: ["vip", "beta", "early-adopter"] } } })
db.users.findOne({ _id: 1 }, { _id: 0, tags: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 0,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ tags: [ 'vip', 'early-adopter', 'beta' ] }
What’s happening
  1. Alice's tags is ["vip"]. Adding "vip" again is a no-op: matchedCount: 1, modifiedCount: 0.
  2. Adding "early-adopter" appends it: ["vip", "early-adopter"].
  3. With $each, $addToSet checks each value separately: "vip" and "early-adopter" are already there, "beta" isn't, so only "beta" is appended. Without $each, the whole array ["vip", "beta", "early-adopter"] would be added as one nested element.
  4. Final: ['vip', 'early-adopter', 'beta']. Note $addToSet doesn't sort, and it compares embedded documents by exact equality including field order — { a: 1, b: 2 } and { b: 2, a: 1 } count as different.
AdvancedA capped top-N list with $push modifiers
mongosh
db.scores.updateOne({ _id: 1 }, { $push: { top: { $each: [92, 60, 88], $sort: -1, $slice: 3 } } })
db.scores.findOne({ _id: 1 })
db.scores.updateOne({ _id: 1 }, { $push: { top: { $each: [99], $position: 0 } } })
db.scores.findOne({ _id: 1 }).top
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{ _id: 1, player: 'Alice', top: [ 92, 88, 85 ] }
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[ 99, 92, 88, 85 ]
What’s happening
  1. Alice's top starts as [70, 85]. $push with $each appends three scores, giving [70, 85, 92, 60, 88] in the middle of the operation.
  2. $sort: -1 sorts the array descending: [92, 88, 85, 70, 60]. $slice: 3 then keeps the first three.
  3. The stored result is top: [92, 88, 85] — a "top 3" list maintained in one atomic update, which also keeps the array from growing without bound (the 16 MB problem).
  4. $position: 0 inserts at the front instead of the end, giving [99, 92, 88, 85]. Positions count from 0; a negative position counts from the end.
Advanced$pull with conditions, $pullAll and $pop
mongosh
db.scores.updateOne({ _id: 1 }, { $pull: { history: { $lt: 50 } } })
db.scores.findOne({ _id: 1 }).history
db.scores.updateOne({ _id: 1 }, { $pullAll: { history: [55, 71] } })
db.scores.findOne({ _id: 1 }).history
db.scores.updateOne({ _id: 1 }, { $pop: { history: 1 } })
db.scores.updateOne({ _id: 1 }, { $pop: { history: -1 } })
db.scores.findOne({ _id: 1 }).history
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[ 55, 92, 88, 71 ]
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[ 92, 88 ]
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[]
What’s happening
  1. history starts as [55, 92, 40, 88, 40, 71]. $pull: { history: { $lt: 50 } } uses a query condition and removes every element below 50 — both 40s: [55, 92, 88, 71].
  2. $pullAll removes every occurrence of each listed value: 55 and 71 go, leaving [92, 88].
  3. $pop: { history: 1 } removes the last element (88), and $pop: { history: -1 } removes the first (92) — no filter, just position.
  4. The array is now empty, []: $pop leaves an empty array rather than removing the field.
Predict the result: $push an array
mongosh
// tags is ["vip"]
db.users.updateOne({ _id: 1 }, { $push: { tags: ["a", "b"] } })
Show answer

tags becomes ["vip", ["a", "b"]] — one new element that is itself an array.

mongosh
db.users.updateOne({ _id: 2 }, { $set: { tags: ["vip"] } })
db.users.updateOne({ _id: 2 }, { $push: { tags: ["a", "b"] } })
db.users.findOne({ _id: 2 }).tags
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[ 'vip', [ 'a', 'b' ] ]
What’s happening
  1. Bob's tags is set to ["vip"] first so the example starts where the question does.
  2. $push appends its value as one element, and the value here is an array, so the array is nested inside tags.
  3. To append "a" and "b" as two separate elements, use $push: { tags: { $each: ["a", "b"] } } — the same $each modifier $addToSet uses.

#The positional $ operator

To update one element of an array without knowing its index, use the positional $ operator: "activities.$.status". The $ stands for "the position of the first array element that the filter matched". So the filter does double duty: it chooses the document and the element.

Mental model: the filter points at an element with a laser pointer, and $ in the update says "this one — wherever the pointer is". The pointer marks only the first match, which is the operator's main limitation. My notes mention the positional operator twice, without an example that survived, so here is one.

mongosh
db.users.updateOne(
  { name: "Alice", "activities.type": "chess" },
  { $set: { "activities.$.status": "done" } }
)
db.users.findOne({ name: "Alice" }, { _id: 0, activities: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  activities: [
    { type: 'swimming', status: 'done' },
    { type: 'chess', status: 'done' },
    { type: 'hiking', status: 'pending' }
  ]
}
What’s happening
  1. The filter matches Alice and records which element of activities satisfied "activities.type": "chess": index 1.
  2. In the update, "activities.$.status" becomes "activities.1.status", so only the chess activity's status changes from pending to done.
  3. Swimming (index 0) and hiking (index 2) are untouched, and we never had to know that chess was at index 1.
  4. This is the classic way to update one item in an embedded list: one review, one line item, one address.
AdvancedOnly the first matching element is updated
mongosh
db.users.updateOne({ name: "Alice", "activities.status": "pending" }, { $set: { "activities.$.status": "started" } })
db.users.findOne({ name: "Alice" }, { _id: 0, activities: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  activities: [
    { type: 'swimming', status: 'done' },
    { type: 'chess', status: 'started' },
    { type: 'hiking', status: 'pending' }
  ]
}
What’s happening
  1. Alice's chess activity was set back to pending off-page, so she has two pending activities: chess (index 1) and hiking (index 2).
  2. The filter "activities.status": "pending" matches both, but $ marks only the first matching element. Chess becomes started; hiking stays pending, and modifiedCount is still just 1 (it counts documents, not elements).
  3. To update every matching element, use $[<identifier>] with arrayFilters; to update every element, $[] (next topic).
AdvancedEdge case: the filter must mention the array
Advanced$elemMatch in the filter for exact targeting

When the element must satisfy several conditions at once, put $elemMatch in the filter so $ points at the right one:

mongosh
db.users.updateOne(
  { activities: { $elemMatch: { type: "hiking", status: "pending" } } },
  { $set: { "activities.$.status": "done" } }
).modifiedCount
db.users.findOne({ name: "Alice" }, { _id: 0, activities: 1 })
Result
1
{
  activities: [
    { type: 'swimming', status: 'done' },
    { type: 'chess', status: 'started' },
    { type: 'hiking', status: 'done' }
  ]
}
What’s happening
  1. $elemMatch makes one element satisfy both conditions, so $ points at exactly that element: Alice's hiking activity, which is still pending.
  2. With two separate dot-notation conditions ("activities.type": "hiking", "activities.status": "pending"), each condition could be met by a different element, and $ wouldn't reliably point at the one you meant (the docs warn against relying on it). $elemMatch removes the ambiguity.
  3. Alice now has swimming done, chess started (from the previous box) and hiking done.

#$[] and arrayFilters

The positional $ updates the first matching element. Two more operators cover the rest. $[] — the all-positional operator — means "every element of this array". $[<identifier>] together with the arrayFilters option means "every element that satisfies this filter", where the identifier is a name you choose (starting with a lowercase letter) and arrayFilters describes the condition for it. Both arrived in MongoDB 3.6.

My notes describe $[] exactly: "for all elements where name is Alice, update all elements in the activities array, set their status to Bye… if a document array has 3 elements and only 1 matched, it will update all 3 in that document". And the follow-up question: "what if you wanted to update just 1? Make use of array filters." Mental model: $ is a laser pointer on one element, $[] floods the whole array with light, and $[x] with arrayFilters is a spotlight that lights up every element matching a description.

mongosh
db.users.updateMany(
  { name: "Alice", "activities.type": "swimming" },
  { $set: { "activities.$[].status": "Bye" } }
)
db.users.findOne({ name: "Alice" }, { _id: 0, activities: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  activities: [
    { type: 'swimming', status: 'Bye' },
    { type: 'chess', status: 'Bye' },
    { type: 'hiking', status: 'Bye' }
  ]
}
What’s happening
  1. The filter matches Alice because one of her activities is swimming.
  2. "activities.$[].status" doesn't care which element matched: $[] expands to every index — 0, 1 and 2.
  3. All three activities now have status: 'Bye', including chess and hiking, which had nothing to do with swimming. That is precisely my notes' point: "if a document array has 3 elements and only 1 matched, it will update all 3 in that document".
  4. The filter only chose which documents to update. To restrict which elements, you need arrayFilters.
AdvancedarrayFilters: update just the matching elements
mongosh
db.users.updateOne(
  { name: "Alice" },
  { $set: { "activities.$[act].status": "Bye" } },
  { arrayFilters: [ { "act.type": "swimming" } ] }
)
db.users.findOne({ name: "Alice" }, { _id: 0, activities: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  activities: [
    { type: 'swimming', status: 'Bye' },
    { type: 'chess', status: 'pending' },
    { type: 'hiking', status: 'pending' }
  ]
}
What’s happening
  1. Alice's activities were reset off-page to swimming done, chess pending, hiking pending.
  2. act is an identifier we invented. "activities.$[act].status" says "the status of every element I'll call act", and arrayFilters: [{ "act.type": "swimming" }] defines act as "an element whose type is swimming".
  3. Only the swimming activity becomes Bye; chess and hiking keep pending. That's my notes' "what if you wanted to update just 1".
  4. Unlike $, this updates every element that matches the array filter, not just the first, and the main filter doesn't need to mention the array at all.
AdvancedPartial failure: arrayFilters on missing arrays
mongosh
db.users.updateMany(
  {},
  { $set: { "activities.$[p].status": "todo" } },
  { arrayFilters: [ { "p.status": "pending" } ] }
)
db.users.find({ activities: { $exists: true, $ne: [] } }, { _id: 0, name: 1, activities: 1 })
Result
MongoServerError: Plan executor error during update :: caused by :: The path 'activities' must exist in the document in order to apply array updates.
[
  {
    name: 'Alice',
    activities: [
      { type: 'swimming', status: 'Bye' },
      { type: 'chess', status: 'todo' },
      { type: 'hiking', status: 'todo' }
    ]
  },
  { name: 'Bob', activities: [ { type: 'golf', status: 'done' } ] },
  {
    name: 'Chandra',
    activities: [
      { type: 'swimming', status: 'todo' },
      { type: 'cricket', status: 'done' }
    ]
  }
]
What’s happening
  1. The intent: in every user, rename every pending activity to todo. The filter {} matches all five users.
  2. It fails: The path 'activities' must exist in the document in order to apply array updates. Dana (_id: 4) has no activities field, and an array update on a missing path is an error, not a no-op.
  3. But look at the find: Alice's chess and hiking and Chandra's swimming are already todo. updateMany processes documents one at a time, each atomically, and it had updated Alice and Chandra (Bob needed no change) before it reached Dana and stopped. Elif was never processed. updateMany is not all-or-nothing.
  4. The fix is a filter that only matches documents that have something to update (next block).
AdvancedFiltering first so every document qualifies
mongosh
db.users.updateMany(
  { "activities.status": "pending" },
  { $set: { "activities.$[p].status": "todo" } },
  { arrayFilters: [ { "p.status": "pending" } ] }
)
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 2,
  modifiedCount: 2,
  upsertedCount: 0
}
What’s happening
  1. After resetting the three activities to pending off-page, the filter { "activities.status": "pending" } selects only users with at least one pending activity: Alice and Chandra. Dana is never touched, so there's no error.
  2. Inside each matched document, the array filter renames every pending element: matchedCount: 2, modifiedCount: 2.
  3. With $ this would have needed one update per element, because $ only handles the first match in each document. And if a batch like this must be all-or-nothing, run it inside a transaction.
Advanced$[] arithmetic and identifier rules
mongosh
db.scores.updateOne({ _id: 1 }, { $inc: { "rounds.$[]": 5 } })
db.scores.findOne({ _id: 1 }).rounds
db.scores.updateOne({ _id: 1 }, { $inc: { "rounds.$[high]": 1 } }, { arrayFilters: [ { high: { $gte: 90 } } ] })
db.scores.findOne({ _id: 1 }).rounds
db.scores.updateOne({ _id: 1 }, { $inc: { "rounds.$[High]": 1 } }, { arrayFilters: [ { High: { $gte: 90 } } ] })
db.scores.updateOne({ _id: 1 }, { $inc: { "rounds.$[x]": 1 } })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[ 75, 90, 97 ]
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
[ 75, 91, 98 ]
MongoServerError: Error parsing array filter :: caused by :: The top-level field name must be an alphanumeric string beginning with a lowercase letter, found 'High'
MongoServerError: No array filter found for identifier 'x' in path 'rounds.$[x]'
What’s happening
  1. $inc with $[] adds 5 to every round: [75, 90, 97].
  2. For an array of plain values, the array filter tests the identifier itself: { high: { $gte: 90 } }. Rounds of 90 and above get one more point: [75, 91, 98].
  3. Identifiers must start with a lowercase letter and contain only letters and digits; High fails with Error parsing array filter :: caused by :: The top-level field name must be an alphanumeric string beginning with a lowercase letter, found 'High'.
  4. Using $[x] without defining x in arrayFilters fails with No array filter found for identifier 'x' in path 'rounds.$[x]'.
  5. Identifiers can be combined for nested arrays — "grades.$[g].questions.$[q]" with a filter for each — which $ can't do at all.
Which operator?
mongosh
// For every order, mark the line items whose qty is 0 as "removed".
db.orders.updateMany({}, { $set: { "items.???.status": "removed" } }, /* options? */)
Show answer

$[z] with arrayFilters: [{ "z.qty": 0 }].

What’s happening
  1. $ would mark only the first zero-quantity item in each order, and it needs the filter to mention items — not enough when an order can have several.
  2. $[] would mark every item as removed, including ones with quantity.
  3. "items.$[z].status" with { arrayFilters: [ { "z.qty": 0 } ] } marks exactly the zero-quantity items, in every order, in one updateMany.

#Update pipelines

Added

Since MongoDB 4.2, the update argument can be an aggregation pipeline — an array of stages — instead of an update document. That lets an update compute new values from the document's own fields: build fullName from first and last, convert a string to a number, set a flag from a comparison, or write the server's current time. The stages allowed are $set (alias $addFields), $unset, $project, $replaceRoot and $replaceWith, and inside them you can use any aggregation expression.

The mental model: a classic update operator is a fixed instruction ("set age to 11"); a pipeline update is a formula evaluated against each document ("set ageNextYear to age + 1"). The square brackets are the only syntactic difference: { $set: { … } } is an operator, [ { $set: { … } } ] is a pipeline stage — and inside a pipeline, "$age" means "the value of age", not the string "$age".

mongosh
db.people.updateMany({}, [
  { $set: {
      fullName: { $concat: ["$first", " ", "$last"] },
      age: { $toInt: "$age" },
      updatedAt: "$$NOW"
  } }
])
db.people.find({}, { _id: 0, fullName: 1, age: 1, updatedAt: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 2,
  modifiedCount: 2,
  upsertedCount: 0
}
[
  {
    age: 28,
    fullName: 'Alice Ng',
    updatedAt: ISODate('2026-10-16T15:52:55.127Z')
  },
  {
    age: 47,
    fullName: 'Grace Hopper',
    updatedAt: ISODate('2026-10-16T15:52:55.127Z')
  }
]
What’s happening
  1. The update is an array, so it's a pipeline with one $set stage, evaluated per document.
  2. fullName concatenates two existing fields with a space: 'Alice Ng', 'Grace Hopper'. A plain $set operator couldn't do this; it can only write constants.
  3. age: { $toInt: "$age" } converts Grace's CSV-imported string "47" into the integer 47 (Alice's 28 is already an int and stays 28). This is the fix promised in $exists and $type.
  4. updatedAt: "$$NOW" writes the server's current time — the same instant for every document in the operation.
  5. Both documents were modified in one updateMany with no data round-tripping through the application.
AdvancedStoring a computed comparison

$expr: comparing fields ended with "store the comparison and index it". A pipeline update does that:

mongosh
db.sales.updateMany({}, [
  { $set: { beatTarget: { $gt: ["$volume", "$target"] },
            margin: { $subtract: ["$volume", "$target"] } } }
])
db.sales.find({}, { _id: 0, item: 1, beatTarget: 1, margin: 1 })
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 3,
  modifiedCount: 3,
  upsertedCount: 0
}
[
  { item: 'laptops', beatTarget: true, margin: 20 },
  { item: 'phones', beatTarget: true, margin: 5 },
  { item: 'chargers', beatTarget: false, margin: -30 }
]
What’s happening
  1. $gt: ["$volume", "$target"] is the same expression as in the $expr query, but now its result — true or false — is stored in beatTarget.
  2. margin stores the difference: laptops 20, phones 5, chargers -30.
  3. Queries like { beatTarget: true } can now use an ordinary index. The application has to keep the fields in sync on later writes (or re-run this update), which is the usual trade of storage for read speed.
AdvancedArrays and conditions inside a pipeline

Update operators like $push don't exist inside a pipeline; you use expressions such as $concatArrays, $filter and $map instead:

mongosh
db.users.updateMany({}, [
  { $set: {
      activities: { $map: { input: "$activities", as: "a",
        in: { $mergeObjects: ["$$a", { label: { $toUpper: "$$a.type" } }] } } },
      pendingCount: { $size: { $filter: { input: "$activities", as: "a", cond: { $eq: ["$$a.status", "pending"] } } } }
  } },
  { $set: { level: { $cond: { if: { $gt: ["$pendingCount", 0] }, then: "busy", else: "free" } } } }
])
db.users.find({}, { _id: 0 })
db.users.updateOne({ _id: 1 }, [ { $push: { activities: { type: "tennis" } } } ])
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 2,
  modifiedCount: 2,
  upsertedCount: 0
}
[
  {
    name: 'Alice',
    activities: [
      { type: 'swimming', status: 'done', label: 'SWIMMING' },
      { type: 'chess', status: 'pending', label: 'CHESS' }
    ],
    pendingCount: 1,
    level: 'busy'
  },
  {
    name: 'Bob',
    activities: [ { type: 'golf', status: 'done', label: 'GOLF' } ],
    pendingCount: 0,
    level: 'free'
  }
]
MongoServerError[Location40324]: Unrecognized pipeline stage name: '$push'
What’s happening
  1. $map rewrites every element of activities: $mergeObjects keeps each activity's fields and adds a label that is the upper-cased type ('SWIMMING', 'CHESS', 'GOLF'). With operators you'd need $[] and still couldn't compute the label.
  2. $filter keeps only the pending activities and $size counts them: Alice 1, Bob 0. Note that both $set expressions in the same stage see the original document.
  3. The second stage sees the output of the first, so it can use pendingCount: $cond sets level to 'busy' for Alice and 'free' for Bob.
  4. Trying an update operator inside a pipeline fails: Unrecognized pipeline stage name: '$push'. To append in a pipeline you'd write $concatArrays: ["$activities", [ { type: "tennis" } ]].
AdvancedPitfall: literal strings that start with $

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.