Query Operators and Advanced Filters in MongoDB

Simple equality filters — { published: true } — cover only the most basic query needs. Real-world MERN applications require richer queries: posts with more than 100 views, posts created this week, posts tagged with either “react” or “mongodb”, posts whose titles match a search term, users who have not verified their email yet. MongoDB’s query operator system — comparison, logical, element, array, and text operators — gives you the tools to express any query you can imagine. This lesson covers the complete toolkit and shows how to combine operators into the complex filters your MERN Blog API needs.

Query Operator Categories

Category Operators Used For
Comparison $eq $ne $gt $gte $lt $lte $in $nin Numeric ranges, exact matches, set membership
Logical $and $or $nor $not Combining multiple conditions
Array $all $elemMatch $size Array field queries
Element $exists $type Field existence and type checking
Evaluation $regex $text $where Pattern matching, full-text search
Note: In Mongoose, query operators are the same as raw MongoDB operators — you use them directly in the filter object passed to find(), findOne(), or countDocuments(). There is no Mongoose-specific query syntax for filters. Post.find({ viewCount: { $gt: 100 } }) in Mongoose produces exactly the same MongoDB query as db.posts.find({ viewCount: { $gt: 100 } }) in mongosh.
Tip: Build complex query filter objects dynamically in your Express controllers rather than hardcoding them. Start with a base filter object, then conditionally add operators based on request parameters. This keeps your controller clean and avoids deeply nested ternaries: const filter = {}; if (req.query.tag) filter.tags = req.query.tag; if (req.query.search) filter.$text = { $search: req.query.search };
Warning: The $where operator allows you to write arbitrary JavaScript inside a MongoDB query — it is convenient but extremely slow (no index use, full collection scan) and a security risk if any user input reaches it. Never use $where in a MERN application. Use standard comparison operators or $regex instead.

Comparison Operators

// ── $eq — equal to (same as field: value shorthand) ───────────────────────────
Post.find({ published: { $eq: true } });
Post.find({ published: true });              // shorthand — identical

// ── $ne — not equal ────────────────────────────────────────────────────────────
Post.find({ status: { $ne: 'archived' } });  // all non-archived posts

// ── $gt / $gte / $lt / $lte — numeric comparisons ────────────────────────────
Post.find({ viewCount: { $gt:  100 } });     // more than 100 views
Post.find({ viewCount: { $gte: 100 } });     // 100 or more views
Post.find({ viewCount: { $lt:  10 }  });     // fewer than 10 views
Post.find({ viewCount: { $gte: 10, $lte: 100 } }); // between 10 and 100

// ── $gt / $lt with Date ────────────────────────────────────────────────────────
const last7Days = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
Post.find({ createdAt: { $gte: last7Days } }); // posts from the last week

// ── $in — value is in a list ──────────────────────────────────────────────────
Post.find({ status: { $in: ['draft', 'published'] } });  // either status
Post.find({ _id:    { $in: [id1, id2, id3] } });         // any of these IDs

// ── $nin — value is NOT in a list ─────────────────────────────────────────────
Post.find({ status: { $nin: ['archived', 'deleted'] } }); // exclude these

Logical Operators

// ── $and — all conditions must match ──────────────────────────────────────────
// Note: multiple conditions at the same level are AND by default
Post.find({ published: true, featured: true });
// Equivalent explicit $and:
Post.find({ $and: [{ published: true }, { featured: true }] });

// Use explicit $and when applying multiple operators to the same field:
Post.find({ $and: [
  { viewCount: { $gte: 100 } },
  { viewCount: { $lte: 1000 } },
  { createdAt: { $gte: new Date('2025-01-01') } },
] });

// ── $or — at least one condition must match ───────────────────────────────────
Post.find({ $or: [
  { tags: 'mern' },
  { tags: 'react' },
  { featured: true },
] }); // posts tagged mern OR react OR featured

// ── $nor — none of the conditions match ──────────────────────────────────────
Post.find({ $nor: [
  { published: false },
  { deletedAt: { $ne: null } },
] }); // documents that are NOT unpublished AND NOT deleted

// ── $not — negates an operator expression ─────────────────────────────────────
Post.find({ viewCount: { $not: { $gt: 1000 } } });
// Same as: Post.find({ viewCount: { $lte: 1000 } })

Array Operators

// Single element in array — MongoDB searches inside arrays automatically
Post.find({ tags: 'mern' }); // posts where tags array contains 'mern'

// ── $all — array contains ALL specified values ─────────────────────────────────
Post.find({ tags: { $all: ['mern', 'javascript', 'beginner'] } });
// posts tagged with ALL three tags

// ── $in — array contains ANY of the specified values ──────────────────────────
Post.find({ tags: { $in: ['mern', 'react', 'mongodb'] } });
// posts tagged with ANY of these

// ── $size — array has exactly N elements ──────────────────────────────────────
Post.find({ tags: { $size: 0 } }); // posts with no tags
Post.find({ tags: { $size: 3 } }); // posts with exactly 3 tags

// ── $elemMatch — at least one array element matches ALL conditions ─────────────
// Useful for arrays of objects
User.find({
  addresses: {
    $elemMatch: { city: 'London', primary: true },
  },
}); // users who have a primary address in London

Element Operators

// ── $exists — field exists (or doesn't exist) ─────────────────────────────────
Post.find({ publishedAt: { $exists: true } });  // has a publishedAt field
Post.find({ excerpt:     { $exists: false } }); // no excerpt field

// ── $type — field is of a specific BSON type ──────────────────────────────────
Post.find({ viewCount: { $type: 'number' } });  // viewCount is a number
Post.find({ tags:      { $type: 'array'  } });  // tags is an array
// Type names: 'string', 'number', 'bool', 'date', 'null', 'array', 'objectId'

Evaluation Operators — Text Search and Regex

// ── $regex — regular expression pattern matching ───────────────────────────────
Post.find({ title: { $regex: /mern/i } });            // case-insensitive contains
Post.find({ title: { $regex: '^Getting', $options: 'i' } }); // starts with 'Getting'
Post.find({ email: { $regex: '@gmail\.com$' } });     // ends with @gmail.com

// ── $text — full-text search (requires a text index) ──────────────────────────
// First, create a text index:
// postSchema.index({ title: 'text', body: 'text' })

Post.find({ $text: { $search: 'mern stack tutorial' } })
  .sort({ score: { $meta: 'textScore' } }); // sort by relevance score

// $text with language-aware stemming:
Post.find({ $text: { $search: 'running', $language: 'en' } });
// Matches: 'run', 'running', 'runner' — stemming is applied

Building a Dynamic Filter — Blog Post Search

// server/src/controllers/postController.js
const buildPostFilter = (query) => {
  const filter = { published: true };  // base filter — always applied

  // Tag filter
  if (query.tag) {
    filter.tags = query.tag;
  }

  // Author filter
  if (query.author) {
    filter.author = query.author;
  }

  // Text search
  if (query.search) {
    filter.$text = { $search: query.search };
  }

  // Date range filter
  if (query.from || query.to) {
    filter.createdAt = {};
    if (query.from) filter.createdAt.$gte = new Date(query.from);
    if (query.to)   filter.createdAt.$lte = new Date(query.to);
  }

  // View count range
  if (query.minViews) {
    filter.viewCount = { $gte: parseInt(query.minViews, 10) };
  }

  return filter;
};

// Usage in controller:
const filter = buildPostFilter(req.query);
const posts  = await Post.find(filter).sort({ createdAt: -1 }).limit(10);

Common Mistakes

Mistake 1 — Using $regex for full-text search on large collections

❌ Wrong — regex queries on large text fields without a text index are very slow:

Post.find({ body: { $regex: req.query.search, $options: 'i' } });
// Full collection scan on every search — milliseconds become seconds at scale

✅ Correct — use a text index and the $text operator for search functionality:

// Schema: postSchema.index({ title: 'text', body: 'text' })
Post.find({ $text: { $search: req.query.search } }); // uses the text index ✓

Mistake 2 — Forgetting that $in with ObjectIds needs proper types

❌ Wrong — passing string IDs to $in for an ObjectId field:

const ids = ['64a1f2b3...', '64a1f2b4...'];
Post.find({ _id: { $in: ids } }); // may return 0 results if schema expects ObjectId

✅ Correct — convert strings to ObjectIds, or let Mongoose handle it via proper schema types:

const { Types } = require('mongoose');
const ids = rawIds.map(id => new Types.ObjectId(id));
Post.find({ _id: { $in: ids } }); // ✓

Mistake 3 — Applying $or incorrectly for date range queries on the same field

❌ Wrong — using $or for a date range (should be $and or combined conditions):

Post.find({ $or: [
  { createdAt: { $gte: startDate } },
  { createdAt: { $lte: endDate   } },
] }); // returns posts BEFORE endDate OR AFTER startDate — not a range!

✅ Correct — combine range conditions on the same field in one object:

Post.find({ createdAt: { $gte: startDate, $lte: endDate } }); // ✓ proper range

Quick Reference

Goal Operator / Pattern
Greater than { field: { $gt: value } }
Less than or equal { field: { $lte: value } }
In a set { field: { $in: [a, b, c] } }
Not in a set { field: { $nin: [a, b] } }
Either condition { $or: [{ cond1 }, { cond2 }] }
Array contains value { tags: 'mern' }
Array contains all { tags: { $all: ['a','b'] } }
Array contains any { tags: { $in: ['a','b'] } }
Field exists { field: { $exists: true } }
Text search { $text: { $search: 'query' } }
Regex match { field: { $regex: /pattern/i } }
Date range { createdAt: { $gte: from, $lte: to } }

🧠 Test Yourself

You need a query that returns posts that are either: (a) tagged with ‘featured’ AND have more than 500 views, OR (b) published in the last 7 days. Which query structure is correct?