Filter concepts by levelShowing all levels.

MongoDB · Section 13

Partial, Sparse, Unique, and TTL Indexes

Level
intermediate
Read
20 min
Concepts
4

A second, narrower pass over the four index types section 10 already introduced: partialFilterExpression's exact operator set (equality, $exists, comparison, $and/$or/$in, geospatial) and how to measure its real size reduction; sparse's precise null-vs-missing semantics; the unique + sparse combination as the standard fix for an optional-but-unique field; and TTL deletion's asynchronous timing and its consequence for any downstream logic that assumes exact-time removal.

What is true here

  1. partialFilterExpression supports a real operator set — equality, $exists, comparison, $type, $and, $or, $in, geospatial — check the current manual rather than an outdated mental model.
  2. A sparse index excludes only truly absent fields; an explicit null still counts as present and is indexed.
  3. unique: true + sparse: true together is the standard fix for a field that must be unique only among documents that actually have it.
  4. TTL deletion happens on the background sweep's own schedule, not the instant a document expires — check timestamps explicitly wherever exact-time correctness matters.

What you will be able to do

  • Write a partialFilterExpression using its real supported operator set, and measure the size reduction it actually achieves
  • Predict whether a document with an explicit null field is included or excluded from a sparse index
  • Combine unique and sparse correctly to fix the missing-field collision on an optional field
  • Write application logic that checks expiry explicitly instead of trusting TTL to have already deleted a document

A second, narrower pass

Four compact recaps of section 10's index types, each focused on a specific detail the roadmap calls out on its own — cross-linked back to the full treatment.

partialFilterExpression: what it accepts, and sizing the reduction

standardintermediate

partialFilterExpression accepts equality, $exists, comparison ($gt/$gte/$lt/$lte), $type, $and, $or, $in, and the geospatial $geoWithin/$geoIntersects operators — a real but not unlimited subset of the query language ($regex and several others are not supported). Size reduction is worth measuring, not assuming: count how many documents actually match the filter versus the full collection.

Think of it as

The operator restriction exists because the filter is evaluated once per document, at write and index-build time, to decide "does this document belong in the index" — not re-evaluated per query the way a normal find() filter is. That is still a real, useful subset of the query language (including $or and $in), just not every operator a find() call can use.

text
{ partialFilterExpression: { <field>: { $exists: true } } }  // equality, comparison, $and/$or/$in also supported

What we're doing: Measure a real size reduction rather than assuming one, using the $in support the earlier, narrower mental model would have missed.

measure-partial-reduction.txttext
db.orders.countDocuments({})                                    // e.g. 10,000,000
db.orders.countDocuments({ status: { $in: ["pending", "processing"] } })  // e.g. 40,000
// -> a partial index on this filter covers ~0.4% of the collection — a real, worthwhile reduction

db.orders.createIndex(
  { customerId: 1 },
  { partialFilterExpression: { status: { $in: ["pending", "processing"] } } }
)  // $in is supported directly — no need to restructure into repeated $or/$and clauses
1
Measuring both counts directly gives the real reduction percentage, rather than assuming "pending orders are rare" without checking.
5
$in is supported in partialFilterExpression, so the filter can be written directly rather than expanded into several $or branches.

Why this works: The whole point of a partial index is a size/cost reduction that is worth the complexity of remembering to include its filter condition in queries — that trade is only justified if the actual measured reduction is significant, which requires counting, not assuming.

Assuming partialFilterExpression's operator set from an outdated or half-remembered list

Wrong

text
// Assuming $or and $in are unsupported and manually rewriting a filter into an awkward, only-equality form

Better

text
// Check the current manual's supported-operator list before working around a restriction that may not apply anymore

What you see: Effort spent rewriting a filter to avoid an operator that was actually supported all along, based on a stale or incomplete mental model of the restriction.

Why: MongoDB's supported-operator list for partialFilterExpression has expanded over versions — a claim about which operators are or are not supported is a version-specific fact worth re-checking against the current manual rather than carrying forward from memory.

Remember: partialFilterExpression supports equality, $exists, comparison operators, $type, $and, $or, $in, and geospatial operators — check the current manual before assuming an operator is unsupported. Measure the real size reduction (matching vs. total document count) rather than assuming it.

See also: sparse and partial indexes

What "missing" means for a sparse index

standardintermediate

A sparse index excludes a document only if the indexed field is entirely absent — a field explicitly set to null still counts as present and is included. On a compound sparse index, a document is included if any one of the indexed fields is present, not only if all of them are.

Think of it as

Sparse asks one specific question per document: "does this field exist at all," not "does this field have a meaningful value." A null is a value — the field exists, it just holds nothing — so a sparse index treats an explicit null the same as any other present value, including it in the index exactly as a scalar or object would be.

text
db.<collection>.createIndex({ <field>: 1 }, { sparse: true })

What we're doing: Show the null-vs-missing distinction concretely — both included, only true absence excluded.

sparse-null-vs-missing.txttext
db.users.createIndex({ phone: 1 }, { sparse: true })

db.users.insertOne({ name: "Arya" })                 // phone entirely absent — excluded from the index
db.users.insertOne({ name: "Jon", phone: null })      // phone explicitly null — INCLUDED, null is a value
db.users.insertOne({ name: "Sansa", phone: "555-01" }) // included, normal case
1
The index only excludes documents where phone is entirely absent — this is a narrower exclusion than "no meaningful phone number."
5
Explicitly setting phone: null still counts as the field being present — this document is included in the sparse index, which surprises people expecting null to be treated like "missing."

Why this works: The distinction matters because "sparse" is often reached for with the intent "only index documents that have a real value here" — but the actual behavior is narrower (only true absence is excluded), so a field pattern using explicit nulls as a placeholder does not get the exclusion someone might expect.

Using explicit null as a "no value" placeholder and expecting a sparse index to exclude it

Wrong

text
// Application code sets phone: null when a user has none, expecting the sparse index to skip these documents

Better

text
// Omit the field entirely when there is no value, rather than setting it to null, if sparse exclusion is the goal — or use a partial index with an explicit condition

What you see: A sparse index is larger than expected, because every document using null-as-placeholder is still included.

Why: Sparse indexes exclude based on field presence, not field meaningfulness — null is a value like any other BSON type as far as presence is concerned, so a design that uses null to mean "no data" does not get the sparse exclusion unless the field is actually omitted.

Remember: Sparse excludes only truly absent fields — an explicit null still counts as present and is indexed. On a compound sparse index, any one present field is enough to include the document.

See also: sparse and partial indexes

Unique indexes and missing fields, plus the sparse+unique fix

standardintermediate

A missing field counts as null for a unique index, so only one document may omit it — section 10 covers this in depth. The fix for a genuinely optional unique field: combine unique: true with sparse: true, so documents missing the field are excluded from the uniqueness check entirely.

Think of it as

Sparse and unique solve two different problems that compound nicely together: sparse decides which documents the index even considers, unique decides whether two considered documents may share a value. Combining them means "only enforce uniqueness among documents that actually have this field" — exactly the behavior someone usually wants from an "optional but unique when present" field.

text
db.<collection>.createIndex({ <field>: 1 }, { unique: true, sparse: true })

What we're doing: Show the sparse+unique combination fixing the exact collision section 10 demonstrates with a plain unique index.

sparse-unique-fix.txttext
db.users.createIndex({ email: 1 }, { unique: true, sparse: true })

db.users.insertOne({ name: "Arya Stark" })     // email absent — excluded from the sparse index entirely
db.users.insertOne({ name: "Jon Snow" })       // also absent — also excluded, no collision this time
1
sparse: true means documents missing email never enter the index at all — there is no "null" entry for them to collide on.
4
Both documents succeed, unlike the plain unique-only case from section 10, because neither one is ever considered by the index.

Why this works: This directly resolves the exact surprise section 10's unique-indexes concept flags — the fix is not to work around the null-collision behavior in application code, but to tell the index to stop considering documents that lack the field in the first place.

Adding unique: true to an optional field without also making it sparse (or partial)

Wrong

text
db.users.createIndex({ email: 1 }, { unique: true })  // email is optional, but the index does not know that

Better

text
db.users.createIndex({ email: 1 }, { unique: true, sparse: true })

What you see: The second user who signs up without an email address gets a duplicate-key error that has nothing to do with an actual email collision — the exact scenario section 10 warns about.

Why: A plain unique index has no concept of "this field is allowed to be absent" — sparse (or an equivalent partial filter) is what actually encodes that allowance, by removing missing-field documents from the index's consideration entirely.

Remember: unique alone treats missing as null (only one document may omit the field — see section 10). unique + sparse excludes missing-field documents from the index entirely, fixing the collision for genuinely optional unique fields.

See also: unique indexes · sparse and partial indexes

TTL deletion is asynchronous, not exact-time

standardintermediate

A TTL-expired document is not gone the instant its time is up — it becomes eligible for deletion, and a background task removes it on its own schedule (roughly every 60 seconds, per section 10). Anything downstream that assumes "expired means already deleted" can observe a document for up to about a minute past its expiry.

Think of it as

TTL expiry is a queue entry, not an event — a document joins the "eligible for cleanup" set at its expiry time, and the background sweep processes that queue on its own schedule. Code that reads the collection directly during that window sees a document that is logically expired but not yet physically gone, which is a real state to design for, not an edge case to ignore.

text
// Read expiry-sensitive documents by checking the timestamp field directly, not by assuming TTL has already removed them

What we're doing: Show application logic correctly checking the expiry timestamp instead of trusting TTL to have already removed an expired document.

check-timestamp-not-absence.txttext
// Wrong assumption: "if it's still in the collection, it must still be valid"
const session = db.sessions.findOne({ _id: sessionId })
// -> session could be expired but not yet TTL-deleted; using it as-is risks accepting an expired session

// Correct: check the expiry condition explicitly, independent of whether TTL has run yet
const session = db.sessions.findOne({ _id: sessionId, lastActiveAt: { $gte: cutoffTime } })
1
A direct lookup by _id says nothing about whether the document has passed its TTL expiry — it may simply not have been swept yet.
4
Adding the same expiry condition the TTL index itself is based on makes the application logic correct regardless of exactly when the background sweep runs.

Why this works: TTL indexes are a convenience for eventual cleanup, not a synchronization primitive — application logic that needs to know "is this still valid right now" has to encode that check itself, the same way it would need to if TTL did not exist at all.

Treating "still present in the collection" as proof a document has not expired

Wrong

text
// if (db.sessions.findOne({ _id })) { /* treat as valid */ }  — no expiry check, relying on TTL having already cleaned it up

Better

text
// Always include the expiry condition in the query itself, treating TTL purely as eventual storage cleanup, not a correctness guarantee

What you see: A session, code, or token that should be expired is still accepted as valid for up to roughly a minute past its expiry time.

Why: TTL's background sweep runs on its own schedule, independent of any individual read — code that conflates "not yet deleted" with "not yet expired" is trusting a timing coincidence rather than an actual guarantee MongoDB makes.

Remember: TTL deletion happens on the background sweep's own schedule, not the instant a document expires — application logic needing exact-time correctness must check the expiry timestamp itself, never assume absence-so-far means still valid.

See also: ttl indexes

Advertisement