Text Search in MongoDB: Text Indexes, $text, and When You Need Atlas Search

7 min readMongoDB

MongoDB has two built-in answers to "find documents containing this word": regular expressions and text indexes. Regexes work without preparation but only use an index when anchored to the start of a string. Text indexes tokenize and stem string content so you can search for words anywhere in a field, with relevance scoring. There is also a third answer, Atlas Search, which is a separate Lucene-based engine available on MongoDB Atlas.

This guide covers the self-managed text index and $text operator in depth, then draws an honest line where Atlas Search becomes the better tool.

Creating a text index

A text index tokenizes string fields, lowercases the terms, strips language-specific stop words, and stems words to their root (running, runs, ran all index as run for English):

db.articles.createIndex({ title: "text", body: "text" })

A collection can have only one text index. That single index can cover multiple fields, or everything:

// Index every string field, including inside embedded documents
db.articles.createIndex({ "$**": "text" })

The wildcard form is convenient but indexes more than you usually need, which costs write performance and disk. Prefer naming the fields users actually search.

Weights

By default every indexed field contributes equally to the relevance score. Weights change that:

db.articles.createIndex(
  { title: "text", body: "text" },
  { weights: { title: 10, body: 1 } }
)

A match in title now contributes 10x more to the score than the same match in body. Weights are fixed at index-creation time; changing them means dropping and rebuilding the index.

Querying with $text

db.articles.find({ $text: { $search: "mongodb aggregation" } })

Behaviors that are not obvious from the syntax:

  • Terms are OR'd by default. "mongodb aggregation" matches documents containing either word. This surprises almost everyone.
  • Phrases use escaped quotes: { $search: "\"aggregation pipeline\"" } matches the exact phrase.
  • Negation uses a minus: { $search: "aggregation -atlas" } excludes documents containing atlas.
  • Stemming applies to the query too. Searching running matches documents containing run.
  • Case and diacritic insensitivity are the default (text index version 3, the default since MongoDB 3.2).

Sorting by relevance

A text match computes a score, but you only see it if you project it with $meta:

db.articles.find(
  { $text: { $search: "aggregation pipeline" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

Without the explicit sort, results come back in unspecified order, not by relevance. Forgetting this is the most common "text search returns garbage ordering" complaint.

Combining with other filters

$text composes with normal query predicates:

db.articles.find({
  $text: { $search: "indexes" },
  status: "published",
  publishedAt: { $gte: ISODate("2025-01-01") }
})

The text index satisfies the $text part; the other predicates are filtered afterward. You cannot put non-text fields into the text index to make this a covered query, but you can create a compound index with a text component and equality prefix fields if you always filter on those fields, for example { status: 1, title: "text" }. Such an index requires the query to include an equality match on status.

Restrictions you will hit

These are documented but routinely discovered the hard way (as of June 2026):

  • One text index per collection. If two features need different searchable field sets, they share one index, or you restructure.
  • One $text expression per query, and you cannot use $text inside $or together with other clauses unless all top-level clauses are indexable.
  • No $text in $elemMatch.
  • Sort order cannot come from a regular index when using $text; sorting on anything other than textScore is an in-memory sort of the matched set.
  • In an aggregation pipeline, $text is only allowed inside a $match that is the first stage. The score is available later via { $meta: "textScore" }.
  • Hint and text do not mix: you cannot hint() a different index alongside $text.
  • Languages: stemming and stop words are language-specific. The default is English; set default_language on the index or a language field per document. About 15 European languages are supported with stemming; languages like Chinese, Japanese, and Korean have no stemming support in self-managed text indexes, which makes them effectively substring-blind.

Text index vs regex

Text indexRegex
Finds word anywhere in fieldYesYes, but collection scan unless anchored
Stemming, stop wordsYesNo
Relevance scoreYesNo
Partial words / substringsNo (token-based)Yes
Index preparation neededYesNo

The substring row matters: a text index cannot find gres inside postgres, because it indexes whole stemmed tokens. Autocomplete-style prefix matching of partial words is exactly where text indexes disappoint, and where people either fall back to anchored regexes (/^postgr/ can use a regular index) or move to Atlas Search.

Where Atlas Search fits

Atlas Search is a different engine: Apache Lucene running alongside MongoDB, available only on Atlas (and as of recent versions, in some self-hosted Enterprise/Community configurations via mongot, still maturing). You define search indexes separately and query through the $search aggregation stage. Compared to text indexes it adds:

  • Autocomplete and partial-word matching
  • Fuzzy matching (typo tolerance)
  • Per-field analyzers, many more languages
  • Faceting, highlighting, synonyms
  • Relevance tuning far beyond static weights

MongoDB's own documentation now steers users toward Atlas Search for application-facing search features, and that advice is sound. The honest summary: text indexes are fine for modest "find documents mentioning X" features on any deployment, including self-managed ones. If search is the feature (storefront search, autocomplete, typo tolerance, ranking that needs tuning), you will outgrow text indexes quickly, and the choice becomes Atlas Search or an external engine like Elasticsearch, Meilisearch, or Typesense.

If you need faceted results alongside text matches on a self-managed deployment, you can combine a first-stage $text match with $facet; see our faceted aggregation guide.

Worked example

db.products.createIndex(
  { name: "text", description: "text", tags: "text" },
  { weights: { name: 10, tags: 5, description: 1 }, default_language: "english" }
)
 
db.products.aggregate([
  { $match: { $text: { $search: "wireless \"noise cancelling\"" }, inStock: true } },
  { $set: { score: { $meta: "textScore" } } },
  { $sort: { score: -1 } },
  { $limit: 20 },
  { $project: { name: 1, price: 1, score: 1 } }
])

This matches documents containing the stem wireless OR the exact phrase noise cancelling, filters to in-stock, and returns the 20 most relevant. Note the $match with $text is the first stage, as required.

Common mistakes

Expecting AND semantics. Space-separated terms are OR'd. For AND, use multiple phrase terms ("\"wireless\" \"noise\"") or filter further with $regex/application logic.

No relevance sort. $text without .sort({ score: { $meta: "textScore" } }) returns matches in arbitrary order.

Expecting substring matching. Text indexes match stemmed tokens, not substrings. $search: "post" will not match postgresql.

Creating a second text index. You get an error. Drop and recreate the single index with the union of fields and appropriate weights.

Large wildcard text indexes on write-heavy collections. Every string in every document gets tokenized on each write. Measure index size with db.collection.stats() before committing to $**.


Mako connects to MongoDB with AI-powered autocomplete for queries and aggregations. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.