Ch. 12 · MongoDB

MongoDB Collation and Case-Insensitive Queries

Pick a collation strength for case-insensitive matching, build indexes the planner can use with it, and keep emails unique regardless of case.

~6 min readadvancedupdated Oct 19, 2026

Sort a list of customer names in MongoDB without thinking about it, and the result looks broken to anyone outside engineering: every capitalised name comes before every lowercase one, and “Émile” ends up after “zoë”. Search for ann@example.com and you miss the account registered as Ann@Example.com. Both are the same problem. By default MongoDB compares strings byte by byte, and human expectations about text are not byte order.

Collation is how you tell MongoDB which language rules to compare strings with. It affects matching, sorting, range queries and unique indexes, and it has to agree with your indexes or performance falls off a cliff.

Binary order versus language order

MongoDB uses ICU, the same library behind JavaScript’s Intl.Collator, for its collations. That means Node can show you the orderings you’ll get. Here’s one list sorted three ways:

binary : Banana, Olsen, Zoe, apple, emile, item10, item2, zoë, Émile, Ölund
en     : apple, Banana, emile, Émile, item10, item2, Olsen, Ölund, Zoe, zoë
en+num : item1, item2, item10
Text

The first line is what you get with no collation (MongoDB calls it the simple locale): all uppercase ASCII letters precede lowercase ones, and anything outside ASCII sorts after z. The second is English rules: case and accents only break ties between otherwise equal letters. The third uses numericOrdering: true, which compares digit runs as numbers, so item10 follows item2.

Locale matters beyond English. Swedish treats Ö as a separate letter after Z, while German sorts it with O:

sv : Olsen, Zander, Ölund
de : Olsen, Ölund, Zander
Text

If a product serves Swedish users an alphabetical list, { locale: "sv" } is the correct order and { locale: "en" } is a bug report waiting to happen.

Strength: what counts as equal

The collation option that matters most for queries is strength:

strength Compares "emile" vs "Emile" "Emile" vs "Émile"
1 base letters only equal equal
2 base letters and accents equal different
3 (default) base letters, accents and case different different

So “case-insensitive” in MongoDB usually means { locale: "en", strength: 2 }, and “case- and accent-insensitive” means strength: 1. A query with a collation looks like this:

db.users.find({ lastName: "muller" }).collation({ locale: "de", strength: 1 })
// matches "Muller", "MULLER" and "Müller"
JavaScript

Collation also works on aggregate, updateMany, deleteMany, distinct, countDocuments and the rest, passed as an option.

The index has to share the query’s collation

This is the part interviewers care about. An index stores keys in the order defined by its collation. A strength-2 index on email stores Ann@x.com and ann@x.com as equal keys, side by side. A query with a different collation, or with none, compares strings differently, so the planner can’t use that index’s ordering to answer string comparisons.

Here’s the trap in practice. Suppose you create a case-insensitive index:

db.users.createIndex({ email: 1 }, { collation: { locale: "en", strength: 2 } })
JavaScript

This query uses it:

db.users.find({ email: "ann@example.com" }).collation({ locale: "en", strength: 2 })
JavaScript

This one, with no collation, can’t use it for the string match:

db.users.find({ email: "ann@example.com" })
JavaScript

Without a matching index, the second query falls back to a collection scan. The illustrative explain() output below shows the difference you’d look for (abbreviated, in the shape MongoDB 7.0 and 8.0 report):

// with the matching collation
winningPlan: { stage: 'FETCH', inputStage: { stage: 'IXSCAN', indexName: 'email_1', collation: { locale: 'en', strength: 2, ... } } }

// without it
winningPlan: { stage: 'COLLSCAN', filter: { email: { '$eq': 'ann@example.com' } } }
JavaScript

It’s easy to miss in development, where the collection has 200 documents and both queries return instantly. In production with 20 million users, the second one takes seconds.

One nuance: an index with a collation can still be used for parts of a query that don’t compare strings, such as a numeric prefix of a compound index. String comparisons are the part that needs the collations to match.

Making it the default for a collection

Asking every developer to remember .collation(...) on every query is fragile. You can set a default collation when creating the collection:

db.createCollection("users", { collation: { locale: "en", strength: 2 } })
JavaScript

Then queries without a collation use the default, and indexes created without one inherit it. A query can still override it, and an index can be created with { collation: { locale: "simple" } } if one field really needs binary comparison (an API key, say).

The limitation is that you can’t change a collection’s default collation after creation. Adding it to an existing collection means creating a new collection with the collation and copying the data, which is worth planning before launch.

A worked example: unique emails, ignoring case

Your signup flow stores emails as typed. Support finds two accounts, Ann@Example.com and ann@example.com, both belonging to the same person, and password resets go to whichever one the lookup happens to hit first. You need lookups to ignore case and duplicates to be rejected.

The usual first attempt is a regex:

db.users.findOne({ email: /^ann@example\.com$/i })
JavaScript

It works, but a case-insensitive regex can’t use index bounds efficiently. MongoDB has to examine every index key (or every document) and test the regex. It also doesn’t stop duplicates. And unescaped user input in a regex is an injection risk.

The second common attempt is to store a lowercased copy, emailLower, with a unique index. That’s a legitimate design, and it’s what you’d do in many databases. Its weaknesses are that every write path must remember to set the field, and lowercasing isn’t a full case-folding for every language.

The collation approach puts the rule in the index:

db.users.createIndex(
  { email: 1 },
  { unique: true, collation: { locale: "en", strength: 2 } }
)
JavaScript

Now inserting ann@example.com when Ann@Example.com exists fails:

MongoServerError: E11000 duplicate key error collection: app.users index: email_1 collation: { locale: "en", ... strength: 2, ... } dup key: { email: "ann@example.com" }
Text

(Illustrative; the real message lists every collation field.) Lookups use the same collation and hit the index:

db.users.findOne({ email: "ann@example.com" }, { collation: { locale: "en", strength: 2 } })
JavaScript

Before creating the index on an existing collection, you have to find and merge the duplicates, or the build fails with the same E11000 error. An aggregation grouping on the email with the same collation lists them:

db.users.aggregate(
  [ { $group: { _id: "$email", ids: { $push: "$_id" }, n: { $sum: 1 } } }, { $match: { n: { $gt: 1 } } } ],
  { collation: { locale: "en", strength: 2 } }
)
JavaScript

Strength 2 rather than 1 is a deliberate choice here: email local parts can technically contain non-ASCII characters, and treating é and e as identical in an address would be wrong.

A few places collation doesn’t reach: $text indexes ignore it and use their own rules, $regex matching doesn’t honour it, and it changes nothing about how strings are stored. The document keeps the original capitalisation, which is usually what you want for display.

An admin page lets staff search customers by last name with a “starts with” box. The query is db.customers.find({ lastName: { $regex: "^" + input, $options: "i" } }).sort({ lastName: 1 }). There’s an index on { lastName: 1 }. It takes four seconds on 8 million customers. How would you fix it?

Answer and reasoning

The i option is the problem. An anchored, case-sensitive regex like /^Mul/ can turn into a tight index range, but a case-insensitive one can’t, because the matching keys (Mul…, mul…, MUL…) aren’t contiguous in a binary-ordered index. So the planner scans all index keys or the whole collection, and the binary-ordered sort may still look wrong to users.

I’d create an index with a case-insensitive collation, createIndex({ lastName: 1 }, { collation: { locale: "en", strength: 2 } }), and replace the regex with a range query using the same collation: find({ lastName: { $gte: input, $lt: input + "￿" } }).collation({ locale: "en", strength: 2 }).sort({ lastName: 1 }). Because the index treats case as equal, mul to mul￿ covers Muller, MULLER and muller in one contiguous range, and the sort comes from the index in language order. I’d confirm with explain("executionStats") that the plan is an IXSCAN with tight bounds and no in-memory SORT, and I’d test the upper-bound trick against the chosen locale, since that’s the step most worth verifying on real data. If accent-insensitive matching is wanted (muller finding Müller), use strength 1 instead. The input no longer needs regex escaping, but I’d still check it’s a plain string, since a client that sends an object could inject query operators.

Keep reading

More in MongoDB

read ✓MongoDB · mid

MongoDB Bulk Writes

Batch a nightly upsert job with bulkWrite, pick ordered or unordered mode, and read writeErrors so partial failures don't get lost or retried.

~6 min readread →
read ✓MongoDB · mid

MongoDB Capped Collections

Size a capped collection for a recent-errors feed, follow it with a tailable cursor, and see why TTL indexes suit time-based retention better.

~6 min readread →
read ✓MongoDB · hard

MongoDB Change Streams and Resume Tokens

Build an order-to-search sync on change streams, save resume tokens after processing, and recover when the oplog has rolled past you.

~6 min readread →
esc