TTL Indexes
A TTL (time-to-live) index makes MongoDB automatically delete documents after a certain amount of time. It is the built-in answer to "how do I expire sessions, tokens, cache entries, and old logs without writing a cron job?"
Creating a TTL Index
A TTL index is a single-field index on a date field, created with the expireAfterSeconds option:
Expire documents 1 hour after creation
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 3600 }
)
// Documents must store a real BSON Date in that field
db.sessions.insertOne({
userId: ObjectId("665f1a2b3c4d5e6f7a8b9c0d"),
token: "d41d8cd98f00b204",
createdAt: new Date()
})Each document is eligible for deletion once createdAt + expireAfterSeconds is in the past. Documents where the field is missing, or is not a Date (or array of Dates), are never expired.
Date object, not a timestamp number or an ISO string. A document with createdAt: 1717000000000 or createdAt: "2026-07-10" will simply live forever — TTL ignores non-Date values without any error.How the TTL Monitor Works
Deletion is done by a background thread called the TTL monitor, and understanding it explains the quirks:
The monitor wakes up every 60 seconds and scans TTL indexes for expired entries.
Expired documents are removed with normal deletes — they hit the oplog, fire change streams and, on replica sets, replicate to secondaries.
Deletion is therefore not exact: a document can survive up to a minute (or longer under heavy load) past its expiry time.
On replica sets, only the primary runs deletions; secondaries expire documents via replication.
Large expiry backlogs are deleted in batches, so a huge wave of expiring documents creates real write load.
createdAt in your query — TTL is cleanup, not enforcement.Belt and suspenders: query excludes expired docs
// Even if TTL hasn't swept yet, expired sessions never match
db.sessions.findOne({
token: "d41d8cd98f00b204",
createdAt: { $gte: new Date(Date.now() - 3600 * 1000) }
})Common Patterns
Use case | Field | expireAfterSeconds |
|---|---|---|
Login sessions | createdAt or lastSeenAt | 3600 – 86400 |
Password-reset / email-verification tokens | createdAt | 900 – 3600 |
Rate-limit counters | windowStart | 60 – 3600 |
Application cache entries | cachedAt | seconds to minutes |
Logs / audit events | timestamp | 30–90 days in seconds |
Exact scheduled expiry | expiresAt (a future date) | 0 |
Sliding Sessions
For "keep the session alive while the user is active", update the indexed date on every request. The TTL clock restarts from the new value:
Refresh on activity
db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 1800 })
// On each authenticated request:
db.sessions.updateOne(
{ token: "d41d8cd98f00b204" },
{ $currentDate: { lastSeenAt: true } }
)Expiring at an Exact Date: expireAfterSeconds 0
Sometimes every document has its own expiry moment — an offer valid until Friday, a token with a per-user lifetime. Store the target date in the field and set expireAfterSeconds: 0. The document expires as soon as the stored date passes:
Per-document expiry date
db.offers.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 }
)
db.offers.insertMany([
{
code: "SUMMER26",
discount: 0.2,
expiresAt: new Date("2026-08-31T23:59:59Z")
},
{
code: "FLASH24H",
discount: 0.4,
expiresAt: new Date(Date.now() + 24 * 3600 * 1000)
}
])expireAfterSeconds: N style.Changing or Removing the TTL
You cannot change expireAfterSeconds with createIndex (it errors with an "index already exists with different options" conflict). Use collMod instead — no rebuild required:
Adjusting an existing TTL
// Change 1 hour to 2 hours
db.runCommand({
collMod: "sessions",
index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 7200 }
})
// Remove TTL behavior entirely: drop the index
db.sessions.dropIndex({ createdAt: 1 }){ expireAfterSeconds_old: 3600, expireAfterSeconds_new: 7200, ok: 1 }Limitations
TTL indexes are single-field only — no compound TTL indexes.
The
_idfield cannot have a TTL index.Capped collections do not support TTL (documents there cannot be removed individually).
Time-series collections use their own
expireAfterSecondscollection option instead of a TTL index.Deletes are background work — expiry timing is approximate, and mass expiry adds write and oplog load.
If
mongodis down at expiry time, documents are removed at the next sweep after restart — expect a deletion burst.
Verifying TTL Behavior
Quick 30-second experiment in mongosh
db.ttlDemo.createIndex({ createdAt: 1 }, { expireAfterSeconds: 30 })
db.ttlDemo.insertOne({ note: "I will vanish", createdAt: new Date() })
db.ttlDemo.countDocuments() // 1
// ...wait ~90s (30s TTL + up to 60s until the monitor sweeps)...
db.ttlDemo.countDocuments() // 0db.collection.getIndexes() — they are the ones showing an expireAfterSeconds property. The server-wide sweep frequency is controlled by the ttlMonitorSleepSecs parameter (default 60), which you rarely need to touch.