
TTL Indexes in MongoDB: Automatically Expiring Old Data
A lot of data has a shelf life. Login sessions should vanish after a day of inactivity. Password reset tokens should be useless after an hour. Rate limit counters, verification codes, cached API responses, and debug logs all stop being useful after a while, and if nothing deletes them, they quietly pile up until your working set no longer fits in memory.
The usual first attempt is a cron job that runs deleteMany({ createdAt: { $lt: cutoff } }). It works, but it's one more moving part to deploy, monitor, and remember. A TTL index (time to live) moves that job into the database. You tell MongoDB which date field to watch and how long documents should live, and a background process deletes them once they expire.
This guide covers how TTL indexes work, the two ways to configure expiration, how to change or add TTL to existing indexes, driver and Mongoose examples, and the timing and performance details that trip people up in production.
How a TTL Index Works
A TTL index is a regular single-field index on a date field, with one extra option: expireAfterSeconds.
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });
That's it. Any document in sessions whose createdAt is more than an hour in the past is now eligible for deletion.
The deletion itself is done by a background thread on each mongod called the TTL monitor. By default it wakes up every 60 seconds, scans each TTL index for expired entries, and deletes the matching documents. Because the index is sorted by date, finding expired documents is cheap: it's a range scan from the beginning of the index up to "now minus expireAfterSeconds."
Two things follow from this design, and they're the most important facts about TTL indexes:
- Expiration is not instant. A document can survive for up to a minute past its expiry just from the monitor's schedule, and longer if there's a large backlog of deletes or the server is busy.
- Expired documents are still visible until deleted. A query run 30 seconds after a token "expired" may still return it.
If your application logic depends on exact expiry, like rejecting an expired reset token, always check the date in your query as well:
db.resetTokens.findOne({
token: "c2f9a1...",
expiresAt: { $gt: new Date() },
});
Treat the TTL index as garbage collection, not as your security boundary.
Two Ways to Define Expiration
Fixed Lifetime From a Timestamp
The first pattern gives every document the same lifetime relative to a date field. Sessions that last 24 hours from creation:
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 });
db.sessions.insertOne({
sessionId: "a8f3...",
userId: ObjectId("66f0c1a2e4b0a1b2c3d4e5f6"),
createdAt: new Date(),
});
A document expires at createdAt + expireAfterSeconds. If you want a sliding session that lasts 24 hours after the last activity, index a lastSeenAt field instead and bump it on each request:
db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 86400 });
db.sessions.updateOne(
{ sessionId: "a8f3..." },
{ $set: { lastSeenAt: new Date() } },
);
Each update pushes the expiry forward. To avoid writing on every single request, only update lastSeenAt when it's more than a few minutes old.
Per-Document Expiry Time
The second pattern stores the exact expiration time in each document and sets expireAfterSeconds to 0:
db.cache.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.cache.insertMany([
{
key: "weather:london",
value: { tempC: 14 },
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
{
key: "fx:usd-eur",
value: { rate: 0.92 },
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
]);
Now each document expires at its own expiresAt. This is more flexible: different cache entries can have different lifetimes, a "remember me" session can last 30 days while a normal one lasts a day, and you can extend a single document's life by updating its expiresAt. It also makes the expiry visible in the data, which helps when debugging.
| Pattern | Index option | Best for |
|---|---|---|
| Fixed lifetime from timestamp | expireAfterSeconds: N | Logs, events, uniform sessions |
| Per-document expiry time | expireAfterSeconds: 0 | Caches, tokens with varying lifetimes, invitations |
Rules for the Indexed Field
The TTL monitor only deletes documents where the indexed field holds a real BSON date. That leads to a few rules worth memorizing:
- Date values expire normally. Use
new Date()in JavaScript,datetimein Python, and so on. - Arrays of dates use the earliest date. If
expiresAt: [dateA, dateB], the document expires based on whichever is earlier. - Non-date values never expire. A string like
"2026-09-19T07:00:00Z", a number of milliseconds, or a missing field means the document stays forever. - Documents without the field never expire. This is actually useful: leave the field out on documents you want to keep.
The string case is the most common bug. Data imported from JSON or CSV often ends up with dates stored as strings, the TTL index is created successfully, and nothing is ever deleted. You can find these documents quickly:
db.sessions.countDocuments({
createdAt: { $exists: true, $not: { $type: "date" } },
});
If that returns anything other than zero, convert them:
db.sessions.updateMany({ createdAt: { $type: "string" } }, [
{ $set: { createdAt: { $toDate: "$createdAt" } } },
]);
Handling dates and time zones correctly covers why dates should always be stored as BSON dates in the first place. The good news on time zones: BSON dates are UTC instants, so TTL expiry has no time zone ambiguity as long as you store real dates.
Index Restrictions
A few index configurations can't be TTL indexes:
- Compound indexes. TTL applies to single-field indexes. If you set
expireAfterSecondson a compound index, MongoDB ignores it (or rejects it, depending on version). Create a separate single-field TTL index. - The
_idfield. You can't make_ida TTL index, even though ObjectIds contain a timestamp. Add an explicit date field. - Capped collections. TTL indexes aren't supported on capped collections, which already expire data by size.
The value of expireAfterSeconds must be a non-negative integer within a 32-bit signed range (up to 2,147,483,647 seconds, about 68 years).
Partial TTL Indexes
You can combine TTL with a partialFilterExpression to expire only some documents. For example, delete unverified sign-ups after 48 hours but keep verified accounts forever:
db.users.createIndex(
{ createdAt: 1 },
{
expireAfterSeconds: 172800,
partialFilterExpression: { emailVerified: false },
},
);
When a user verifies their email and you set emailVerified: true, their document drops out of the index and will never expire. This is a clean way to expire by state, not just by age. One limitation: the partial index is only used by queries whose filter includes the partial condition, so it may not help your other queries on createdAt.
Changing and Adding TTL on Existing Indexes
You don't need to drop and rebuild an index to change its lifetime. Use collMod:
db.runCommand({
collMod: "sessions",
index: {
keyPattern: { createdAt: 1 },
expireAfterSeconds: 7200,
},
});
{ expireAfterSeconds_old: 86400, expireAfterSeconds_new: 7200, ok: 1 }
In recent versions (MongoDB 5.1 and later), you can also use collMod to add expireAfterSeconds to an existing non-TTL single-field index, turning it into a TTL index without a rebuild. That's valuable on large collections where building a new index would take a long time.
Be careful when shortening a lifetime. Changing 30 days to 7 days instantly makes 23 days of documents eligible for deletion, and the TTL monitor will start removing them on its next pass. On a large collection, that's a big burst of deletes (more on that below).
To check the current setting:
db.sessions.getIndexes().filter((i) => i.expireAfterSeconds !== undefined);
TTL From Application Code
Node.js Driver
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
const tokens = client.db("auth").collection("resetTokens");
await tokens.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
await tokens.createIndex({ token: 1 }, { unique: true });
export async function issueResetToken(userId, token) {
await tokens.insertOne({
userId,
token,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
});
}
export async function consumeResetToken(token) {
// Check expiry explicitly; the TTL monitor may not have run yet
return tokens.findOneAndDelete({ token, expiresAt: { $gt: new Date() } });
}
Using findOneAndDelete makes the token single-use, and the explicit expiresAt filter makes expiry exact. The TTL index just cleans up tokens that were never used.
Mongoose
Mongoose supports TTL through the expires option on a Date path, which accepts seconds or a duration string:
import mongoose from "mongoose";
const sessionSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, required: true },
data: mongoose.Schema.Types.Mixed,
createdAt: { type: Date, default: Date.now, expires: "1d" },
});
export const Session = mongoose.model("Session", sessionSchema);
For per-document expiry, index the field explicitly:
cacheSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
One Mongoose gotcha: changing expires in your schema later does not update an index that already exists. autoIndex sees an index on createdAt and tries to create one with different options, which fails with an IndexOptionsConflict error, or gets skipped silently if you've disabled autoIndex. Use collMod to change the value in the database.
PyMongo
from datetime import datetime, timedelta, timezone
from pymongo import MongoClient, ASCENDING
db = MongoClient("mongodb://localhost:27017")["app"]
db.events.create_index([("createdAt", ASCENDING)], expireAfterSeconds=30 * 24 * 3600)
db.events.insert_one({
"type": "login",
"userId": "u_123",
"createdAt": datetime.now(timezone.utc),
})
Use timezone-aware datetime objects. PyMongo stores them as UTC BSON dates, which is exactly what the TTL monitor needs.
TTL on Replica Sets and Sharded Clusters
On a replica set, the TTL monitor only deletes on the primary. The deletes replicate to secondaries through the oplog like any other write. Secondaries don't run their own TTL deletes, so they stay consistent with the primary. One consequence: if you read from secondaries, expired documents stay visible there until the primary's delete has replicated.
On a sharded cluster, each shard's primary runs its own TTL monitor over its own chunk of the data. There's nothing special to configure.
TTL deletes are ordinary deletes, so they appear in the oplog and in change streams as delete events. If you have a change stream worker listening to a collection with a TTL index, it will see every expiry. That's useful if you want to react to expirations, but it also means a large expiry burst produces a large burst of events.
Performance and Delete Bursts
Deleting documents isn't free. Each deleted document removes an entry from every index on the collection and generates an oplog entry. A TTL index that removes a steady trickle of documents is barely noticeable. A TTL index that suddenly has ten million documents to remove can cause real load, replication lag, and cache churn.
Bursts usually come from one of these situations:
- Creating a TTL index on an existing collection full of old data. Every document older than the cutoff expires at once.
- Shortening
expireAfterSecondson a large collection. - A bulk import where many documents share the same timestamp, so they all expire in the same minute.
Newer MongoDB versions process TTL deletes in batches and limit how long each pass runs, which helps, but for really large backlogs it's still smarter to clean up manually first. Delete the old data in controlled batches during a quiet period, then create the TTL index so it only has to maintain a steady state:
const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000);
let deleted;
do {
const ids = db.events
.find({ createdAt: { $lt: cutoff } }, { _id: 1 })
.limit(5000)
.toArray()
.map((d) => d._id);
deleted = db.events.deleteMany({ _id: { $in: ids } }).deletedCount;
print(`deleted ${deleted}`);
sleep(200);
} while (deleted > 0);
For imports, spreading expiry times with a little jitter (for example, adding a random number of seconds to expiresAt) smooths out the delete curve.
Monitoring the TTL Monitor
serverStatus exposes counters that tell you whether TTL is working:
db.serverStatus().metrics.ttl;
{ deletedDocuments: Long("184223"), passes: Long("5120"), subPasses: Long("5120") }
If passes increases but deletedDocuments stays flat on a collection you know should be expiring, check the field types with the $type query shown earlier. On Atlas, the same data feeds into metrics and you can watch delete operations over time.
The monitor interval is controlled by the ttlMonitorSleepSecs server parameter. You rarely need to change it, and on Atlas you generally can't. Designing your application to tolerate a minute or two of delay is a better approach than tuning the monitor.
TTL vs. Alternatives
TTL indexes aren't the only way to age out data in MongoDB:
- Capped collections keep a fixed amount of data by size, discarding the oldest when full. Good for bounded logs, but you can't control expiry by time. See capped collections.
- Time series collections support
expireAfterSecondsat the collection level and delete whole buckets at a time, which is far more efficient than document-by-document TTL for metrics data. - Online archiving (on Atlas) moves old data to cheaper storage instead of deleting it, for cases where you need to keep it queryable.
If you're storing high-volume measurements, time series collections with collection-level expiry are usually the better tool. For sessions, tokens, caches, and ordinary documents with a lifetime, TTL indexes are the right answer.
Common Pitfalls
Storing dates as strings or numbers. The index builds fine and nothing ever expires. Check with $type and convert to BSON dates.
Relying on TTL for exact expiry. The monitor runs about once a minute and can fall behind. Always filter on the expiry field when correctness matters.
Expecting TTL on a compound index. It only works on single-field indexes. Add a dedicated single-field index on the date.
Creating a TTL index on a huge backlog. You'll trigger a delete storm. Pre-clean in batches, then add the index.
Changing Mongoose expires and expecting the database to follow. Existing indexes don't change when the schema does. Use collMod.
Forgetting that TTL deletes are real deletes. They replicate, show up in change streams, and count against your write capacity. Plan for them like any other write load.
Conclusion
TTL indexes let MongoDB handle the tedious job of deleting stale data. Create a single-field index on a BSON date with expireAfterSeconds for a fixed lifetime, or set it to 0 and store an explicit expiresAt for per-document expiry. Remember that the TTL monitor runs roughly once a minute, so filter on the expiry date whenever exact timing matters, and watch out for strings masquerading as dates.
Look through your database for the collection that grows forever with data nobody reads after a week, whether it's sessions, logs, or email verification codes. Confirm its timestamp is a real date, clear the backlog in batches, and add a TTL index. It's one of the few performance fixes that takes a single line of code.


