Query Operators — Comparison, Logical, Element, and Array Operators

A plain equality filter — { status: 'pending' } — matches documents where a field equals a specific value. Real applications need more: find tasks due before Friday, find tasks that are either high priority or overdue, find users who have not set a phone number, find tasks that include a specific tag. MongoDB’s query operators extend the filter syntax to express all of these. Operators are prefixed with $ and appear as values inside the filter object. Mastering them means you can push complex filtering logic into the database — where it runs on indexed data — rather than fetching everything and filtering in Node.js.

Comparison Operators

Operator Meaning Example
$eq Equal to (implicit — rarely written explicitly) { age: { $eq: 30 } } = { age: 30 }
$ne Not equal to { status: { $ne: 'deleted' } }
$gt Greater than { dueDate: { $gt: new Date() } }
$gte Greater than or equal to { score: { $gte: 70 } }
$lt Less than { price: { $lt: 100 } }
$lte Less than or equal to { createdAt: { $lte: cutoffDate } }
$in Value is in the given array { priority: { $in: ['high', 'medium'] } }
$nin Value is NOT in the given array { status: { $nin: ['deleted', 'archived'] } }

Logical Operators

Operator Meaning Example
$and All conditions must be true (implicit when multiple fields) { $and: [{ status: 'pending' }, { priority: 'high' }] }
$or At least one condition must be true { $or: [{ priority: 'high' }, { dueDate: { $lt: now } }] }
$nor None of the conditions can be true { $nor: [{ status: 'deleted' }, { archived: true }] }
$not Negates an expression (field-level) { priority: { $not: { $eq: 'low' } } }

Element Operators

Operator Meaning Example
$exists Field exists (true) or does not exist (false) { dueDate: { $exists: true } }
$type Field is of a specific BSON type { age: { $type: 'int' } }

Array Operators

Operator Meaning Example
$all Array contains ALL specified elements { tags: { $all: ['urgent', 'Q4'] } }
$elemMatch At least one element matches all conditions { scores: { $elemMatch: { $gte: 80, $lt: 90 } } }
$size Array has exactly n elements { tags: { $size: 3 } }
Note: When querying an array field with a scalar value — { tags: 'urgent' } — MongoDB matches any document where the tags array contains the value 'urgent' as one of its elements. This is one of MongoDB’s most convenient behaviours: you do not need a special operator to check if an array contains a value. Only use $in when you want to match any of several possible values, or $all when you need all specified values to be present.
Tip: Use $exists: false to find documents missing a field — extremely useful for identifying incomplete data or migration tasks. For example, { phoneNumber: { $exists: false } } finds all users who have not provided a phone number. This is impossible to express in SQL without nullable columns and an IS NULL check, but in MongoDB (where fields can be completely absent) it is a first-class query pattern.
Warning: The $or operator significantly affects query performance. MongoDB can use an index for each individual condition in a $or array, but only if there is a separate index for each field. If any condition in a $or cannot use an index, MongoDB falls back to a full collection scan. Always ensure each field referenced in a $or is indexed, or consider restructuring the query to use a single field with $in when possible.

Complete Query Examples

const { ObjectId } = require('mongodb');
const now = new Date();

// ── Comparison operators ──────────────────────────────────────────────────

// Tasks due in the next 7 days
const sevenDays = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
await Task.find({ dueDate: { $gte: now, $lte: sevenDays } });

// Tasks that are NOT completed
await Task.find({ status: { $ne: 'completed' } });

// High or medium priority tasks
await Task.find({ priority: { $in: ['high', 'medium'] } });

// All tasks except deleted and archived
await Task.find({ status: { $nin: ['deleted', 'archived'] } });

// Tasks with a score between 70 and 90 (inclusive)
await Task.find({ score: { $gte: 70, $lte: 90 } });

// ── Logical operators ─────────────────────────────────────────────────────

// High priority OR overdue (past due date)
await Task.find({
    $or: [
        { priority: 'high' },
        { dueDate: { $lt: now }, status: { $ne: 'completed' } },
    ],
});

// Pending AND (high priority OR has a due date)
await Task.find({
    status: 'pending',   // implicit $and for top-level fields
    $or: [
        { priority: 'high' },
        { dueDate: { $exists: true } },
    ],
});

// Tasks that are neither deleted nor archived
await Task.find({
    $nor: [
        { status: 'deleted' },
        { archived: true },
    ],
});

// ── Element operators ─────────────────────────────────────────────────────

// Tasks that have a due date set
await Task.find({ dueDate: { $exists: true } });

// Tasks with no description (field absent)
await Task.find({ description: { $exists: false } });

// Users where phone is stored as a string (not number)
await User.find({ phone: { $type: 'string' } });

// ── Array operators ───────────────────────────────────────────────────────

// Tasks tagged with 'urgent' (array contains 'urgent')
await Task.find({ tags: 'urgent' });   // MongoDB auto-matches array elements

// Tasks tagged with BOTH 'urgent' AND 'Q4'
await Task.find({ tags: { $all: ['urgent', 'Q4'] } });

// Tasks with exactly 3 tags
await Task.find({ tags: { $size: 3 } });

// Tasks with no tags (empty array)
await Task.find({ tags: { $size: 0 } });

// Tasks where at least one attachment is larger than 1MB
await Task.find({
    attachments: {
        $elemMatch: { size: { $gt: 1024 * 1024 } }
    }
});

// ── Date range filters ─────────────────────────────────────────────────────
const startOfDay = new Date(now.setHours(0, 0, 0, 0));
const endOfDay   = new Date(now.setHours(23, 59, 59, 999));

// Tasks created today
await Task.find({ createdAt: { $gte: startOfDay, $lte: endOfDay } });

// Tasks overdue (past due date and not completed)
await Task.find({
    dueDate: { $lt: new Date() },
    status:  { $ne: 'completed' },
});

// ── Combined real-world query ──────────────────────────────────────────────
// "Find all active, non-deleted tasks for a user that are either
//  high priority or overdue, created in the last 30 days"
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);

await Task.find({
    userId:     new ObjectId(userId),
    status:     { $nin: ['completed', 'deleted'] },
    createdAt:  { $gte: thirtyDaysAgo },
    $or: [
        { priority: 'high' },
        { dueDate: { $lt: new Date() } },
    ],
}).sort({ priority: -1, dueDate: 1 }).limit(20);

How It Works

Step 1 — Operators Are Values Inside the Filter Object

Query operators always appear as the value of a field key, inside a nested object. { age: { $gte: 18 } } means “the age field must be greater than or equal to 18.” Multiple operators on the same field are implicitly ANDed: { age: { $gte: 18, $lte: 65 } } means age is between 18 and 65. This nested syntax lets MongoDB parse the filter JSON without ambiguity.

Step 2 — Top-Level Multiple Fields Are Implicitly ANDed

{ status: 'pending', priority: 'high' } is shorthand for { $and: [{ status: 'pending' }, { priority: 'high' }] }. The explicit $and is only necessary when you need to apply multiple conditions to the same field — for example, { $and: [{ tags: 'urgent' }, { tags: 'Q4' }] } is different from { tags: { $all: ['urgent', 'Q4'] } } in some edge cases.

Step 3 — Array Queries Work Without Special Syntax for Contains

When you query { tags: 'urgent' } on a document with tags: ['urgent', 'Q4', 'project'], MongoDB returns the document. Querying an array field with a scalar value performs an “array contains” check automatically. This is intentional by design — it means the same query syntax works whether tags is a string or an array of strings in different documents.

Step 4 — $elemMatch Is Required for Multi-Condition Array Element Queries

Without $elemMatch, { scores: { $gte: 80, $lte: 90 } } matches a document where any element in scores is ≥80 AND any element (not necessarily the same one) is ≤90. With $elemMatch, both conditions must be met by the same element. The difference: a document with scores: [95, 60] would match the first query (95≥80 and 60≤90) but not the second.

Step 5 — Operators Are Evaluated Against MongoDB Indexes

MongoDB optimises queries by using indexes when available. $eq, $in, $lt, $gt, and range queries all benefit from B-tree indexes. $exists on a sparse index is efficient. $regex without an anchor (^) cannot use an index. Understanding which operators are index-compatible helps you design efficient queries and choose the right indexes.

Real-World Example: Dashboard Stats Query

// GET /api/v1/tasks/stats — returns counts grouped by status
// controllers/task.controller.js

exports.getStats = asyncHandler(async (req, res) => {
    const userId = new ObjectId(req.user.id);
    const now    = new Date();

    const [statusCounts, overdue, dueToday] = await Promise.all([
        // Group by status
        Task.aggregate([
            { $match: { userId, deletedAt: { $exists: false } } },
            { $group: { _id: '$status', count: { $sum: 1 } } },
        ]),

        // Count overdue tasks (due before now, not completed)
        Task.countDocuments({
            userId,
            dueDate: { $lt: now },
            status:  { $nin: ['completed', 'deleted'] },
            deletedAt: { $exists: false },
        }),

        // Count tasks due today
        Task.countDocuments({
            userId,
            dueDate: {
                $gte: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
                $lt:  new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1),
            },
            status:    { $ne: 'completed' },
            deletedAt: { $exists: false },
        }),
    ]);

    const stats = { overdue, dueToday };
    statusCounts.forEach(({ _id, count }) => { stats[_id] = count; });

    res.json({ success: true, data: stats });
});

Common Mistakes

Mistake 1 — Using $or on the same field when $in is simpler

❌ Wrong — verbose and less efficient:

await Task.find({
    $or: [{ priority: 'high' }, { priority: 'medium' }]
});

✅ Correct — use $in for multiple values on the same field:

await Task.find({ priority: { $in: ['high', 'medium'] } });

Mistake 2 — Expecting $exists: false to match null values

❌ Wrong — null and missing are different in MongoDB:

// Document: { title: 'Task', dueDate: null }
await Task.find({ dueDate: { $exists: false } });  // does NOT match — field exists, value is null
await Task.find({ dueDate: null });                  // matches both null AND missing fields

✅ Correct — match truly absent fields or handle null explicitly:

// Find documents where dueDate is absent OR null:
await Task.find({ $or: [{ dueDate: { $exists: false } }, { dueDate: null }] });
// OR simply:
await Task.find({ dueDate: { $in: [null, undefined] } });

Mistake 3 — Using $not instead of $ne for simple inequality

❌ Overcomplicated — $not wraps an expression:

await Task.find({ status: { $not: { $eq: 'completed' } } });  // verbose

✅ Simpler — use $ne directly:

await Task.find({ status: { $ne: 'completed' } });

Quick Reference

Need Operator Example
Not equal $ne { s: { $ne: 'done' } }
Greater than $gt { age: { $gt: 18 } }
Between range $gte + $lte { score: { $gte: 70, $lte: 90 } }
One of many values $in { p: { $in: ['high', 'medium'] } }
Not any of $nin { s: { $nin: ['deleted', 'archived'] } }
Either condition $or { $or: [{a: 1}, {b: 2}] }
Field exists $exists { phone: { $exists: true } }
Array contains value Scalar query on array field { tags: 'urgent' }
Array contains ALL $all { tags: { $all: ['a','b'] } }
Array element matches multiple conditions $elemMatch { arr: { $elemMatch: { $gt:5, $lt:10 } } }

🧠 Test Yourself

You need to find all tasks for a user that are either high priority OR have a due date in the past. Which query is correct?