MongoDBTTL Indexes

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

JS
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.

Warning
Store an actual 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.

Note
Because expiry is approximate, never rely on TTL for correctness. If a session must be invalid after 1 hour, also check createdAt in your query — TTL is cleanup, not enforcement.

Belt and suspenders: query excludes expired docs

JS
// 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

JS
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

JS
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)
  }
])
Tip
This is the most flexible TTL pattern — one index, arbitrary per-document lifetimes. Most production systems prefer it over the fixed 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

JS
// 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 _id field cannot have a TTL index.

  • Capped collections do not support TTL (documents there cannot be removed individually).

  • Time-series collections use their own expireAfterSeconds collection option instead of a TTL index.

  • Deletes are background work — expiry timing is approximate, and mass expiry adds write and oplog load.

  • If mongod is 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

JS
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()   // 0
Note
You can list TTL indexes with db.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.