Deepening query capabilities with comparison, logical, element, and array operators; mastering sorting, limit, and skip; and dissecting offset-based pagination versus cursor-based pagination for large-scale applications.

In episodes 3 and 4 you already wrote simple queries: filters based on equality ({ name: "Sneaker" }) and updates with operators. But real-world applications are rarely that simple. Questions like "show products between 100 thousand and 500 thousand", "find users who are active OR have the mentor role", or "find orders where all items are paid" need far richer operators.
Episode 5 is your query arsenal. The roadmap: first comparison operators, second logical operators, third element operators, fourth array operators, and fifth sorting and pagination. By the end of the episode, you'll be able to answer almost all of an application's filtering needs with one expressive query language.
Comparison operators are the most basic and most frequently used. Notice the shape: the field on the left, the operator inside the object on the right:
| Operator | Meaning | Example |
|---|---|---|
$eq | Equal to | { price: { $eq: 150000 } } |
$ne | Not equal to | { role: { $ne: "admin" } } |
$gt | Greater than | { price: { $gt: 150000 } } |
$gte | Greater than or equal | { stock: { $gte: 5 } } |
$lt | Less than | { age: { $lt: 30 } } |
$lte | Less than or equal | { age: { $lte: 30 } } |
$in | Any of the list | { category: { $in: ["elektronik", "fashion"] } } |
$nin | None of the list | { category: { $nin: ["makanan"] } } |
db.products.find({
category: { $in: ["elektronik", "fashion"] },
price: { $gte: 100000, $lte: 1000000 }
})Notice the second example: the price field accepts several operators at once in a single object — meaning all conditions must be satisfied. This is far more concise than writing two separate filters.
Logical operators govern how conditions are combined:
| Operator | Meaning |
|---|---|
$and | All conditions must be true (implicit default when writing several fields) |
$or | At least one condition must be true |
$not | Inverts a condition's result |
$nor | All conditions must be false |
The most useful example is $or — finding documents that match any one of the conditions:
db.users.find({
$or: [
{ role: "mentor" },
{ isActive: true }
],
$and: [
{ age: { $gte: 18 } },
{ age: { $lte: 40 } }
]
})Info
It's important to know: when you write several fields without an explicit logical operator, MongoDB combines them with $and implicitly. So { price: { $gt: 100 }, category: "elektronik" } means the same as writing { $and: [{ price: { $gt: 100 } }, { category: "elektronik" }] }. Use the explicit form when you need clarity or when there are conditions using the same field.
Because MongoDB's schema is flexible, not all documents in one collection have the same fields. Two element operators help handle this situation:
$exists — checks whether the field exists in the document (regardless of its value).$type — checks the BSON type of a field.db.products.find({ specs: { $exists: true } })
db.products.find({ price: { $type: "decimal" } })The first query finds all products that have a specs field. The second query finds products whose price is stored as the decimal type — useful for finding wrongly-typed data that crept in in the past.
Array operations are the area where MongoDB most distinguishes itself from RDBMS. When querying against an array field, MongoDB has a default behavior and three special operators:
find({ tags: "mongodb" }) matches if the array contains that element.$all — the array must contain all the elements mentioned.$elemMatch — at least one array element satisfies several conditions at once.$size — the array length equals the given value.db.products.find({ tags: { $all: ["best-seller", "baru"] } })
db.orders.find({
items: {
$elemMatch: { product: "laptop", qty: { $gte: 2 } }
}
})
db.orders.find({ items: { $size: 3 } })The $elemMatch example is the most important: it looks for orders that have a single item named laptop and a qty of at least 2 in the same element of the array. Without $elemMatch, the condition { "items.product": "laptop", "items.qty": { $gte: 2 } } could match two different elements — behavior that is often confusing.
Order the results with .sort(). A value of 1 means ascending (small to large), -1 means descending (large to small):
db.products.find({}).sort({ price: 1 })
db.products.find({}).sort({ price: -1, createdAt: -1 }).limit(n) limits the number of results; .skip(n) skips the first n documents:
db.products.find({})
.sort({ price: -1 })
.limit(10)
.skip(20)The query above retrieves the third page (assuming 10 items per page) of products from most expensive to cheapest. This is the basis of offset-based pagination.
The pattern above (skip + limit) is offset-based pagination — the simplest, suitable for small pages. But it has a fundamental weakness: .skip() still scans the documents it skips. As the dataset grows (e.g. skipping 100,000), the query slows down dramatically. Not to mention that if new data arrives mid-navigation, results can "jump" and pages duplicate.
For large or constantly changing datasets, use cursor-based pagination: save the last unique value from the previous page, then filter based on that value. Because it uses an index, performance stays fast no matter how deep the page:
db.products.find({
$or: [
{ price: { $lt: 150000 } },
{ price: 150000, _id: { $gt: ObjectId("66xxxxxxxxxxxxxxxxxxxxxxxx") } }
]
}).sort({ price: -1, _id: -1 }).limit(10)The $or pattern above retrieves 10 products priced below 150 thousand, or exactly 150 thousand with an _id greater than the last product seen — the cleanly ordered (price, _id) composition guarantees no data is missed or duplicated.
Warning
Deep pagination with skip on a collection of millions of documents is one of the most common causes of slow queries in production. If you see a large skip (thousands and up) being called often, consider switching to cursor-based pagination — or combine it with a temporal filter (e.g. "get this month's orders") to narrow the scope.
In episode 5 you significantly expanded your querying capabilities: comparison operators $gt, $gte, $lt, $lte, $in, $nin, and $ne; logical operators $and, $or, $not, and $nor; element operators $exists and $type; array operators $all, $elemMatch, and $size; as well as sorting, limit, skip, and the two pagination approaches — offset-based and cursor-based.
Key takeaways:
$and; use $or for alternatives.$elemMatch matches conditions on the same array element — avoid the ordinary array query trap.$exists and $type are very useful in flexible schemas..sort().limit().skip() is the basis of simple pagination.In the next episode, episode 6, we level up from "being able to query" to "designing data properly": Schema Design Patterns & Best Practices. You'll understand that a flexible schema doesn't mean no design, learn the principle of data locality, and recognize the anti-patterns to avoid such as massive arrays and excessive nesting. See you in episode 6!