Database NotesRohit’s interview study guide
Chapter 17

Querying Documents

Inserting documents, then reading them back: filters, comparison and logical operators, projections, arrays, embedded documents, regex, $exists, $expr, sorting, pagination, counting and deleting — with the traps my notes flagged.

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

#Inserting documents

Writing data comes first, because every query needs something to find. MongoDB has two insert methods: insertOne(doc) for a single document and insertMany([docs]) for a batch, which sends them in one round trip. Both return the _id of what they inserted; if a document has no _id, the client generates an ObjectId for it before sending.

The mental model for insertMany is a conveyor belt: documents go in one after another. By default it is ordered, so if one document fails (a duplicate _id, say), the belt stops there — earlier documents are already stored, later ones are never tried. With { ordered: false } the belt keeps going and reports every failure at the end. Either way, there is no rollback of the documents that succeeded: an insert batch is not a transaction.

This chapter's examples use one small users collection, inserted here, plus three more collections inserted at the end of this topic (products, movies, sales). One user, Grace, was "imported from a CSV" with her age stored as the string "47"; several later results depend on that.

mongosh
db.users.insertOne({
  _id: 1, name: "Alice", age: 28, city: "Toronto", email: "alice@example.com",
  hobbies: ["reading", "chess", "hiking"],
  address: { street: "12 King St", city: "Toronto", zip: "M5H" }
})

db.users.insertMany([
  { _id: 2, name: "Bob",     age: 45,   city: "New York",    email: "bob@example.com",
    hobbies: ["golf", "chess"], address: { city: "New York", zip: "10001" } },
  { _id: 3, name: "Chandra", age: 52,   city: "Mumbai",      email: "chandra@example.com",
    hobbies: ["cricket", "cooking", "chess"], address: { city: "Mumbai", zip: "400001" } },
  { _id: 4, name: "Dana",    age: 35,   city: "London",      email: null, hobbies: [] },
  { _id: 5, name: "Elif",    age: 41,   city: "Los Angeles", hobbies: ["surfing"] },
  { _id: 6, name: "Farid",   age: 19,   city: "Dubai",       email: "farid@example.com" },
  { _id: 7, name: "Grace",   age: "47", city: "Toronto",     email: "GRACE@EXAMPLE.COM", hobbies: ["Reading"] }
])
Result
{ acknowledged: true, insertedId: 1 }

{
  acknowledged: true,
  insertedIds: { '0': 2, '1': 3, '2': 4, '3': 5, '4': 6, '5': 7 }
}
What’s happening
  1. insertOne sends Alice and replies with insertedId: 1, the _id we chose. The users collection is created by this first write.
  2. insertMany sends the other six in one request. The reply's insertedIds maps each document's position in the array ('0' to '5') to its _id, so you can tell which input became which document.
  3. The documents deliberately differ: Dana has email: null and an empty hobbies array, Elif has no email field at all, Farid has no hobbies field, and Grace's age is a string. MongoDB accepted all of them without complaint, because nothing validates the shape.
  4. acknowledged: true means the server confirmed the write under the default write concern (majority of the replica set, which here is the one member). With { writeConcern: { w: 0 } } you'd get acknowledged: false and no confirmation at all (Write concern).
AdvancedOrdered and unordered batches on failure
mongosh
db.scratch.insertMany([{ _id: 1 }, { _id: 2 }])
db.scratch.insertMany([{ _id: 3 }, { _id: 1 }, { _id: 4 }])
db.scratch.insertMany([{ _id: 5 }, { _id: 1 }, { _id: 6 }], { ordered: false })
db.scratch.find()
Result
{ acknowledged: true, insertedIds: { '0': 1, '1': 2 } }
Uncaught:

MongoBulkWriteError: E11000 duplicate key error collection: ch16.scratch index: _id_ dup key: { _id: 1 }
Result: BulkWriteResult {
  insertedCount: 1,
  matchedCount: 0,
  modifiedCount: 0,
  deletedCount: 0,
  upsertedCount: 0,
  upsertedIds: {},
  insertedIds: { '0': 3 }
}
Write Errors: [
  WriteError {
    err: {
      index: 1,
      code: 11000,
      errmsg: 'E11000 duplicate key error collection: ch16.scratch index: _id_ dup key: { _id: 1 }',
      errInfo: undefined,
      op: { _id: 1 }
    }
  }
]
Uncaught:

MongoBulkWriteError: E11000 duplicate key error collection: ch16.scratch index: _id_ dup key: { _id: 1 }
Result: BulkWriteResult {
  insertedCount: 2,
  matchedCount: 0,
  modifiedCount: 0,
  deletedCount: 0,
  upsertedCount: 0,
  upsertedIds: {},
  insertedIds: { '0': 5, '2': 6 }
}
Write Errors: [
  WriteError {
    err: {
      index: 1,
      code: 11000,
      errmsg: 'E11000 duplicate key error collection: ch16.scratch index: _id_ dup key: { _id: 1 }',
      errInfo: undefined,
      op: { _id: 1 }
    }
  }
]
[ { _id: 1 }, { _id: 2 }, { _id: 3 }, { _id: 5 }, { _id: 6 } ]
What’s happening
  1. The first batch inserts _id 1 and 2.
  2. The second batch is ordered (the default). _id: 3 goes in, then _id: 1 is a duplicate: MongoBulkWriteError with E11000, and the Result: part shows insertedCount: 1. The batch stopped, so _id: 4 was never attempted. The Write Errors list gives the failing position (index: 1) and the code 11000.
  3. The third batch is unordered. _id: 5 goes in, _id: 1 fails, and the server carries on: _id: 6 goes in too, insertedCount: 2, and insertedIds has positions '0' and '2' — position 1 is the one that failed.
  4. The final find() shows 1, 2, 3, 5, 6: no 4, and nothing was rolled back. Use ordered: false for bulk loads where you want as many good documents as possible (it can also be faster, since the server may apply the batch in parallel); use the default when later documents depend on earlier ones. For all-or-nothing, wrap the inserts in a transaction.
AdvancedThe other collections used in this chapter

products is the site's sample product list with reviews embedded, movies has arrays of genres, ratings and comments, and sales has a volume and a target per item:

mongosh
db.products.insertMany([
  { _id: 1, name: "ThinkBook 14", category: "Electronics", subcategory: "Laptops", price: 899, stock: 12,
    reviews: [ { reviewer: "Alice", rating: 5, comment: "Great keyboard" }, { reviewer: "Bob", rating: 3, comment: "Fan is loud" } ] },
  { _id: 2, name: "MacBook Air", category: "Electronics", subcategory: "Laptops", price: 1299, stock: 5,
    reviews: [ { reviewer: "Bob", rating: 5, comment: "Silent and fast" }, { reviewer: "Alice", rating: 3, comment: "Too few ports" } ] },
  { _id: 3, name: "Pixel 10", category: "Electronics", subcategory: "Phones", price: 799, stock: 20,
    reviews: [ { reviewer: "Alice", rating: 4, comment: "great camera" } ] },
  { _id: 4, name: "iPhone 17", category: "Electronics", subcategory: "Phones", price: 1099, stock: 0, reviews: [] },
  { _id: 5, name: "SQL Performance Explained", category: "Books", price: 39, stock: 50,
    reviews: [ { reviewer: "Chandra", rating: 5, comment: "Must read for indexes" } ] },
  { _id: 6, name: "Designing Data-Intensive Applications", category: "Books", price: 49, stock: 35,
    reviews: [ { reviewer: "Alice", rating: 5 }, { reviewer: "Dana", rating: 4, comment: "Dense but worth it" } ] },
  { _id: 7, name: "USB-C Charger", category: "Electronics", subcategory: "Accessories", price: 29, stock: 100,
    reviews: [ { reviewer: "Alice", rating: 2, comment: "Stopped working" }, { reviewer: "Elif", rating: 4 } ] },
  { _id: 8, name: "Noise-cancelling Headphones", category: "Electronics", subcategory: "Accessories", price: 249, stock: 8,
    reviews: [ { reviewer: "Bob", rating: 4 } ] }
]).insertedIds

db.movies.insertMany([
  { _id: 1, title: "Inception", year: 2010, genres: ["action", "thriller", "sci-fi"], ratings: [9, 8, 10], visitors: 1200,
    comments: [ { user: "amy", text: "Mind-bending" }, { user: "raj", text: "Loved the score" }, { user: "lee", text: "Too long" } ] },
  { _id: 2, title: "Heat", year: 1995, genres: ["action", "thriller"], ratings: [8, 7], visitors: 800,
    comments: [ { user: "raj", text: "Classic" } ] },
  { _id: 3, title: "Amelie", year: 2001, genres: ["comedy", "romance"], ratings: [9, 7], visitors: 500 },
  { _id: 4, title: "Up", year: 2009, genres: ["animation", "comedy"], ratings: [7, 10, 6], visitors: 1200 },
  { _id: 5, title: "Dune", year: 1984, genres: ["sci-fi"], ratings: [6, 5], visitors: 300 },
  { _id: 6, title: "Dune", year: 2021, genres: ["sci-fi", "adventure"], ratings: [8, 9], visitors: 1500 },
  { _id: 7, title: "Mad Max: Fury Road", year: 2015, genres: ["action", "adventure"], ratings: [10, 9, 8], visitors: 900 }
]).insertedIds

db.sales.insertMany([
  { _id: 1, item: "laptops",    volume: 200, target: 180 },
  { _id: 2, item: "phones",     volume: 195, target: 190 },
  { _id: 3, item: "chargers",   volume: 120, target: 150 },
  { _id: 4, item: "books",      volume: 90,  target: 80 },
  { _id: 5, item: "headphones", volume: 190, target: 185 }
]).insertedIds
Result
{ '0': 1, '1': 2, '2': 3, '3': 4, '4': 5, '5': 6, '6': 7, '7': 8 }

{ '0': 1, '1': 2, '2': 3, '3': 4, '4': 5, '5': 6, '6': 7 }

{ '0': 1, '1': 2, '2': 3, '3': 4, '4': 5 }
What’s happening
  1. Each insertMany(...) is followed by .insertedIds, which picks just that part of the reply so the output stays short: position → _id for every document.
  2. In products, every document has an array of embedded review documents. Several later examples hinge on the fact that the MacBook Air and the USB-C Charger each have a review by Alice and a review with a high rating, but not in the same review.
  3. In movies, there are two films called "Dune" (for multi-key sorting), and Up has ratings [7, 10, 6], which matters for the $elemMatch example.
  4. In sales, phones and headphones beat their targets only narrowly, which is what the $cond example in $expr: comparing fields is about.

#find and findOne

find(filter, projection) returns every document matching the filter; findOne(filter, projection) returns just the first match, or null. The filter is a document that describes what you want — { city: "Toronto" } — and an empty filter {} (or no argument) matches everything.

The important difference is what comes back. find returns a cursor: a lazy handle to the result set that fetches documents from the server in batches as you iterate. findOne runs the query, takes one document, closes the cursor and gives you the document itself. Mental model: find hands you a ticket for the queue of results; findOne hands you the first person in the queue.

mongosh
db.users.find({ city: "Toronto" })
db.users.findOne({ city: "Toronto" })
db.users.findOne({ city: "Paris" })
Result
[
  {
    _id: 1,
    name: 'Alice',
    age: 28,
    city: 'Toronto',
    email: 'alice@example.com',
    hobbies: [ 'reading', 'chess', 'hiking' ],
    address: { street: '12 King St', city: 'Toronto', zip: 'M5H' }
  },
  {
    _id: 7,
    name: 'Grace',
    age: '47',
    city: 'Toronto',
    email: 'GRACE@EXAMPLE.COM',
    hobbies: [ 'Reading' ]
  }
]
{
  _id: 1,
  name: 'Alice',
  age: 28,
  city: 'Toronto',
  email: 'alice@example.com',
  hobbies: [ 'reading', 'chess', 'hiking' ],
  address: { street: '12 King St', city: 'Toronto', zip: 'M5H' }
}
null
What’s happening
  1. find({ city: "Toronto" }) matches Alice and Grace. The shell prints the cursor by iterating it, so you see an array-like list of both documents (and with more than 20 matches you'd get Type "it" for more).
  2. findOne with the same filter returns one plain document, Alice — no brackets around it. "First" here means the first one the query happened to find (with no index or sort, that's roughly insertion order); it is not guaranteed to stay the same.
  3. findOne({ city: "Paris" }) matches nothing and returns null. The equivalent find would return an empty cursor, which prints nothing. Code that uses findOne must handle null.
  4. Both take the same arguments: filter first, projection second. So the next topic's projections work identically on both.
AdvancedCorrecting my notes: findOne and projections
AdvancedDriving a cursor by hand

You can also drive a cursor by hand, one document at a time:

mongosh
const cur = db.users.find({ age: { $gt: 40 } }, { name: 1 })
cur.hasNext()
cur.next()
cur.toArray()
cur.hasNext()
Result
true
{ _id: 2, name: 'Bob' }
[ { _id: 3, name: 'Chandra' }, { _id: 5, name: 'Elif' } ]
false
What’s happening
  1. find builds the cursor but sends nothing yet. The query runs on the first fetch.
  2. hasNext() triggers it and reports true; next() returns the first match, Bob.
  3. toArray() drains the rest of the cursor — Chandra and Elif — into an array. Bob isn't repeated: a cursor only moves forward.
  4. After that, hasNext() is false: the cursor is exhausted. In Node the same patterns are await cursor.next(), for await (const doc of cursor) and await cursor.toArray(). Use toArray only when the result is known to be small; iterate when it might be large.

#Comparison operators

A bare value in a filter means equality: { age: 45 }. For anything else you use a query operator, a key starting with $, nested under the field: { age: { $gt: 40 } } reads "age greater than 40". The comparison operators are $eq, $ne, $gt, $gte, $lt, $lte, and the set operators $in and $nin.

The mental model: a filter document is a template. Field names pick where to look; plain values say "must equal this"; $ operators say "must satisfy this test". Several operators on the same field are combined with AND: { age: { $gte: 30, $lt: 50 } } is a range.

One rule surprises SQL people: comparisons are type-aware. $gt: 40 only considers values that are numbers (int, long, double, decimal). A string "47" is not greater than 40 — it isn't compared at all. This is called type bracketing.

mongosh
db.users.find({ age: { $gt: 40 } }, { _id: 0, name: 1, age: 1 })
Result
[
  { name: 'Bob', age: 45 },
  { name: 'Chandra', age: 52 },
  { name: 'Elif', age: 41 }
]
What’s happening
  1. The filter { age: { $gt: 40 } } asks for users whose age is greater than 40 — the example from my notes.
  2. Bob (45), Chandra (52) and Elif (41) qualify. Alice (28), Dana (35) and Farid (19) are below 40.
  3. Grace is missing even though she is 47, because her age is the string "47". A numeric comparison only matches numeric values, so strings are skipped silently — no error, no warning.
  4. The projection { _id: 0, name: 1, age: 1 } keeps the output to the two fields we care about (Projections covers it).
  5. If your data might contain mixed types, find them first with $type and fix them; a schema validator stops new ones arriving.
AdvancedRanges, $in and $nin
mongosh
db.users.find({ age: { $gte: 30, $lte: 45 } }, { _id: 0, name: 1, age: 1 })
db.users.find({ city: { $in: ["New York", "Los Angeles"] } }, { _id: 0, name: 1, city: 1 })
db.users.find({ city: { $nin: ["Toronto", "London"] } }, { _id: 0, name: 1, city: 1 })
Result
[
  { name: 'Bob', age: 45 },
  { name: 'Dana', age: 35 },
  { name: 'Elif', age: 41 }
]
[
  { name: 'Bob', city: 'New York' },
  { name: 'Elif', city: 'Los Angeles' }
]
[
  { name: 'Bob', city: 'New York' },
  { name: 'Chandra', city: 'Mumbai' },
  { name: 'Elif', city: 'Los Angeles' },
  { name: 'Farid', city: 'Dubai' }
]
What’s happening
  1. Two operators on one field are ANDed, so { $gte: 30, $lte: 45 } is an inclusive range, like SQL's BETWEEN 30 AND 45: Bob (45), Dana (35) and Elif (41), in _id order because nothing asked for a sort.
  2. $in takes an array and matches any listed value — SQL's IN (...). New York and Los Angeles give Bob and Elif. It's shorter and usually faster than an $or of equalities on the same field.
  3. $nin ("not in") excludes the listed values: everyone outside Toronto and London — Bob, Chandra, Elif, Farid.
  4. $nin and $ne also match documents where the field is missing. Every user here has a city, so it doesn't show; the next box does.
AdvancedEdge case: $ne matches missing fields
AdvancedComparing strings and dates

Strings compare character by character by their bytes (unless you specify a collation), and dates compare chronologically:

mongosh
db.users.find({ name: { $lt: "C" } }, { _id: 0, name: 1 })
db.movies.find({ year: { $gte: 2010 } }, { _id: 0, title: 1, year: 1 })
Result
[ { name: 'Alice' }, { name: 'Bob' } ]
[
  { title: 'Inception', year: 2010 },
  { title: 'Dune', year: 2021 },
  { title: 'Mad Max: Fury Road', year: 2015 }
]
What’s happening
  1. name < "C" is a string comparison: "Alice" and "Bob" sort before "C", and "Chandra" doesn't, because "Chandra" is longer than "C" and starts with it, so it compares greater.
  2. year >= 2010 is numeric: Inception (2010), Dune (2021) and Mad Max (2015).
  3. For real dates you compare BSON dates the same way: { createdAt: { $gte: ISODate("2025-01-01") } }. Don't store dates as strings unless they are ISO-8601 and you only ever compare them as text.

#Logical operators: $and, $or, $not and $ne

A filter with several fields is already an AND: { city: "Toronto", age: { $gt: 30 } } means both must hold. The logical operators handle everything else: $or (any of an array of conditions), $and (all of them, written explicitly), $nor (none of them) and $not (negate one operator expression on a field). $ne, from the previous topic, is the simple "not equal".

Mental model: $and, $or and $nor take an array of complete filters and sit at the top level, like SQL's AND/OR between whole conditions. $not is different: it sits under a field and flips one test on that field.

mongosh
db.users.find(
  { $or: [ { city: "Toronto" }, { age: { $gt: 50 } } ] },
  { _id: 0, name: 1, city: 1, age: 1 }
)
Result
[
  { name: 'Alice', age: 28, city: 'Toronto' },
  { name: 'Chandra', age: 52, city: 'Mumbai' },
  { name: 'Grace', age: '47', city: 'Toronto' }
]
What’s happening
  1. $or takes an array of two complete filters. A document matches if either is true.
  2. { city: "Toronto" } matches Alice and Grace. { age: { $gt: 50 } } matches Chandra.
  3. The result is the union: Alice, Chandra and Grace, each once, in the order the scan found them.
  4. Grace is included because of her city; her string age doesn't matter here, because the age condition isn't needed for her.
AdvancedWhen you need an explicit $and

The implicit AND of a filter document can't express two conditions on the same key, because a document (and a JavaScript object) can't have the same key twice. Two $or groups are the usual case:

mongosh
db.users.find(
  { $and: [
      { $or: [ { city: "Toronto" }, { city: "New York" } ] },
      { $or: [ { age: { $lt: 30 } }, { hobbies: "chess" } ] }
  ] },
  { _id: 0, name: 1, city: 1, age: 1, hobbies: 1 }
)
Result
[
  {
    name: 'Alice',
    age: 28,
    city: 'Toronto',
    hobbies: [ 'reading', 'chess', 'hiking' ]
  },
  {
    name: 'Bob',
    age: 45,
    city: 'New York',
    hobbies: [ 'golf', 'chess' ]
  }
]
What’s happening
  1. We want "(Toronto or New York) and (under 30 or plays chess)". Both groups are $or, so writing them side by side in one document would need the key $or twice — the second would silently overwrite the first.
  2. $and with an array keeps them separate. The first group admits Alice, Bob and Grace (by city).
  3. The second group then keeps Alice (28, and plays chess) and Bob (plays chess). Grace fails both: her age is a string, so $lt: 30 doesn't match it, and her only hobby is "Reading".
  4. Result: Alice and Bob. This is the "Update with $and" idea in my notes too: the same filter shape works in updateMany (updateOne and updateMany with $set).
Advanced$not, and the easier $ne

My notes' example: "find all products where category is not Electronics", first with $not, then with the easier $ne:

mongosh
db.products.find({ category: { $not: { $eq: "Electronics" } } }, { _id: 0, name: 1, category: 1 })
db.products.find({ category: { $ne: "Electronics" } }, { _id: 0, name: 1, category: 1 })
db.products.find({ category: { $not: "Electronics" } })
Result
[
  { name: 'SQL Performance Explained', category: 'Books' },
  { name: 'Designing Data-Intensive Applications', category: 'Books' }
]
[
  { name: 'SQL Performance Explained', category: 'Books' },
  { name: 'Designing Data-Intensive Applications', category: 'Books' }
]
MongoServerError[BadValue]: $not argument must be a regex or an object
What’s happening
  1. $not wraps an operator expression — here { $eq: "Electronics" } — and negates it. Both books come back.
  2. $ne: "Electronics" says the same thing directly, with the same result. For a single value, prefer $ne; my notes' "there is an easier way as well" is right.
  3. $not with a plain value is an error: $not argument must be a regex or an object. It needs something to negate: an operator document like { $gt: 5 } or a regex like /^E/.
  4. Where $not earns its keep is negating ranges and patterns: { price: { $not: { $gt: 100 } } } ("not more than 100", which also includes documents with no price) and { name: { $not: /^Mac/ } }.
Advanced$nor: none of the conditions

$nor is "none of these": a document matches only if every condition in the array is false.

mongosh
db.users.find({ $nor: [ { city: "Toronto" }, { age: { $gte: 40 } } ] }, { _id: 0, name: 1, city: 1, age: 1 })
Result
[
  { name: 'Dana', age: 35, city: 'London' },
  { name: 'Farid', age: 19, city: 'Dubai' }
]
What’s happening
  1. Excluded: anyone in Toronto (Alice, Grace) and anyone aged 40 or more (Bob, Chandra, Elif).
  2. Left: Dana (London, 35) and Farid (Dubai, 19).
  3. Like $ne, $nor also matches documents where the fields are missing, since a condition on a missing field is false.
Predict the result: the same key twice
mongosh
db.users.find({ age: { $gt: 30 }, age: { $lt: 40 } }, { _id: 0, name: 1, age: 1 })
Show answer

It returns Alice (28), Dana (35) and Farid (19) — everyone under 40 — not "between 30 and 40".

mongosh
db.users.find({ age: { $gt: 30 }, age: { $lt: 40 } }, { _id: 0, name: 1, age: 1 })
Result
[
  { name: 'Alice', age: 28 },
  { name: 'Dana', age: 35 },
  { name: 'Farid', age: 19 }
]
What’s happening
  1. The filter is a JavaScript object literal, and an object can't have two age keys. JavaScript keeps the last one, so the object the shell sends is just { age: { $lt: 40 } }.
  2. MongoDB never sees $gt: 30 at all, and no error is raised anywhere.
  3. age < 40 matches Alice, Dana and Farid (Grace's string age is skipped).
  4. The fix is to put both operators in one object, { age: { $gt: 30, $lt: 40 } } (Dana only), or to use $and with two separate filters.

#Projections

The second argument to find and findOne is the projection: which fields to return. It's MongoDB's SELECT column list. You write either an inclusion projection ({ name: 1, age: 1 } — only these) or an exclusion projection ({ email: 0 } — everything except these). Returning fewer fields means less data over the network and, with the right index, a query that never touches the documents at all (Covered queries).

The rule my notes underline: _id is always returned unless you set it to 0. It's the one field with a default of "included", and the only field you may exclude inside an inclusion projection. Mental model: the projection is a stencil laid over each document — holes for the fields you want — except _id has its own hole punched in every stencil until you tape it over.

mongosh
db.users.find({ age: { $gt: 40 } }, { name: 1, age: 1 })
db.users.find({ age: { $gt: 40 } }, { _id: 0, name: 1, age: 1 })
db.users.find({ age: { $gt: 40 } }, { hobbies: 0, address: 0, email: 0 })
Result
[
  { _id: 2, name: 'Bob', age: 45 },
  { _id: 3, name: 'Chandra', age: 52 },
  { _id: 5, name: 'Elif', age: 41 }
]
[
  { name: 'Bob', age: 45 },
  { name: 'Chandra', age: 52 },
  { name: 'Elif', age: 41 }
]
[
  { _id: 2, name: 'Bob', age: 45, city: 'New York' },
  { _id: 3, name: 'Chandra', age: 52, city: 'Mumbai' },
  { _id: 5, name: 'Elif', age: 41, city: 'Los Angeles' }
]
What’s happening
  1. The inclusion projection { name: 1, age: 1 } returns those two fields plus _id, which we didn't ask for — exactly what my notes warn about.
  2. Adding _id: 0 removes it. As my notes say, "you don't have to do that for any other field": in an inclusion projection, every field you don't list is already left out.
  3. The exclusion projection { hobbies: 0, address: 0, email: 0 } returns everything else: _id, name, age and city. Elif has no email to exclude, which is fine.
  4. Which to choose: inclusion when you know exactly what you need (the usual case in APIs, and stable when new fields are added later); exclusion when you want "everything but the big field".
AdvancedMixing inclusion and exclusion fails
mongosh
db.users.find({}, { name: 1, email: 0 })
Result
MongoServerError[Location31254]: Cannot do exclusion on field email in inclusion projection
What’s happening
  1. name: 1 makes this an inclusion projection, and email: 0 then asks to exclude a field from it.
  2. The server rejects it: Cannot do exclusion on field email in inclusion projection. It isn't meaningful — in an inclusion projection, email is already excluded because it isn't listed.
  3. _id is the only exception: { _id: 0, name: 1 } is allowed because _id is included by default.
AdvancedNested fields and $slice

Dot notation projects fields inside embedded documents, and $slice returns part of an array — my notes' example is "the first 2 comments":

mongosh
db.users.find({ _id: 1 }, { _id: 0, name: 1, "address.city": 1 })
db.movies.find({ _id: 1 }, { _id: 0, title: 1, comments: { $slice: 2 } })
db.movies.find({ _id: 1 }, { _id: 0, title: 1, comments: { $slice: -1 } })
db.movies.find({ _id: 1 }, { _id: 0, title: 1, comments: { $slice: [1, 1] } })
Result
[ { name: 'Alice', address: { city: 'Toronto' } } ]
[
  {
    title: 'Inception',
    comments: [
      { user: 'amy', text: 'Mind-bending' },
      { user: 'raj', text: 'Loved the score' }
    ]
  }
]
[
  { title: 'Inception', comments: [ { user: 'lee', text: 'Too long' } ] }
]
[
  {
    title: 'Inception',
    comments: [ { user: 'raj', text: 'Loved the score' } ]
  }
]
What’s happening
  1. "address.city": 1 returns the address object with only city inside it: { name: 'Alice', address: { city: 'Toronto' } }. The shape is kept; the other sub-fields are dropped.
  2. comments: { $slice: 2 } returns the first two comments of Inception (amy's and raj's) and leaves out lee's.
  3. $slice: -1 counts from the end: just the last comment, lee's "Too long".
  4. $slice: [1, 1] is [skip, limit]: skip one, take one — raj's comment. This is how you page through a long embedded array.
  5. $slice doesn't decide on its own whether the projection includes or excludes: here title: 1 makes it an inclusion projection, so the result is just title plus the sliced comments. With { comments: { $slice: 2 } } alone, every field comes back and only comments is trimmed.
AdvancedThe $elemMatch and positional $ projections

To return only the matching elements of an array instead of the whole array:

mongosh
db.products.find({ _id: 2 }, { _id: 0, name: 1, reviews: { $elemMatch: { reviewer: "Alice" } } })
db.products.find({ "reviews.rating": 5 }, { _id: 0, name: 1, "reviews.$": 1 })
Result
[
  {
    name: 'MacBook Air',
    reviews: [ { reviewer: 'Alice', rating: 3, comment: 'Too few ports' } ]
  }
]
[
  {
    name: 'ThinkBook 14',
    reviews: [ { reviewer: 'Alice', rating: 5, comment: 'Great keyboard' } ]
  },
  {
    name: 'MacBook Air',
    reviews: [ { reviewer: 'Bob', rating: 5, comment: 'Silent and fast' } ]
  },
  {
    name: 'SQL Performance Explained',
    reviews: [
      { reviewer: 'Chandra', rating: 5, comment: 'Must read for indexes' }
    ]
  },
  {
    name: 'Designing Data-Intensive Applications',
    reviews: [ { reviewer: 'Alice', rating: 5 } ]
  }
]
What’s happening
  1. reviews: { $elemMatch: { reviewer: "Alice" } } in the projection keeps only the first array element that matches: Alice's 3-star review of the MacBook Air. Bob's review is dropped from the output (not from the document).
  2. The positional "reviews.$": 1 means "the first element that the query filter matched". The filter { "reviews.rating": 5 } matches four products, and each comes back with just its first 5-star review.
  3. Both return at most one element per document. To return every matching element, use an aggregation with $filter (Expressions and $addFields).
  4. Don't confuse these with the $elemMatch query operator (Querying arrays) or the positional $ update operator (The positional $ operator): same symbols, different jobs.
AdvancedComputed fields in a projection (4.4+)

Since MongoDB 4.4, a find projection can also compute new fields with aggregation expressions:

mongosh
db.users.find(
  { age: { $type: "number" } },
  { _id: 0, name: 1, ageNextYear: { $add: ["$age", 1] }, hobbyCount: { $size: { $ifNull: ["$hobbies", []] } } }
).limit(3)
Result
[
  { name: 'Alice', ageNextYear: 29, hobbyCount: 3 },
  { name: 'Bob', ageNextYear: 46, hobbyCount: 2 },
  { name: 'Chandra', ageNextYear: 53, hobbyCount: 3 }
]
What’s happening
  1. The filter { age: { $type: "number" } } skips Grace, whose age is a string ($exists and $type).
  2. ageNextYear: { $add: ["$age", 1] } computes a field that doesn't exist in the document: "$age" means "the value of age".
  3. hobbyCount uses $size to count the array, wrapped in $ifNull so that a user without a hobbies field (Farid) would count as 0 instead of raising an error.
  4. For anything more involved — renaming many fields, reshaping — the aggregation pipeline's $project stage is the tool ($match and $project).

#Querying arrays: $size, $all and $elemMatch

Arrays are where MongoDB queries differ most from SQL. The key rule: a condition on an array field is tested against each element, and the document matches if any element satisfies it. { hobbies: "chess" } means "has chess among its hobbies", not "hobbies equals chess". That one rule makes most array queries read naturally, and it also explains the classic bug with ranges, which $elemMatch fixes.

The array-specific operators: $size (the array has exactly N elements), $all (contains every listed value, in any order) and $elemMatch (a single element satisfies all the conditions together). Mental model: the array is a bag of marbles. A plain condition asks "is there a marble like this in the bag?"; $all asks "are all of these marbles in the bag?"; $elemMatch asks "is there one marble that is both red and large?"

mongosh
db.users.find({ hobbies: "chess" }, { _id: 0, name: 1, hobbies: 1 })
db.users.find({ hobbies: { $size: 3 } }, { _id: 0, name: 1, hobbies: 1 })
Result
[
  { name: 'Alice', hobbies: [ 'reading', 'chess', 'hiking' ] },
  { name: 'Bob', hobbies: [ 'golf', 'chess' ] },
  { name: 'Chandra', hobbies: [ 'cricket', 'cooking', 'chess' ] }
]
[
  { name: 'Alice', hobbies: [ 'reading', 'chess', 'hiking' ] },
  { name: 'Chandra', hobbies: [ 'cricket', 'cooking', 'chess' ] }
]
What’s happening
  1. { hobbies: "chess" } is tested against each element of each hobbies array: Alice, Bob and Chandra each have "chess" somewhere. Farid has no hobbies field and Dana's array is empty, so neither matches.
  2. { hobbies: { $size: 3 } } — "find document with array size of hobbies as 3" from my notes — matches arrays with exactly three elements: Alice and Chandra.
  3. $size counts elements; it doesn't look inside them. And it only takes an exact number (next box).
Advanced$size only takes an exact number
mongosh
db.users.find({ hobbies: { $size: { $gt: 1 } } })
db.users.find({ "hobbies.1": { $exists: true } }, { _id: 0, name: 1, hobbies: 1 })
Result
MongoServerError[BadValue]: Failed to parse $size. Expected a number in: $size: { $gt: 1 }
[
  { name: 'Alice', hobbies: [ 'reading', 'chess', 'hiking' ] },
  { name: 'Bob', hobbies: [ 'golf', 'chess' ] },
  { name: 'Chandra', hobbies: [ 'cricket', 'cooking', 'chess' ] }
]
What’s happening
  1. A range inside $size is rejected: Failed to parse $size. Expected a number in: $size: { $gt: 1 }.
  2. The idiom for "at least two elements" is to ask whether index 1 exists: "hobbies.1" is the second element (indexes start at 0). Alice, Bob and Chandra have one.
  3. Alternatives: { $expr: { $gt: [{ $size: "$hobbies" }, 1] } } (fails on documents where hobbies is missing unless you wrap it in $ifNull), or store a hobbyCount field and index it if you query this often.
Advanced$size and $all on movies

My notes: movies with 2 genres, and movies whose genres include both thriller and action:

mongosh
db.movies.find({ genres: { $size: 2 } }, { _id: 0, title: 1, genres: 1 })
db.movies.find({ genres: { $all: ["thriller", "action"] } }, { _id: 0, title: 1, genres: 1 })
db.movies.find({ genres: ["thriller", "action"] }, { _id: 0, title: 1 })
Result
[
  { title: 'Heat', genres: [ 'action', 'thriller' ] },
  { title: 'Amelie', genres: [ 'comedy', 'romance' ] },
  { title: 'Up', genres: [ 'animation', 'comedy' ] },
  { title: 'Dune', genres: [ 'sci-fi', 'adventure' ] },
  { title: 'Mad Max: Fury Road', genres: [ 'action', 'adventure' ] }
]
[
  { title: 'Inception', genres: [ 'action', 'thriller', 'sci-fi' ] },
  { title: 'Heat', genres: [ 'action', 'thriller' ] }
]
What’s happening
  1. $size: 2 matches the five films with exactly two genres; Inception (three) and the 1984 Dune (one) are left out.
  2. $all: ["thriller", "action"] matches arrays that contain both values, in any order and alongside anything else: Inception (action, thriller, sci-fi) and Heat (action, thriller).
  3. Writing the array directly, { genres: ["thriller", "action"] }, is an exact match of the whole array — same elements, same order, nothing else. No film stores exactly ["thriller", "action"] (Heat has them the other way round), so nothing comes back.
  4. Rule of thumb: an array literal in a filter means "equals this array"; $all means "contains these".
Advanced$elemMatch on ratings between 8 and 10

My notes: "ratings greater than 8 and less than 10". With and without $elemMatch:

mongosh
db.movies.find({ ratings: { $gt: 8, $lt: 10 } }, { _id: 0, title: 1, ratings: 1 })
db.movies.find({ ratings: { $elemMatch: { $gt: 8, $lt: 10 } } }, { _id: 0, title: 1, ratings: 1 })
Result
[
  { title: 'Inception', ratings: [ 9, 8, 10 ] },
  { title: 'Amelie', ratings: [ 9, 7 ] },
  { title: 'Up', ratings: [ 7, 10, 6 ] },
  { title: 'Dune', ratings: [ 8, 9 ] },
  { title: 'Mad Max: Fury Road', ratings: [ 10, 9, 8 ] }
]
[
  { title: 'Inception', ratings: [ 9, 8, 10 ] },
  { title: 'Amelie', ratings: [ 9, 7 ] },
  { title: 'Dune', ratings: [ 8, 9 ] },
  { title: 'Mad Max: Fury Road', ratings: [ 10, 9, 8 ] }
]
What’s happening
  1. Without $elemMatch, each condition is tested against the array separately: "some element is > 8" and "some element is < 10" — possibly different elements.
  2. Up has ratings [7, 10, 6]. The 10 satisfies > 8 and the 7 satisfies < 10, so Up matches the first query even though no single rating is between 8 and 10.
  3. With $elemMatch, one element must pass both tests. Only a 9 qualifies, so the result is the films with a 9: Inception, Amelie, Dune (2021) and Mad Max — no Up.
  4. Heat ([8, 7]) is in neither result: no element is greater than 8.
Advanced$elemMatch on arrays of embedded reviews

The same trap with embedded documents is the one interviewers love. My notes' example: Electronics products with a review by Alice with a rating of at least 4:

mongosh
db.products.find(
  { category: "Electronics", "reviews.reviewer": "Alice", "reviews.rating": { $gte: 4 } },
  { _id: 0, name: 1 }
)
db.products.find(
  { category: "Electronics", reviews: { $elemMatch: { reviewer: "Alice", rating: { $gte: 4 } } } },
  { _id: 0, name: 1 }
)
Result
[
  { name: 'ThinkBook 14' },
  { name: 'MacBook Air' },
  { name: 'Pixel 10' },
  { name: 'USB-C Charger' }
]
[ { name: 'ThinkBook 14' }, { name: 'Pixel 10' } ]
What’s happening
  1. The first query uses dot notation for each condition separately: "some review is by Alice" and "some review has rating ≥ 4".
  2. It returns four products, including the MacBook Air (Alice gave 3, Bob gave 5) and the USB-C Charger (Alice gave 2, Elif gave 4). Each condition is satisfied, but by different reviews.
  3. $elemMatch requires one review to satisfy both: by Alice and rated ≥ 4. Only the ThinkBook 14 (Alice: 5) and the Pixel 10 (Alice: 4) qualify.
  4. The rule: when several conditions must apply to the same array element, use $elemMatch. With a single condition ({ "reviews.reviewer": "Alice" }), dot notation and $elemMatch are equivalent.
Spot the bug: a 5-star review with a comment
mongosh
// Intention: products where ONE review is 5 stars AND has a comment
db.products.find({ "reviews.rating": 5, "reviews.comment": { $exists: true } }, { _id: 0, name: 1 })
Show answer

It also returns Designing Data-Intensive Applications, whose 5-star review has no comment.

mongosh
db.products.find({ "reviews.rating": 5, "reviews.comment": { $exists: true } }, { _id: 0, name: 1 })
db.products.find({ reviews: { $elemMatch: { rating: 5, comment: { $exists: true } } } }, { _id: 0, name: 1 })
Result
[
  { name: 'ThinkBook 14' },
  { name: 'MacBook Air' },
  { name: 'SQL Performance Explained' },
  { name: 'Designing Data-Intensive Applications' }
]
[
  { name: 'ThinkBook 14' },
  { name: 'MacBook Air' },
  { name: 'SQL Performance Explained' }
]
What’s happening
  1. The two dot-notation conditions are tested independently: "some review is rated 5" and "some review has a comment".
  2. The ThinkBook, the MacBook and the SQL book each have a 5-star review with a comment, so they are correct matches.
  3. Designing Data-Intensive Applications slips in because Alice's review is the 5-star one (no comment) and Dana's 4-star review is the one with a comment. Each condition is met, by a different review.
  4. $elemMatch applies both conditions to the same review, and the book drops out: three products instead of four.

#Querying embedded documents

An embedded document is a document inside a field: address: { city: "Toronto", zip: "M5H" }. You query into it with dot notation: { "address.city": "Toronto" }. The quotes are required, because a key containing a dot isn't a valid bare JavaScript identifier.

There's a second, rarely-wanted form: { address: { city: "Toronto", zip: "M5H" } }, which compares the whole embedded document for exact equality — every field, the same values, in the same order, nothing extra. Mental model: dot notation opens the envelope and reads one line; the whole-document form compares the sealed envelope against another sealed envelope, byte for byte.

mongosh
db.users.find({ "address.city": "Toronto" }, { _id: 0, name: 1, address: 1 })
db.users.find({ address: { city: "Toronto" } }, { _id: 0, name: 1 })
db.users.find({ address: { city: "New York", zip: "10001" } }, { _id: 0, name: 1 })
db.users.find({ address: { zip: "10001", city: "New York" } }, { _id: 0, name: 1 })
Result
[
  {
    name: 'Alice',
    address: { street: '12 King St', city: 'Toronto', zip: 'M5H' }
  }
]

[ { name: 'Bob' } ]
What’s happening
  1. "address.city": "Toronto" reaches inside address and matches Alice. Grace lives in Toronto too, but she has no address object — only a top-level city — so she doesn't match.
  2. { address: { city: "Toronto" } } asks for an address that is exactly { city: "Toronto" }. Alice's address also has street and zip, so it isn't equal, and nothing matches.
  3. { address: { city: "New York", zip: "10001" } } is exactly Bob's address, fields in the stored order, so Bob matches.
  4. The same two fields in the other order match nothing: embedded-document equality is order-sensitive, because BSON documents are ordered. This is why you almost always want dot notation.
AdvancedDot notation through arrays of documents

Dot notation also walks through arrays: "reviews.reviewer" means "the reviewer field of any element of reviews":

mongosh
db.products.find({ "reviews.reviewer": "Chandra" }, { _id: 0, name: 1 })
db.products.find({ "reviews.0.reviewer": "Bob" }, { _id: 0, name: 1 })
db.products.find({ reviews: { reviewer: "Bob", rating: 4 } }, { _id: 0, name: 1 })
Result
[ { name: 'SQL Performance Explained' } ]
[ { name: 'MacBook Air' }, { name: 'Noise-cancelling Headphones' } ]
[ { name: 'Noise-cancelling Headphones' } ]
What’s happening
  1. "reviews.reviewer": "Chandra" checks every review of every product: only the SQL book has one.
  2. A number in the path is an array index: "reviews.0.reviewer" is "the first review is by Bob" — the MacBook Air and the headphones.
  3. { reviews: { reviewer: "Bob", rating: 4 } } is whole-document equality against each element: the headphones' only review is exactly { reviewer: 'Bob', rating: 4 }, so it matches. A review with a comment added wouldn't.
  4. For "an element with these fields, regardless of other fields", use $elemMatch (previous topic).

#$regex

$regex matches strings against a regular expression — MongoDB's LIKE, but more powerful. In mongosh you can write a JavaScript regex literal directly, { name: /^ch/i }, or the operator form, { name: { $regex: "^ch", $options: "i" } }, which is what you build from user input or send from a driver as a string. Common options: i (case-insensitive), m (multi-line anchors), x (extended, ignore whitespace) and s (dot matches newlines).

Mental model: a regex query is a text search done the slow way — the server has to test the pattern against each candidate string. It can use an index efficiently only when the pattern is a case-sensitive prefix (/^Ch/), because then the matching values sit together in the index's sorted order, like looking up words starting with "Ch" in a dictionary. Anything else means scanning.

mongosh
db.users.find({ name: /^ch/i }, { _id: 0, name: 1 })
db.users.find({ email: { $regex: "@example\\.com$", $options: "i" } }, { _id: 0, name: 1, email: 1 })
db.users.find({ hobbies: /^read/i }, { _id: 0, name: 1, hobbies: 1 })
Result
[ { name: 'Chandra' } ]
[
  { name: 'Alice', email: 'alice@example.com' },
  { name: 'Bob', email: 'bob@example.com' },
  { name: 'Chandra', email: 'chandra@example.com' },
  { name: 'Farid', email: 'farid@example.com' },
  { name: 'Grace', email: 'GRACE@EXAMPLE.COM' }
]
[
  { name: 'Alice', hobbies: [ 'reading', 'chess', 'hiking' ] },
  { name: 'Grace', hobbies: [ 'Reading' ] }
]
What’s happening
  1. /^ch/i — "starts with ch, any case" — matches Chandra. Without the i flag, ^ch would match nothing, since the names are capitalised.
  2. The operator form takes the pattern as a string, so the backslash that escapes the dot has to be doubled in JavaScript ("\\."). It matches every email ending in @example.com in any case — including Grace's upper-case GRACE@EXAMPLE.COM. Dana (null) and Elif (no field) aren't strings and don't match.
  3. A regex on an array of strings tests each element, just like equality: Alice ("reading") and Grace ("Reading") both match /^read/i. My notes say "regex works when the array has just strings" — that part is right.
AdvancedCorrecting my notes: regex on objects in arrays
AdvancedWhich regexes can use an index

To see the difference, index name and compare how many index keys each query examines:

mongosh
db.users.createIndex({ name: 1 })
db.users.find({ name: /^Ch/ }).explain("executionStats").executionStats.totalKeysExamined
db.users.find({ name: /^ch/i }).explain("executionStats").executionStats.totalKeysExamined
db.users.find({ name: /and/ }).explain("executionStats").executionStats.totalKeysExamined
Result
name_1
2
7
7
What’s happening
  1. createIndex returns the index's name, name_1.
  2. /^Ch/ is a case-sensitive prefix: the server turns it into an index range ("Ch" up to just before "Ci") and examines only 2 keys: "Chandra", and the next key, which shows the range has ended.
  3. /^ch/i is case-insensitive, so matches could be anywhere in the sorted index (Ch…, ch…, CH…). The server has to read every key — 7, one per user — and test each.
  4. /and/ has no anchor, so it also reads all 7 keys. On seven documents none of this matters; on seven million it's the difference between milliseconds and seconds. (The index was dropped again afterwards, so the rest of the chapter runs without it.)
  5. For case-insensitive search at scale, use an index with a case-insensitive collation and an equality query, store a lower-cased copy of the field, or use a text index or Atlas Search (Text indexes and Atlas Search).
AdvancedPitfall: user input in a regex

#$exists and $type

Added

MongoDB has two kinds of "nothing": a field can be missing from the document, or present with the value null. SQL has only NULL. Most of the time you can ignore the difference, but it matters for data cleaning, for sparse and partial indexes, and for understanding why { email: null } returns more than you expected.

$exists: true/false tests whether the field is present at all, whatever its value. $type tests the field's BSON type, by name ("string", "int", "double", "null", "array"…) or with the alias "number" for any numeric type. Mental model: $exists asks "is there a box with this label?"; $type asks "what kind of thing is in the box?"; and { field: null } asks "is the box empty or missing?"

mongosh
db.users.find({ email: null }, { _id: 0, name: 1, email: 1 })
db.users.find({ email: { $exists: false } }, { _id: 0, name: 1 })
db.users.find({ email: { $type: "null" } }, { _id: 0, name: 1 })
db.users.countDocuments({ email: { $exists: true } })
Result
[ { name: 'Dana', email: null }, { name: 'Elif' } ]
[ { name: 'Elif' } ]
[ { name: 'Dana' } ]
6
What’s happening
  1. { email: null } matches both Dana (email: null) and Elif (no email field). Equality with null deliberately covers "missing" too.
  2. { email: { $exists: false } } matches only documents without the field: Elif.
  3. { email: { $type: "null" } } matches only a stored null: Dana.
  4. { email: { $exists: true } } counts every user that has the field, including Dana's null: 6 of 7. "Exists" is about the key, not the value; for "has a real email" use { email: { $exists: true, $ne: null } } or { email: { $type: "string" } }.
AdvancedFinding wrong types with $type

$type is the tool for data cleaning — like finding Grace's string age:

mongosh
db.users.find({ age: { $type: "string" } }, { _id: 0, name: 1, age: 1 })
db.users.countDocuments({ age: { $type: "number" } })
db.users.find({ age: { $not: { $type: "number" } } }, { _id: 0, name: 1, age: 1 })
db.users.aggregate([{ $group: { _id: { $type: "$age" }, n: { $sum: 1 } } }, { $sort: { _id: 1 } }])
Result
[ { name: 'Grace', age: '47' } ]
6
[ { name: 'Grace', age: '47' } ]
[ { _id: 'int', n: 6 }, { _id: 'string', n: 1 } ]
What’s happening
  1. $type: "string" finds Grace, whose "47" came from a CSV.
  2. $type: "number" matches int, long, double and decimal: the other 6 users.
  3. $not: { $type: "number" } is the safer cleaning query: it finds every non-numeric age, including any document where age is missing (none here, but in real data there usually are some).
  4. Grouping by { $type: "$age" } gives a type census of the field: 6 int, 1 string. Run this on a field before trusting comparisons on it. The fix itself is an update (Update pipelines shows converting the string with $toInt).
AdvancedEdge case: $exists on array positions

$exists also works on a path into an array, which gives "has at least N elements" (Querying arrays) and "has no elements":

mongosh
db.users.find({ "hobbies.0": { $exists: false } }, { _id: 0, name: 1, hobbies: 1 })
db.users.find({ hobbies: { $exists: true, $size: 0 } }, { _id: 0, name: 1 })
Result
[ { name: 'Dana', hobbies: [] }, { name: 'Farid' } ]
[ { name: 'Dana' } ]
What’s happening
  1. "hobbies.0" doesn't exist when the array is empty or the field is missing: Dana ([]) and Farid (no hobbies).
  2. { $exists: true, $size: 0 } narrows it to "has the field, and it's empty": only Dana.
  3. Which one you want depends on whether "never set" and "set to nothing" mean different things in your application — which is the real question behind every missing-versus-null decision.

#$expr: comparing fields

Normal query operators compare a field with a constant: { volume: { $gt: 180 } }. To compare two fields of the same document — "volume greater than target" — you need $expr, which lets the filter use aggregation expressions. Inside $expr, "$volume" means "this document's volume", and operators take an array of arguments: { $gt: ["$volume", "$target"] }.

Mental model: an ordinary filter is a form you hold up against each document; $expr is a little formula evaluated for each document that must come out true. Because it's a formula, you can compute on the way: subtract, multiply, branch with $cond, compare dates with $$NOW.

mongosh
db.sales.find({ $expr: { $gt: ["$volume", "$target"] } })
Result
[
  { _id: 1, item: 'laptops', volume: 200, target: 180 },
  { _id: 2, item: 'phones', volume: 195, target: 190 },
  { _id: 4, item: 'books', volume: 90, target: 80 },
  { _id: 5, item: 'headphones', volume: 190, target: 185 }
]
What’s happening
  1. For each document, $expr evaluates $gt with two arguments: the document's volume and its target — my notes' "find all records where volume is greater than target".
  2. laptops (200 > 180), phones (195 > 190), books (90 > 80) and headphones (190 > 185) are true; chargers (120 > 150) is false.
  3. Without $expr, { volume: { $gt: "$target" } } would compare volume with the literal string "$target" and, thanks to type bracketing, match nothing.
  4. In SQL this is simply WHERE volume > target; $expr is how MongoDB gets the same power.
AdvancedMy notes' $cond volume example

The "complex $expr" from my notes: if volume is at least 190, deduct 10 before comparing with the target:

mongosh
db.sales.find({ $expr: { $gt: [
  { $cond: { if: { $gte: ["$volume", 190] }, then: { $subtract: ["$volume", 10] }, else: "$volume" } },
  "$target"
] } })
Result
[
  { _id: 1, item: 'laptops', volume: 200, target: 180 },
  { _id: 4, item: 'books', volume: 90, target: 80 }
]
What’s happening
  1. $cond is an if/then/else expression evaluated per document. Its result becomes the first argument of $gt.
  2. laptops: 200 ≥ 190, so the adjusted volume is 190, and 190 > 180 is true.
  3. phones: 195 → 185, and 185 > 190 is false. headphones: 190 → 180, and 180 > 185 is false. These two beat their targets only by the margin the rule takes away.
  4. chargers: 120 < 190 so it stays 120, and 120 > 150 is false. books: 90 stays 90, and 90 > 80 is true.
  5. Result: laptops and books. The same expression would work unchanged inside an aggregation $match or $project, because it's the same expression language.
Advanced$expr with dates and $$NOW

Expressions can use variables like $$NOW, the current time on the server. A typical use is "overdue" checks against a field:

mongosh
db.tasks.insertMany([
  { _id: 1, title: "Renew domain", dueAt: ISODate("2025-01-01T00:00:00Z") },
  { _id: 2, title: "File taxes",   dueAt: ISODate("2099-04-30T00:00:00Z") }
]).insertedIds
db.tasks.find({ $expr: { $lt: ["$dueAt", "$$NOW"] } }, { _id: 0, title: 1 })
Result
{ '0': 1, '1': 2 }
[ { title: 'Renew domain' } ]
What’s happening
  1. $$NOW is evaluated once per operation on the server, so every document is compared with the same instant.
  2. "Renew domain" was due in 2025, before now, so it's overdue; "File taxes" in 2099 isn't.
  3. For a fixed comparison like this one, a plain { dueAt: { $lt: new Date() } } works too and can use an index more simply; $$NOW matters when the expression combines it with fields (dueAt + graceDays < now) or inside update pipelines.
AdvancedPitfall: $expr can't use field indexes

#Sorting

.sort({ field: 1 }) orders a cursor ascending; -1 is descending. Several keys sort by the first, then break ties with the next, like SQL's ORDER BY a, b DESC. My notes mark this topic "IMPORTANT", and it is: without a sort, MongoDB returns documents in no guaranteed order, so anything that shows "the first", "the latest" or a page of results needs an explicit sort.

Two MongoDB-specific rules. First, when values of different types are sorted together, MongoDB uses a fixed type order: null (and missing) first, then numbers, then strings, then objects, arrays, ObjectIds, booleans, dates and so on. Second, string sorting is by binary code point unless you specify a collation, so "Z" sorts before "a". Mental model: sort is a librarian with a strict shelving rule — by type first, then by value within the type — who doesn't know that "Grace" and "grace" are the same name unless you hand her a collation.

mongosh
db.users.find({}, { _id: 0, name: 1, age: 1 }).sort({ age: 1 })
Result
[
  { name: 'Farid', age: 19 },
  { name: 'Alice', age: 28 },
  { name: 'Dana', age: 35 },
  { name: 'Elif', age: 41 },
  { name: 'Bob', age: 45 },
  { name: 'Chandra', age: 52 },
  { name: 'Grace', age: '47' }
]
What’s happening
  1. "Find all users sorted by age ascending", from my notes. The numeric ages come out in order: Farid 19, Alice 28, Dana 35, Elif 41, Bob 45, Chandra 52.
  2. Grace comes last, after 52, even though her age is "47". Her age is a string, and in MongoDB's type order all numbers sort before all strings.
  3. With { age: -1 } the order flips: Grace first (strings are "bigger"), then 52, 45, 41…
  4. With no index on age, the server sorted in memory. That's fine here; for big collections an index on the sort key lets MongoDB read documents already in order (Single-field and compound indexes).
AdvancedMulti-key sorts with a projection

My notes' movies examples: a projection as the second argument sorted by title, and then "title ascending, then visitors descending":

mongosh
db.movies.find({}, { _id: 0, title: 1, visitors: 1 }).sort({ title: 1 })
db.movies.find({}, { _id: 0, title: 1, year: 1, visitors: 1 }).sort({ title: 1, visitors: -1 })
Result
[
  { title: 'Amelie', visitors: 500 },
  { title: 'Dune', visitors: 300 },
  { title: 'Dune', visitors: 1500 },
  { title: 'Heat', visitors: 800 },
  { title: 'Inception', visitors: 1200 },
  { title: 'Mad Max: Fury Road', visitors: 900 },
  { title: 'Up', visitors: 1200 }
]
[
  { title: 'Amelie', year: 2001, visitors: 500 },
  { title: 'Dune', year: 2021, visitors: 1500 },
  { title: 'Dune', year: 1984, visitors: 300 },
  { title: 'Heat', year: 1995, visitors: 800 },
  { title: 'Inception', year: 2010, visitors: 1200 },
  { title: 'Mad Max: Fury Road', year: 2015, visitors: 900 },
  { title: 'Up', year: 2009, visitors: 1200 }
]
What’s happening
  1. Sorting by title alone puts the two "Dune" films together, but their relative order is not guaranteed: MongoDB's sort is not stable, and ties come out in whatever order the sort produced them.
  2. Adding visitors: -1 as the second key breaks the tie deliberately: Dune (2021, 1500 visitors) before Dune (1984, 300).
  3. Key order in the sort document matters, and JavaScript preserves insertion order of string keys, so { title: 1, visitors: -1 } really means title first.
  4. For pagination you want a sort that is total — no ties at all — so add a unique field as the last key: .sort({ title: 1, visitors: -1, _id: 1 }). The official docs recommend exactly this.
AdvancedMissing fields, arrays and case in sorts
mongosh
db.users.find({}, { _id: 0, name: 1, email: 1 }).sort({ email: 1 })
db.users.find({}, { _id: 0, name: 1, email: 1 }).sort({ email: 1 }).collation({ locale: "en", strength: 2 })
db.movies.find({}, { _id: 0, title: 1, ratings: 1 }).sort({ ratings: -1 }).limit(3)
Result
[
  { name: 'Dana', email: null },
  { name: 'Elif' },
  { name: 'Grace', email: 'GRACE@EXAMPLE.COM' },
  { name: 'Alice', email: 'alice@example.com' },
  { name: 'Bob', email: 'bob@example.com' },
  { name: 'Chandra', email: 'chandra@example.com' },
  { name: 'Farid', email: 'farid@example.com' }
]
[
  { name: 'Dana', email: null },
  { name: 'Elif' },
  { name: 'Alice', email: 'alice@example.com' },
  { name: 'Bob', email: 'bob@example.com' },
  { name: 'Chandra', email: 'chandra@example.com' },
  { name: 'Farid', email: 'farid@example.com' },
  { name: 'Grace', email: 'GRACE@EXAMPLE.COM' }
]
[
  { title: 'Mad Max: Fury Road', ratings: [ 10, 9, 8 ] },
  { title: 'Up', ratings: [ 7, 10, 6 ] },
  { title: 'Inception', ratings: [ 9, 8, 10 ] }
]
What’s happening
  1. Sorting by email: Dana (null) and Elif (missing) come first — null and missing sort lowest and compare equal to each other. Then GRACE@EXAMPLE.COM, because upper-case letters have lower code points than lower-case ones, and only then alice@….
  2. With a collation of { locale: "en", strength: 2 } (compare letters, ignore case), Grace moves to her alphabetical place, last, after farid@…. Collation is also how you get language-correct ordering of accented characters.
  3. Sorting by an array uses one element per document as its key: the largest for a descending sort, the smallest for ascending. Inception, Up and Mad Max all contain a 10, so they lead the descending sort (in an unspecified order among themselves).
  4. Sorts that can't use an index must fit in 100 MB of memory or spill to disk, which they do by default since MongoDB 6.0; before 6.0 they failed unless you added allowDiskUse().
Predict the order
mongosh
db.mixed.insertMany([{ _id: 1, v: "10" }, { _id: 2, v: 9 }, { _id: 3, v: null }, { _id: 4 }, { _id: 5, v: 100 }])
db.mixed.find({}, { _id: 1 }).sort({ v: 1, _id: 1 })
Show answer

_id order: 3, 4, 2, 5, 1.

mongosh
db.mixed.insertMany([{ _id: 1, v: "10" }, { _id: 2, v: 9 }, { _id: 3, v: null }, { _id: 4 }, { _id: 5, v: 100 }]).insertedIds
db.mixed.find({}, { _id: 1 }).sort({ v: 1, _id: 1 })
Result
{ '0': 1, '1': 2, '2': 3, '3': 4, '4': 5 }
[ { _id: 3 }, { _id: 4 }, { _id: 2 }, { _id: 5 }, { _id: 1 } ]
What’s happening
  1. Type order comes first: null and missing are lowest, so _id: 3 (null) and _id: 4 (missing) lead. They tie on v, and the _id: 1 tiebreaker puts 3 before 4.
  2. Numbers come next, sorted numerically: 9 (_id: 2) then 100 (_id: 5).
  3. Strings come after every number, so "10" (_id: 1) is last — even though it "looks" smaller than 100.
  4. Lesson: if a field's type is inconsistent, sorting on it gives results that look wrong. Fix the types ($exists and $type).

#Pagination with skip and limit

.limit(n) returns at most n documents; .skip(n) discards the first n. Together they page through results: page p with size items per page is .skip((p - 1) * size).limit(size) — SQL's LIMIT … OFFSET …. My notes' example: "skip the first 2 entries, then limit to 1".

Two facts to keep in mind. The server always applies sort, then skip, then limit, no matter what order you chain them in. And skip is not free: to skip 10,000 documents the server still has to walk past 10,000 index entries or documents, so deep pages get slower and slower. Mental model: skip is reading a book by counting pages from the front every time; range-based ("keyset") pagination is keeping a bookmark.

mongosh
db.users.find({}, { _id: 0, name: 1, age: 1 }).sort({ age: 1 }).skip(2).limit(1)
db.users.find({}, { _id: 0, name: 1, age: 1 }).limit(1).skip(2).sort({ age: 1 })
Result
[ { name: 'Dana', age: 35 } ]
[ { name: 'Dana', age: 35 } ]
What’s happening
  1. Sort by age ascending gives Farid 19, Alice 28, Dana 35, Elif 41… (from the previous topic).
  2. skip(2) discards Farid and Alice; limit(1) keeps the next one: Dana.
  3. The second query chains the same methods in the opposite order and returns the same Dana. The cursor methods only record options; the server applies them as sort → skip → limit when the query runs.
  4. Without the sort, "skip 2" would skip whichever two documents happened to come first — and could return a different user on the next run. Always sort when you paginate, and make the sort total (end with _id) so ties don't shuffle between pages.
AdvancedWhy deep skips are slow: keyset pagination

Measured on a 1,000-document collection, fetching the last page of 10:

mongosh
const s = db.pages.find().sort({ _id: 1 }).skip(990).limit(10).explain("executionStats").executionStats
;({ nReturned: s.nReturned, totalKeysExamined: s.totalKeysExamined, totalDocsExamined: s.totalDocsExamined })

const r = db.pages.find({ _id: { $gt: 990 } }).sort({ _id: 1 }).limit(10).explain("executionStats").executionStats
;({ nReturned: r.nReturned, totalKeysExamined: r.totalKeysExamined, totalDocsExamined: r.totalDocsExamined })
Result
{ nReturned: 10, totalKeysExamined: 1000, totalDocsExamined: 10 }

{ nReturned: 10, totalKeysExamined: 10, totalDocsExamined: 10 }
What’s happening
  1. The pages collection holds 1,000 posts with _id 1 to 1000 (loaded off-page with one insertMany).
  2. skip(990).limit(10) returns 10 documents but examines 1,000 index keys: the server walks the _id index from the start and throws away the first 990. Page 10,000 of a big collection would walk 100,000 keys.
  3. The keyset version remembers where the last page ended (_id 990) and asks for { _id: { $gt: 990 } }. The index seeks straight there: 10 keys examined for 10 results, the same cost for page 1 and page 100,000.
  4. The trade-off: keyset pagination gives "next" and "previous", not "jump to page 57", and the sort key must be unique (or made unique by adding _id: { createdAt: { $lt: lastDate } } plus a tiebreak on _id). It also doesn't skip or repeat items when documents are inserted between page loads, which skip does.
  5. The leading ; before each object literal stops the shell from reading ({…}) as a call on the previous line's value.

#Counting documents

My notes say "count documents with criteria". There are two modern methods. countDocuments(filter) runs a real query and returns the exact number of matching documents; it accepts any normal filter and uses indexes like a find would. estimatedDocumentCount() takes no filter and returns the collection's total from metadata, without scanning — near-instant on huge collections, but only an estimate.

The legacy count() merged the two jobs (with no filter it read metadata; with a filter it counted) and is deprecated in mongosh. Mental model: countDocuments is counting heads in the room; estimatedDocumentCount is reading the number on the attendance sheet — fast, and right almost all the time.

mongosh
db.users.countDocuments({ age: { $gt: 40 } })
db.users.countDocuments({})
db.users.estimatedDocumentCount()
db.users.count({ age: { $gt: 40 } })
Result
3
7
7
DeprecationWarning: Collection.count() is deprecated. Use countDocuments or estimatedDocumentCount.
3
What’s happening
  1. countDocuments({ age: { $gt: 40 } }) counts Bob, Chandra and Elif: 3 (Grace's string age doesn't count, as always).
  2. countDocuments({}) counts every document by actually reading them (via the _id index): 7.
  3. estimatedDocumentCount() reads the count stored in the collection's metadata: also 7, without scanning anything.
  4. The legacy count(filter) still returns 3, with a DeprecationWarning naming the two replacements.
AdvancedWhen the estimate is wrong, and $count

estimatedDocumentCount() can drift from the truth after an unclean shutdown, and on a sharded cluster it can include orphaned documents left behind by chunk migrations. Use it for dashboards and "about 12 million rows" displays, never for logic that must be exact. The aggregation equivalent of countDocuments is the $count stage, handy at the end of a longer pipeline:

mongosh
db.users.aggregate([{ $match: { city: "Toronto" } }, { $count: "torontoUsers" }])
db.users.aggregate([{ $group: { _id: "$city", n: { $sum: 1 } } }, { $sort: { n: -1, _id: 1 } }])
Result
[ { torontoUsers: 2 } ]
[
  { _id: 'Toronto', n: 2 },
  { _id: 'Dubai', n: 1 },
  { _id: 'London', n: 1 },
  { _id: 'Los Angeles', n: 1 },
  { _id: 'Mumbai', n: 1 },
  { _id: 'New York', n: 1 }
]
What’s happening
  1. $match keeps Alice and Grace; $count replaces the stream with one document holding the count under the name you gave: { torontoUsers: 2 }.
  2. In fact countDocuments(filter) is implemented as exactly this kind of aggregation ($match then a $group that sums 1), which is why it is exact and why it can't accept operators that aren't allowed in $match, such as $where ($where is not allowed in this context).
  3. For counts per group, use $group with { $sum: 1 }: Toronto 2, then one each for the other cities, sorted by count then name.

#Deleting documents and dropping collections

deleteOne(filter) removes the first matching document; deleteMany(filter) removes every match; both return a deletedCount. findOneAndDelete(filter) removes one document and returns it, which is useful for queues ("take the next job"). To remove the whole collection — documents, indexes and options — use drop(); db.dropDatabase() removes the current database.

The mental model: deleteMany is emptying the drawers, drop() is throwing out the whole cabinet. After deleteMany({}) the collection still exists with its indexes and validation rules, ready for new documents; after drop() it's gone and the next insert creates a bare new one. Deletes are permanent: there is no recycle bin, so the filter deserves the same care as a SQL DELETE … WHERE.

mongosh
db.greetings.insertMany([
  { _id: 1, hello: "world" }, { _id: 2, hello: "there" }, { _id: 3, bye: "now" }, { _id: 4 }
]).insertedIds

db.greetings.deleteMany({ hello: { $exists: false } })
db.greetings.deleteOne({ hello: { $exists: true } })
db.greetings.find()
Result
{ '0': 1, '1': 2, '2': 3, '3': 4 }

{ acknowledged: true, deletedCount: 2 }
{ acknowledged: true, deletedCount: 1 }
[ { _id: 2, hello: 'there' } ]
What’s happening
  1. Four documents: two with a hello field, one with only bye, and one with only _id.
  2. My notes' example, "delete where the hello field doesn't exist": deleteMany({ hello: { $exists: false } }) removes documents 3 and 4 — deletedCount: 2.
  3. deleteOne({ hello: { $exists: true } }) matches documents 1 and 2 but deletes only one of them (here _id: 1, the first the query found). deletedCount: 1.
  4. One document remains, _id: 2. To control which one deleteOne removes, make the filter specific (usually by _id) — deleteOne has no sort option; findOneAndDelete does.
AdvancedfindOneAndDelete: take the next job
mongosh
db.jobs.insertMany([
  { _id: 1, task: "send email", priority: 2 },
  { _id: 2, task: "resize image", priority: 5 },
  { _id: 3, task: "build report", priority: 1 }
]).insertedIds
db.jobs.findOneAndDelete({}, { sort: { priority: -1 } })
db.jobs.countDocuments()
Result
{ '0': 1, '1': 2, '2': 3 }
{ _id: 2, task: 'resize image', priority: 5 }
2
What’s happening
  1. Three jobs with different priorities.
  2. findOneAndDelete with sort: { priority: -1 } picks the highest-priority job, "resize image", deletes it, and returns the deleted document in the same atomic operation.
  3. Two jobs remain. Because finding and deleting are one atomic step, two workers running this at the same time can never both take the same job — a race you'd have with a separate findOne then deleteOne.
AdvanceddeleteMany({}) vs drop() side by side
mongosh
db.greetings.createIndex({ hello: 1 })
db.greetings.deleteMany({})
db.greetings.getIndexes().map(i => i.name)
db.greetings.drop()
db.greetings.getIndexes()
db.getCollectionNames().includes("greetings")
Result
hello_1
{ acknowledged: true, deletedCount: 1 }
[ '_id_', 'hello_1' ]
true
MongoServerError[NamespaceNotFound]: ns does not exist: ch16.greetings
false
What’s happening
  1. We add an index on hello, then delete every document with deleteMany({}): deletedCount: 1.
  2. The collection and its indexes survive: [ '_id_', 'hello_1' ]. deleteMany removes documents one by one (each is a write, replicated through the oplog), which on millions of documents is slow and heavy.
  3. drop() returns true and removes the collection, its indexes and its options in one quick metadata operation. My notes' example is db.users.drop().
  4. Afterwards getIndexes() fails with ns does not exist: ch16.greetings and the collection name is gone from getCollectionNames(). To empty a big collection and start again, drop() then recreate the indexes is usually much faster than deleteMany({}); to delete a subset, deleteMany(filter) is the only option.
  5. The legacy remove() still runs in mongosh with a deprecation warning (mongosh and Compass); write deleteOne / deleteMany.
AdvancedPitfall: filters that match everything

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.