Text Indexes and Text Search
A text index is a special MongoDB index type built for searching string content the way a search engine does, rather than the way a normal equality or range index does. Instead of storing exact field values, MongoDB breaks the indexed text into individual words, reduces each word to its root form, and builds an index of those roots. That lets a query for "running" also match a document that only contains the word "run" or "runs". Text indexes power the $text query operator, which can rank results by relevance instead of returning them in arbitrary order.
Overview / How it works
Every string value MongoDB indexes for text search goes through a processing pipeline before it’s stored in the index: the string is split into individual words (tokenization), common words like "the", "is", and "an" are discarded (stop-word removal), and each remaining word is reduced to a root form using a language-specific algorithm (stemming) — for example "indexing", "indexed", and "indexes" all stem to roughly the same root. Matching is also case-insensitive and diacritic-insensitive by default, so "café" and "cafe" are treated as equivalent. Internally this produces something conceptually like an inverted index: each stemmed word maps to the set of documents (and fields) it appears in, which is exactly the data structure real search engines use.
A few structural rules make text indexes different from every other index type in MongoDB:
- A collection can have only one text index, but that single index can cover multiple fields — this is called a compound text index.
- You can index every string field in a document without naming them individually using a wildcard text index:
{ "$**": "text" }. - Non-string values (numbers, booleans, dates) inside a text-indexed field are simply ignored — only string content is tokenized.
- Arrays of strings are handled automatically; every string element gets indexed, similar to how a multikey index works.
- A compound text index can include ordinary (non-text) fields too, but they’re restricted: any field listed before the text field(s) must be queried with an equality condition for the index to be used efficiently, and you cannot compound a text index with other special index types such as
2dsphereor hashed indexes.
It’s worth being upfront about what text indexes are not: they don’t do fuzzy matching, typo tolerance, autocomplete, or relevance tuning anywhere near as well as a dedicated search engine. For production search experiences with those needs, MongoDB Atlas offers Atlas Search, a Lucene-based full-text engine layered on top of your data. Built-in text indexes remain useful for simple, self-hosted keyword search without extra infrastructure.
Syntax
Creating a text index:
db.collection.createIndex(
{ field1: "text", field2: "text" },
{
weights: { field1: 10, field2: 1 },
default_language: "english",
language_override: "language",
name: "myTextIndex"
}
);
| Option | Purpose |
|---|---|
weights |
Object mapping each text field to a relative importance (1–99999, default 1). Higher-weighted fields contribute more to the relevance score. |
default_language |
Stemming/stop-word language used when a document doesn’t specify its own (default "english"). |
language_override |
Name of a field in your documents that overrides default_language per document (default field name is "language"). |
name |
Custom index name — useful because auto-generated compound text index names can get long. |
Querying with the index:
db.collection.find(
{ $text: { $search: "", $language: "english", $caseSensitive: false, $diacriticSensitive: false } }
);
| Field | Purpose |
|---|---|
$search |
The search string. Space-separated terms are OR’ed together; a quoted phrase like \"exact phrase\" requires that phrase; a term prefixed with - excludes documents containing it. |
$language |
Overrides stemming/stop-word rules for this query only. |
$caseSensitive |
Set true to require exact case matching (default false). |
$diacriticSensitive |
Set true to treat accented characters as distinct (default false). |
Examples
Example 1: A basic single-field text index
db.articles.insertMany([
{ title: "Introduction to MongoDB Indexing", body: "Indexes make queries fast by avoiding full collection scans." },
{ title: "Running a Marathon", body: "Training plans for runners preparing for a marathon race." },
{ title: "MongoDB Aggregation Pipeline", body: "Learn how the aggregation framework processes documents in stages." }
]);
db.articles.createIndex({ title: "text" });
db.articles.find({ $text: { $search: "running" } });
Output:
[
{
_id: ObjectId("66f1a2b3c4d5e6f7a8b9c0d1"),
title: "Running a Marathon",
body: "Training plans for runners preparing for a marathon race."
}
]
Even though the search term was "running", the query matched a title containing "Running" because both words stem to the same root and matching is case-insensitive. No other document matched because "run"/"running" doesn’t appear in the other titles, and only the title field is covered by this particular index.
Example 2: A weighted compound text index with relevance sorting
db.articles.dropIndex("title_text");
db.articles.createIndex(
{ title: "text", body: "text" },
{ weights: { title: 5, body: 1 }, name: "articles_text" }
);
db.articles.find(
{ $text: { $search: "mongodb indexing aggregation" } },
{ title: 1, score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } });
Output:
[
{
_id: ObjectId("66f1a2b3c4d5e6f7a8b9c0d0"),
title: "Introduction to MongoDB Indexing",
score: 1.9166666666666667
},
{
_id: ObjectId("66f1a2b3c4d5e6f7a8b9c0d2"),
title: "MongoDB Aggregation Pipeline",
score: 1.75
}
]
Both remaining articles matched because each contains at least one of "mongodb", "indexing", or "aggregation" somewhere in title or body. Projecting { $meta: "textScore" } exposes the relevance score MongoDB computed internally — a combination of how many search terms matched, how often, and the field weights — and sorting by that same $meta expression returns the best match first.
Example 3: Phrase search and term exclusion
db.articles.find({
$text: { $search: "\"aggregation pipeline\" -marathon" }
});
Output:
[
{
_id: ObjectId("66f1a2b3c4d5e6f7a8b9c0d2"),
title: "MongoDB Aggregation Pipeline",
body: "Learn how the aggregation framework processes documents in stages."
}
]
Wrapping aggregation pipeline in escaped double quotes requires that exact phrase (after stemming) to appear together, and prefixing marathon with - excludes any document containing that word. This is closer to how a search-engine query box behaves than a plain $search of loose OR’ed terms.
How it works step by step
When MongoDB executes a $text query, it doesn’t fall back to a collection scan the way an unindexed find() would — it must use the text index, and the query planner produces a dedicated TEXT stage rather than the familiar IXSCAN/COLLSCAN you’d see for a regular query:
db.articles.find({ $text: { $search: "mongodb" } }).explain("executionStats");
{
queryPlanner: {
winningPlan: {
stage: "TEXT_MATCH",
inputStage: {
stage: "TEXT_OR",
inputStage: { stage: "IXSCAN", indexName: "articles_text" }
}
}
}
}
- MongoDB stems and tokenizes the search string using the same rules that built the index (
default_languageunless overridden). - For each resulting term, it walks the inverted index (an
IXSCANunder the hood) to find the set of documents containing that stemmed term. - A
TEXT_ORstage unions the per-term document sets together (loose terms are OR’ed, not AND’ed). - A
TEXT_MATCHstage computes the relevance score for each surviving document from term frequency and field weights, then applies any remaining filter conditions and phrase/exclusion checks. - If you sorted by
{ $meta: "textScore" }, results are ordered by that computed score before being returned to the client.
Common Mistakes
1. Trying to create a second text index on the same collection
MongoDB allows at most one text index per collection — but that index can span multiple fields, so the fix is almost always to make it compound instead of separate.
// Wrong: a second, separate text index
db.articles.createIndex({ title: "text" });
db.articles.createIndex({ body: "text" }); // fails: only one text index allowed
// Corrected: one compound text index covering both fields
db.articles.createIndex({ title: "text", body: "text" });
2. Expecting $text to match substrings
$text matches whole (stemmed) words, not partial strings, so it can’t be used for autocomplete-style prefix matching.
// Wrong: expecting this to match "laptop"
db.products.find({ $text: { $search: "lap" } }); // no match — "lap" isn't the stem of "laptop"
// Corrected: use a prefix regex for small collections, or Atlas Search for real autocomplete
db.products.find({ name: { $regex: "^lap", $options: "i" } });
3. Assuming results come back ordered by relevance
Without explicitly sorting by the computed score, matching documents are returned in whatever order the underlying index scan happens to produce — not by how well they match.
// Wrong: no relevance ordering applied
db.articles.find({ $text: { $search: "mongodb aggregation" } });
// Corrected: project and sort by the text score
db.articles.find(
{ $text: { $search: "mongodb aggregation" } },
{ score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } });
Best Practices
- Use
weightsto bias relevance toward the fields that matter most (e.g. a productnameweighted higher than its long-formdescription). - Combine a text field with an equality-filtered prefix field in a compound index (e.g.
{ category: 1, description: "text" }) to scope full-text search to a subset of documents efficiently. - Set
default_languageand, for multi-lingual collections,language_overridecorrectly — stemming rules for the wrong language produce poor matches silently. - Avoid a blanket
{ "$**": "text" }wildcard index on documents with many large string fields you never actually search; it inflates index size for no benefit. - Remember
$textcannot be used inside$or,$nor, or$elemMatch, and only one$textclause is allowed per query. - For production search needing fuzzy matching, autocomplete, or highlighting, plan a migration path to Atlas Search rather than stretching a text index beyond what it’s designed for.
- Watch index size on large text corpora with
db.collection.stats()— text indexes can grow substantially larger than the fields they cover.
Practice Exercises
- Create a text index on a
productscollection coveringnameanddescription, weightingnamethree times higher thandescription. Search for a couple of terms that appear in both fields and sort by relevance. Expect an array of documents each carrying a numericscorefield, ordered highest first. - Run the same search once as loose terms and once as a quoted phrase, and compare how the result sets differ on your own sample data.
- Run
explain("executionStats")on a$textquery and locate theTEXT_MATCH/TEXT_ORstages in the winning plan, notingtotalDocsExamined.
Summary
- A text index tokenizes, stems, and case/diacritic-normalizes string fields so
$textqueries can match on word roots rather than exact values. - A collection may have only one text index, but it can be compound across several fields, or a wildcard index covering all string fields.
weightsbias relevance scoring toward more important fields;$meta: "textScore"exposes and enables sorting by that score.$textmatches whole stemmed words only — it is not a substring or autocomplete tool, and it can’t be combined with$or,$nor, or$elemMatch.- For advanced full-text needs (fuzzy matching, autocomplete, relevance tuning at scale), Atlas Search is the natural upgrade path beyond built-in text indexes.
