The CRUD operations from Post 01 take a filter as the first argument. The filter is where MongoDB's query expressiveness lives — comparison, logical, element-existence, array, regex, full-text, and geospatial operators all compose into one JSON-like predicate. This post covers every operator family, projection extras ($elemMatch, $slice, $), the right sort/limit/skip discipline, and the range pagination pattern that scales to millions of rows where .skip() collapses.

Series map: 00 Foundations → 01 CRUD → 02 Query Operators (this post) → 03 Indexes → 04 Query Optimization → 05 Aggregation Basics → … → 17 Drivers & Case Studies.


Learning Outcome

  • Use every comparison operator: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin.
  • Combine with logical operators: $and, $or, $nor, $not.
  • Use element operators: $exists, $type.
  • Match arrays correctly: $all, $elemMatch, $size.
  • Use $regex, $text, and $expr for advanced predicates.
  • Use projection extras: $elemMatch, $slice, the positional $.
  • Paginate with range queries instead of .skip() at scale.

Chapter 1 — Comparison operators

Operator Meaning
$eq equals
$ne not equals
$gt / $gte greater than / or equal
$lt / $lte less than / or equal
$in value in array of options
$nin value NOT in array
1
2
3
4
db.users.find({ age: { $gt: 25, $lte: 40 } })       // 25 < age ≤ 40
db.products.find({ category: { $in: ["book", "pen"] } })
db.users.find({ status: { $nin: ["banned", "deleted"] } })

Bare equality is the most common form:

1
2
db.users.find({ name: "Ada" })          // same as { name: { $eq: "Ada" } }

Equality on arrays

1
2
3
db.users.find({ tags: "math" })         // matches if "math" is an element of tags
db.users.find({ tags: ["math", "eng"] }) // matches if tags EQUALS exactly ["math","eng"]

Subtle and important. The first form is "contains"; the second is "exactly equals this array". Use $all (Chapter 4) for "contains all of".


Chapter 2 — Logical operators

Operator Form
$and { $and: [ filterA, filterB ] } (implicit when conditions are on different fields)
$or { $or: [ filterA, filterB ] }
$nor { $nor: [ filterA, filterB ] } (none match)
$not { field: { $not: { $gt: 10 } } } (negate a sub-expression)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.users.find({
$or: [
{ age: { $lt: 18 } },
{ age: { $gt: 65 } }
]
})

db.orders.find({
status: "paid",
$and: [
{ total: { $gte: 50 } },
{ at: { $gte: ISODate("2026-05-20") } }
]
})

Most simple AND queries don't need $and explicitly — multiple keys at the top level are AND-ed automatically:

1
2
db.orders.find({ status: "paid", total: { $gte: 50 } })   // implicit AND

Use explicit $and when you need multiple conditions on the same field:

1
2
3
4
5
6
7
8
9
db.orders.find({
$and: [
{ total: { $gte: 50 } },
{ total: { $lte: 200 } }
]
})
// or equivalently
db.orders.find({ total: { $gte: 50, $lte: 200 } })

Chapter 3 — Element operators

$exists

1
2
3
4
db.users.find({ email: { $exists: true } })           // field present (any value, even null)
db.users.find({ email: { $exists: false } }) // field absent
db.users.find({ deleted_at: { $exists: false } }) // not soft-deleted

$type

1
2
3
4
5
db.users.find({ age:   { $type: "number" } })
db.users.find({ tags: { $type: "array" } })
db.users.find({ joined: { $type: "date" } })
db.misc.find({ x: { $type: ["int", "long", "double"] } }) // multiple types

Useful in legacy / dirty collections where a field might be a string in some docs, number in others.


Chapter 4 — Array operators

$all — contains all of these values

1
2
3
db.users.find({ tags: { $all: ["math", "eng"] } })
// matches docs where tags has both "math" AND "eng" (any order, may have more)

$size — array length exactly N

1
2
db.users.find({ tags: { $size: 3 } })

No $lt/$gt variants on $size. For "more than N elements" use an indexed counter field (tag_count) updated alongside writes.

$elemMatch — match a single array element against multiple criteria

This one trips beginners. Consider:

1
2
3
4
5
db.students.insertMany([
{ name: "A", scores: [{ subject: "math", grade: 80 }, { subject: "eng", grade: 60 }] },
{ name: "B", scores: [{ subject: "math", grade: 50 }, { subject: "eng", grade: 90 }] },
])

"Find students with a single subject scoring math ≥ 70":

1
2
3
4
5
6
7
8
9
10
// WRONG — matches students where any subject is math AND any subject ≥ 70
db.students.find({ "scores.subject": "math", "scores.grade": { $gte: 70 } })
// matches both A and B (B has eng=90 ≥ 70 + math elsewhere)

// RIGHT — both conditions must hold for the SAME array element
db.students.find({
scores: { $elemMatch: { subject: "math", grade: { $gte: 70 } } }
})
// matches only A

Use $elemMatch whenever multiple conditions must apply to the same array element.

Without $elemMatch two conditions can match different array elements; with $elemMatch one element must meet both


Chapter 5 — Regex

1
2
3
db.users.find({ email: /@example\.com$/ })
db.users.find({ name: { $regex: "^Ad", $options: "i" } }) // case-insensitive prefix
$options flag Meaning
i case-insensitive
m multiline ^ / $
s . matches newlines
x ignore whitespace + allow # comments in pattern

Index use

A left-anchored regex (/^Ad/) can use a B-tree index on the field. Anything else does a scan within the index range or a full collection scan.

1
2
3
4
5
db.users.find({ name: /^Ad/    })   // index-friendly
db.users.find({ name: /Ada$/ }) // not index-friendly
db.users.find({ name: /Ada/ }) // not index-friendly (no left anchor)
db.users.find({ name: /^Ada/i }) // index-friendly only if collation matches

For substring + fuzzy + multi-language matching, prefer text indexes (next) or Atlas Search (Post 16).


Chapter 6 — Text search (built-in)

A text index enables $text queries:

1
2
3
4
5
6
7
8
9
db.articles.createIndex({ title: "text", body: "text" })

db.articles.find({ $text: { $search: "mongodb tutorial" } })
db.articles.find({ $text: { $search: "\"exact phrase\" -excluded" } })
db.articles.find(
{ $text: { $search: "mongodb" } },
{ score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

Limits:

  • One text index per collection.
  • No phrase scoring nuance, no language-aware stemming beyond a basic set.
  • No autocomplete / fuzzy out of the box.

For serious search → Atlas Search (Lucene-based, faceted, autocomplete, fuzzy, multi-language) — Post 16.


Chapter 7 — $expr (use aggregation expressions in queries)

$expr lets you reference other fields in the same document or use aggregation operators inside a find filter:

1
2
3
4
5
// orders where total > 10 × number of items
db.orders.find({
$expr: { $gt: ["$total", { $multiply: [10, { $size: "$items" }] }] }
})

Useful for cross-field predicates without a full aggregation pipeline. $expr doesn't always use indexes the same way; check .explain() (Post 04).


Chapter 8 — Geospatial (overview)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
db.places.createIndex({ location: "2dsphere" })

db.places.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [-0.1, 51.5] },
$maxDistance: 5000 // metres
}
}
})

db.places.find({
location: {
$geoWithin: {
$geometry: { type: "Polygon", coordinates: [[ [...], [...], ... ]] }
}
}
})

Operators: $near, $nearSphere, $geoWithin, $geoIntersects. Indexes: 2dsphere (spherical), 2d (legacy planar).

Skip the deep dive unless your app needs it — full coverage in dedicated docs.


Chapter 9 — Projection extras

Include / exclude

1
2
3
db.users.find({}, { name: 1, email: 1, _id: 0 })       // include name+email, drop _id
db.users.find({}, { password: 0 }) // exclude only password

You can't mix include + exclude except for _id.

$elemMatch in projection — return only matching array elements

1
2
3
4
5
6
db.students.find(
{ name: "A" },
{ name: 1, scores: { $elemMatch: { subject: "math" } } }
)
// returns only the math entry from scores

$slice in projection — return only N array elements

1
2
3
4
db.users.find({}, { tags: { $slice: 5 } })             // first 5
db.users.find({}, { tags: { $slice: -3 } }) // last 3
db.users.find({}, { tags: { $slice: [10, 5] } }) // skip 10, take 5

Positional $ in projection — first matched element

1
2
3
4
5
6
db.students.find(
{ "scores.subject": "math" },
{ "scores.$": 1 }
)
// returns scores array containing only the matched element

Chapter 10 — Sort, limit, skip — and what doesn't scale

1
2
3
4
5
db.orders.find({ status: "paid" })
.sort({ at: -1 })
.limit(20)
.skip(40)

For small offsets this is fine. Cost of .skip(N): server scans N + limit documents in sort order, then discards the N. For N = 10,000 on a million-row collection that's painful even with an index.

Range pagination — the production pattern

Sort by a unique-enough field (often _id or a timestamp). Use the last seen value as a cursor for the next page.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// page 1
const first = await db.collection('orders').find({ status: "paid" })
.sort({ at: -1 })
.limit(20)
.toArray();

const last = first[first.length - 1];

// page 2
const next = await db.collection('orders').find({
status: "paid",
at: { $lt: last.at } // strictly less than last seen
})
.sort({ at: -1 })
.limit(20)
.toArray();

Always O(limit) per page — independent of how deep into the list you've gone.

For non-unique sort keys (multiple rows with the same at), tiebreak on _id:

1
2
3
4
5
6
7
{
$or: [
{ at: { $lt: last.at } },
{ at: last.at, _id: { $lt: last._id } }
]
}

With a compound index on { at: -1, _id: -1 }, this stays O(limit).

Trick: any "Load more" / infinite-scroll UI must use range pagination. Server-side render-time pagination (offset for SEO) tolerates .skip() only up to a few hundred items.


Chapter 11 — Hint, comment, maxTimeMS

1
2
3
4
db.orders.find({ user_id: 1 }).hint({ user_id: 1, at: -1 })   // force this index
db.orders.find({ user_id: 1 }).comment("dashboard-recent-orders") // tag for profiler
db.orders.find({ user_id: 1 }).maxTimeMS(500) // abort if > 500 ms

comment surfaces in db.currentOp() + the slow-query profiler — invaluable for tracking which app code issued a given query in production.

maxTimeMS is a safety net against runaway queries. Always set it in user-facing paths.


Chapter 12 — Distinct, count

1
2
3
4
5
6
db.users.distinct("country")                    // [ "UK", "US", "JP" ]
db.users.distinct("country", { age: { $gte: 30 } })

db.users.countDocuments({ active: true }) // accurate
db.users.estimatedDocumentCount() // fast, metadata only

distinct returns an array (max 16 MB BSON). For huge distinct sets, use the aggregation $group (Post 05).


Chapter 13 — Common pitfalls

Symptom Cause Fix
find({ tags: ["a"] }) returns nothing exact-array match use tags: "a" (contains) or $all: ["a"]
find slow on large collection no index Post 03
Multi-condition array filter matches wrong docs conditions applied independently use $elemMatch
Regex full-scan not left-anchored anchor /^…/ or use text/Atlas Search
.skip(50000) painfully slow offset cost range pagination
$or slow despite indexes each branch needs its own index create one index per branch
find returns sub-document with full fields no projection always project for big docs
App appears to ignore some results iterating cursor twice re-issue query or .toArray() once

Practice Exercises

Exercise 1 — Operator drill

Using the shop dataset, write find queries for:

a) Users aged 30-40 inclusive.
b) Products costing under USD 10 OR over USD 50.
c) Orders for users 1 or 2, status "paid".
d) Users where the address field doesn't exist.

Solutions
1
2
3
4
5
db.users.find({ age: { $gte: 30, $lte: 40 } })
db.products.find({ $or: [{ price: { $lt: 10 } }, { price: { $gt: 50 } }] })
db.orders.find({ user_id: { $in: [1, 2] }, status: "paid" })
db.users.find({ address: { $exists: false } })

Exercise 2 — $elemMatch trap

Insert sample students from Chapter 4. Write the query for "students with math grade ≥ 70" both the WRONG and RIGHT way. Confirm they return different results.

Exercise 3 — Range pagination

Implement a function getPage(beforeAt, limit) for orders that returns limit paid orders strictly older than beforeAt, sorted desc by at. Test with a Node script paging through 1000 synthetic orders.

Exercise 4 — Projection slim

find all users, returning only name, email, and the first 2 elements of tags. No _id.

Solution
1
2
db.users.find({}, { _id: 0, name: 1, email: 1, tags: { $slice: 2 } })

Exercise 5 — Regex anchored vs not

On a 100k-document collection (synthesise), measure find({ name: /^Ad/ }) vs find({ name: /Ad/ }) execution time. Confirm only the first uses the index efficiently (preview of Post 04 explain).

Build a text index over articles.title + body. Query "mongodb tutorial". Sort by text score.


Cheat-sheet

What How
Equals { field: value }
Range { field: { $gt: a, $lte: b } }
One of { field: { $in: [a, b] } }
Multi-cond OR { $or: [{ a: 1 }, { b: 2 }] }
Field present { field: { $exists: true } }
Array contains x { tags: "x" }
Array contains all { tags: { $all: ["x", "y"] } }
Array elem multi-cond { arr: { $elemMatch: { a: 1, b: { $gt: 2 } } } }
Array size N { arr: { $size: N } }
Regex (anchored, indexable) { s: /^prefix/ }
Cross-field compare { $expr: { $gt: ["$a", "$b"] } }
Project include { name: 1, email: 1, _id: 0 }
Project array slice { arr: { $slice: 5 } }
Project matched array elem { "arr.$": 1 } (with matched filter)
Pagination at scale range (< lastSeen) not .skip()
Force index .hint({ index })
Tag for profiler .comment("...")
Timeout safety .maxTimeMS(500)
Distinct db.c.distinct("field", filter?)

What's next

Post 03 covers indexes — single, compound, multikey, text, geo, partial, sparse, TTL, wildcard, hashed; the ESR rule (Equality, Sort, Range) for compound index ordering; index intersection; covered queries; and the explain output that ties everything together.


References

Note: this post is in progress. Planned additions: $jsonSchema for query-time validation, collation deep dive, $where (and why to avoid it), Atlas Search vs $text.