Type something to search...
Regular Expression Queries in MongoDB: Power and Pitfalls

Regular Expression Queries in MongoDB: Power and Pitfalls

Sooner or later, an exact match isn't enough. You need every SKU that starts with LMP-, every email at a particular domain, or every log line that mentions a timeout. MongoDB supports regular expressions directly in queries, with Perl-compatible syntax, and they're remarkably capable: anchors, character classes, alternation, lookaheads, and flags all work.

That power comes with sharp edges. A regex that looks harmless can force MongoDB to examine every key in an index, or every document in a collection. A pattern built from user input can change the meaning of your query or tie up a server thread with catastrophic backtracking. And a regex is often used where a different tool, like collation or a search index, would be dramatically better.

This guide covers the query syntax and flags, how regex interacts with indexes (the most important part), regex in aggregation expressions, how to handle user input safely, and when to stop using regex and reach for something else.

Regex Query Syntax

There are two ways to write a regex condition. The first is a regex literal, which works in mongosh and in JavaScript drivers:

db.products.find({ sku: /^LMP-/ });

The second is the $regex operator, with flags in $options:

db.products.find({ sku: { $regex: "^LMP-" } });
db.products.find({ name: { $regex: "desk lamp", $options: "i" } });

They're equivalent on the server. The operator form is useful when the pattern comes from a string, when you're writing JSON (for example in a Compass filter or a config file), or when you need to combine a regex with other operators on the same field:

db.products.find({
  sku: { $regex: "^LMP-", $nin: ["LMP-000", "LMP-TEST"] },
});

Supported Flags

FlagMeaning
iCase-insensitive matching
mMultiline: ^ and $ match at line breaks, not just the string ends
sDot-all: . also matches newline characters
xExtended: ignore unescaped whitespace and allow # comments in the pattern

The x and s flags only work with the $regex operator and $options, not with JavaScript regex literals, since JavaScript's own regex syntax doesn't support x.

db.logs.find({
  message: {
    $regex: `
      ^\\[(ERROR|WARN)\\]   # severity
      .*timeout             # anywhere after it
    `,
    $options: "x",
  },
});

Regex in $in and $not

You can put regex literals inside $in to match any of several patterns:

db.articles.find({ tags: { $in: [/^mongo/, /^node/] } });

You can't use the $regex operator inside $in, only regex objects. To exclude matches, use $not with a regex object:

db.users.find({ email: { $not: /@example\.com$/ } });

Be aware that $not also matches documents where the field is missing. Add email: { $exists: true } if that isn't what you want.

Regex Against Arrays

When a field holds an array of strings, a regex matches if any element matches:

db.articles.insertOne({
  title: "Pagination",
  tags: ["mongodb", "performance"],
});

db.articles.find({ tags: /^perf/ }); // matches

This makes regex handy for tag prefixes, but the same index caveats below apply to multikey indexes.

How Regex Uses Indexes

This is the section that determines whether your regex query takes two milliseconds or twenty seconds.

MongoDB can use an index to evaluate a regex, but how effectively depends entirely on the shape of the pattern. Given an index on { sku: 1 }, there are three distinct cases.

Case 1: Anchored, Case-Sensitive Prefix

db.products.find({ sku: /^LMP-/ });

A pattern that begins with ^ (or \A) followed by literal characters is a prefix expression. MongoDB can convert the prefix into an index range and seek straight to it. The explain() output shows tight bounds:

"indexBounds": {
  "sku": ["[\"LMP-\", \"LMP.\")", "[/^LMP-/, /^LMP-/]"]
}

The first range means "every key from LMP- up to (but not including) LMP.," which is exactly the set of strings starting with LMP-. Only matching keys are examined. This is as fast as a regex gets, and it's nearly as fast as an equality match.

A subtle optimization: /^LMP-/ is faster than /^LMP-.*/ or /^LMP-.*$/. All three use the same index range, but the shorter pattern stops matching as soon as the prefix is confirmed, while the others keep scanning to the end of each string. Write the minimal prefix.

Case 2: Unanchored or Leading Wildcard

db.products.find({ sku: /LMP/ });
db.users.find({ email: /@example\.com$/ });

Without a leading anchor, there's no prefix to seek to. MongoDB can still use the index, but it has to walk every key in it and test each one. Check explain("executionStats"):

{
  "nReturned": 312,
  "totalKeysExamined": 1840227,
  "totalDocsExamined": 312
}

Scanning the index is cheaper than scanning documents, because keys are small and the documents are only fetched for matches. But the cost is proportional to the index size, not the result size. On large collections, this is a full scan with better constant factors.

Suffix searches like "email ends with @example.com" come up often. A common trick is to store a reversed copy of the field (moc.elpmaxe@ecila) and query it with an anchored prefix, or more simply to store the domain as its own field and use an equality match.

Case 3: Case-Insensitive

db.products.find({ sku: /^lmp-/i });

Even when anchored, a case-insensitive regex can't produce tight index bounds. The index stores LMP-001, lmp-002, and Lmp-003 in different parts of the key order, so there's no single contiguous range. MongoDB walks the whole index, just like Case 2.

This is the most common regex performance problem in real applications, usually from a search box that does new RegExp(term, "i"). The fix is to normalize: store a lowercase version of the field, index it, and query it with a case-sensitive anchored prefix:

db.products.createIndex({ nameLower: 1 });

db.products.find({ nameLower: /^desk la/ }).limit(10);

Note that collation doesn't help here, because regex matching isn't collation-aware. The post on case-insensitive queries and collation covers collation for exact matches.

Quick Reference

PatternIndex usageCost scales with
/^abc/Tight range seekNumber of matches
/^abc.*/Tight range, slower per keyNumber of matches
/abc/Full index scanIndex size
/abc$/Full index scanIndex size
/^abc/iFull index scanIndex size
Any regex, no indexCollection scanCollection size

Regex in Drivers

Every driver can send regex queries, but the syntax varies.

In the Node.js driver, pass a RegExp object or the operator form. Always escape user input (covered in the next section):

const prefix = escapeRegExp(req.query.q ?? "");

const results = await db
  .collection("products")
  .find({ nameLower: { $regex: `^${prefix.toLowerCase()}` } })
  .limit(10)
  .toArray();

In PyMongo, you can pass a compiled pattern from Python's re module, and flags like re.IGNORECASE are translated to MongoDB flags. The bson.regex.Regex class is also available for patterns that must round-trip exactly:

import re

prefix = re.escape(user_input.lower())
results = db.products.find({"nameLower": {"$regex": f"^{prefix}"}}).limit(10)

# Compiled pattern with a flag
errors = db.logs.find({"message": re.compile(r"timeout", re.IGNORECASE)})

In Mongoose, regex values are passed through as-is, and the same escaping rules apply:

const products = await Product.find({
  nameLower: new RegExp(`^${escapeRegExp(q.toLowerCase())}`),
})
  .limit(10)
  .lean();

Handling User Input Safely

Building a regex from user input without care causes two distinct problems.

Escaping Metacharacters

If a user searches for c++ or 1.5, the + and . are regex metacharacters. c++ is actually an invalid pattern (a quantifier following a quantifier), and your query throws an error. 1.5 matches 1x5. Escape every metacharacter before building the pattern:

function escapeRegExp(input) {
  return String(input).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

escapeRegExp("c++"); // "c\\+\\+"
escapeRegExp("1.5"); // "1\\.5"

In Python, re.escape() does the same job. If you're on a recent Node.js version, check whether RegExp.escape() is available in your runtime; it's a newer addition to JavaScript and does this for you.

Operator Injection

A related but separate risk: if your code passes a request value straight into a filter, an attacker can send an object instead of a string. With Express and a naive query parser, ?name[$regex]=.* becomes { name: { $regex: ".*" } }, which matches everything. Always coerce inputs to the type you expect (String(req.query.name)) before using them. The post on preventing NoSQL injection attacks covers this class of bug in depth.

Catastrophic Backtracking

Some patterns take exponential time on certain inputs. The classic example is nested quantifiers:

db.comments.find({ body: /^(a+)+$/ });

Against a string like aaaaaaaaaaaaaaaaaaaaaaaaaaaa!, the regex engine tries an enormous number of ways to split the as before giving up. That's a ReDoS (regular expression denial of service), and on the server it ties up a thread for the duration.

Never let users supply raw patterns. If you must accept advanced search syntax, translate it into a safe pattern yourself. And put a time limit on regex queries so a pathological case can't run forever:

db.comments.find({ body: { $regex: pattern } }).maxTimeMS(2000);
await db.collection("comments").find(filter, { maxTimeMS: 2000 }).toArray();

If the query exceeds the limit, it fails with a MaxTimeMSExpired error, which you can catch and turn into a friendly "search took too long" message.

Regex in the Aggregation Pipeline

Since MongoDB 4.2, three aggregation expressions let you use regex inside computed fields, not just filters:

  • $regexMatch returns true or false.
  • $regexFind returns the first match with its index and capture groups.
  • $regexFindAll returns every match.

These make it possible to extract structure from strings without pulling data into your application. For example, counting users by email domain:

db.users.aggregate([
  {
    $project: {
      domain: {
        $let: {
          vars: { m: { $regexFind: { input: "$email", regex: /@(.+)$/ } } },
          in: { $arrayElemAt: ["$$m.captures", 0] },
        },
      },
    },
  },
  { $group: { _id: "$domain", users: { $sum: 1 } } },
  { $sort: { users: -1 } },
  { $limit: 5 },
]);
[
  { _id: "gmail.com", users: 48211 },
  { _id: "outlook.com", users: 12093 },
  { _id: "yahoo.com", users: 6120 },
  { _id: "tidewave.dev", users: 211 },
  { _id: "icloud.com", users: 198 },
];

$regexFind returns a document with match (the matched text), idx (its position), and captures (an array of capture groups), or null when nothing matches. Here the capture group holds everything after the @.

$regexMatch is also useful inside $cond for classification:

db.tickets.aggregate([
  {
    $set: {
      priority: {
        $cond: [
          { $regexMatch: { input: "$subject", regex: /outage|down|urgent/i } },
          "high",
          "normal",
        ],
      },
    },
  },
]);

Aggregation expressions run per document and can't use indexes for the regex itself, so filter with an indexed $match first to shrink the input.

When to Use Something Else

Regex is a precise tool for pattern matching. It's a poor tool for search.

Use collation for case-insensitive equality. Logging in by email, checking username availability: these are exact matches that should ignore case, and a collated index handles them with a normal index seek.

Use a normalized field for autocomplete. A lowercased, accent-stripped field with an anchored prefix regex and a limit is fast and simple for "starts with" suggestions.

Use a text index for word search. If users type words and expect documents containing those words, a $text query understands word boundaries, stemming, and stop words, and it's indexed. See text search with text indexes.

Use Atlas Search for real search. Relevance ranking, typo tolerance, autocomplete that matches mid-word, highlighting, and facets are what a search engine is for. Trying to replicate them with regex ends in slow queries and unhappy users.

Common Pitfalls

Assuming an index makes any regex fast. Only case-sensitive anchored prefixes get tight bounds. Everything else walks the whole index at best. Check totalKeysExamined in explain() for every regex query that runs on a hot path.

Using .* at the start or end of patterns. A leading .* defeats the anchor entirely, and a trailing .* adds work for no benefit. /^abc/ is the pattern you want.

Not escaping user input. Unescaped metacharacters cause errors, wrong results, or worse. Escape every time, even for "internal" admin search boxes.

Forgetting $not matches missing fields. Exclusion queries often return more than expected because documents without the field count as "not matching the pattern."

No time limit on user-driven searches. A single bad pattern can pin a CPU core. Set maxTimeMS on any query whose pattern depends on input.

Using regex as a search engine. If the feature is called "search" in your product, regex is almost certainly the wrong long-term implementation.

Conclusion

Regular expressions in MongoDB are genuinely powerful: prefix lookups, pattern-based filtering, and string extraction in aggregation pipelines all become one-liners. The key is knowing how each pattern interacts with indexes. Case-sensitive anchored prefixes are fast; unanchored, suffix, and case-insensitive patterns scan the whole index. Pair that knowledge with strict escaping, maxTimeMS limits, and the discipline to switch to collation or a search index when the use case calls for it.

Search your codebase for $regex, new RegExp, and re.compile, and run explain("executionStats") on each query you find. Any case-insensitive or unanchored pattern on a large collection is a candidate for a normalized, indexed prefix field.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading