MongoDB TTL Indexes: Let the Database Delete Your Expired Data (With Caveats)
MongoDB TTL indexes delete expired documents for you — no cron job required. Here's how they work, a working configuration, and the operational caveats that matter before you rely on them.
21 Feb 2026, 23:05 UTC

If your application stores sessions, one-time passcodes, password-reset tokens, or event logs, you have a cleanup problem. The classic solution is a cron job that runs DELETE ... WHERE created_at < now() - interval every night. MongoDB offers a built-in alternative: TTL (time-to-live) indexes, which let the server expire documents automatically. The thesis of this post is simple: TTL indexes are the right default for disposable data, but only if you understand that expiry is approximate and deletion has a real operational cost.
How TTL expiry actually works
A TTL index is a normal single-field index on a date-typed field, created with one extra option: expireAfterSeconds. A background thread inside mongod — the TTL monitor — wakes up roughly every 60 seconds and deletes documents whose indexed date is older than the configured window. Two consequences follow immediately:
- Expiry is approximate. A document eligible at 12:00:00 might be deleted at 12:00:05 or 12:01:30 depending on when the monitor runs and how busy the server is. Under heavy load, lag can be much longer.
- The field must be a BSON Date. If the field is missing, null, or a string like
"2026-10-13", the document is silently never expired. If the field is an array of dates, the earliest one is used.
On a replica set, only the primary runs expiry; the deletes replicate to secondaries like any other write, so members stay consistent without each running their own cleanup logic.
A worked example: expiring verification codes
Say you store email verification codes in an otp_codes collection and want them gone 10 minutes after creation. You run this in mongosh against your application database (it requires privileges to create indexes, typically the dbAdmin role on that database):
db.otp_codes.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 600 }
)
Your application inserts documents with a real Date value — in most drivers, just pass a native datetime object and the driver serializes it as BSON Date:
db.otp_codes.insertOne({
email: "[contact removed]",
codeHash: "...",
createdAt: new Date() // must be a Date, not a string
})
That's it — no scheduled job, no cleanup code in the app. To verify the index exists with the right option, run db.otp_codes.getIndexes() and confirm the entry includes expireAfterSeconds: 600. To verify behavior end to end, insert a test document with createdAt set 15 minutes in the past and confirm it disappears within a few minutes. Also insert one with createdAt as a string and confirm it survives — that negative test validates your schema assumptions, which is where most TTL surprises come from.
Retention requirements change. The nice part is you don't rebuild anything: db.runCommand({ collMod: "otp_codes", index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 1800 } }) updates the window in place.
The trade-offs that bite in production
TTL is not a security boundary. Because deletion lag is workload-dependent, an expired document may persist — and remain readable — well past its deadline. If you have a compliance rule that data must be unrecoverable after time X, enforce that with application-level deletion plus verification, and treat TTL as a convenience layer, not the guarantee.
Mass expiry is real write load. Every TTL delete is a delete: it generates oplog entries, consumes I/O, and replicates. On a high-churn collection — think millions of short-lived sessions expiring in a burst after a traffic spike — the TTL monitor can contribute noticeable replication lag and disk pressure. If you see lag spikes correlating with expiry waves, options include spreading expiry times (jitter the date field per document), shortening retention so deletions arrive in smaller batches, or moving to a time-series collection with automatic bucket expiry if your data fits that model.
Silent exemptions. A schema change that writes the date field as a string, or a code path that omits it, quietly opts documents out of expiry forever. Worth a periodic audit query: db.otp_codes.countDocuments({ createdAt: { $not: { $type: "date" } } }) — a nonzero result means something upstream is bypassing your expiry scheme.
Limitations to know before you design around it
- TTL indexes don't work on capped collections (capped collections already self-expire by size).
- The single-field form is the universally supported one; compound TTL index support and monitor behavior details vary by server version, so check the docs for the version you actually run.
- TTL indexes can't be combined with unique constraints, and the field can't already be indexed in a conflicting way.
The bottom line
For sessions, codes, tokens, and transient logs, a TTL index replaces a whole category of scheduled cleanup jobs with one index option — and collMod makes retention tuning cheap. Just remember the two rules: expiry timing is best-effort, and only genuine BSON Date fields participate. Verify both with a quick insert-and-wait test on a staging collection before you trust it with production data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.