At some point, if you have built anything that handles user sessions, email verification links, OTP codes, or temporary tokens, you have this table sitting in your database. It has a name like pending_verifications or user_sessions or password_reset_tokens. And it is full of rows from six months ago that mean absolutely nothing anymore.

Nobody clicked that verification link. That session expired. That password reset was never completed.

The data is dead. It just does not know it yet.

The Problem Nobody Talks About Enough

When you first build a feature like email verification, you focus on the happy path. User signs up, gets an email, clicks the link, gets verified. That is what you test. That is what goes into production.

What you do not think about at 2 AM when you are trying to ship is: what happens to the row in the database when the link expires? When the user never clicks it? When they just create a new account with a different email and forget the old one?

Nothing happens. The row stays there. Forever.

Multiply that by a few thousand users a day, an expiry window of 24 hours, and a couple of years of operation, and you have a table with millions of rows that are completely useless. Your database is paying to store them. Your queries are paying to skip over them. Your backups are paying to carry them.

The typical fix is a background job. Some cron script that runs every hour, scans the table for rows older than 24 hours, and deletes them in batches. This works, but it is annoying to write, annoying to maintain, annoying to monitor, and annoying when it fails silently at 3 AM and nobody notices for a week.

There is a better way and it has been sitting in most NoSQL databases for years.

What a TTL Actually Is

TTL stands for Time-To-Live. The idea is simple: you tell the database when a record should expire, and the database takes care of deleting it for you. No cron job. No deletion script. No background worker to maintain.

You set a timestamp on each record that says “this row should not exist after this moment.” The database watches that field and automatically removes the row when the time passes. Your application code never has to think about cleanup again.

It is one of those features that sounds almost too convenient until you actually use it and realize you should have been doing this from day one.

A Concrete Scenario

Let me walk through how this works with something realistic. Say you are building a feature where users can request a bulk export of their data. The export takes a few minutes, and you want to store a “pending export” record that your worker picks up and processes. Once the export is done, the user downloads it. After 24 hours, the download link expires and the record should be gone.

Without TTL, you would:

  1. Insert a row when the export is requested
  2. Have your worker update it when processing is done
  3. Write a cleanup job that deletes rows older than 24 hours
  4. Schedule that job somewhere
  5. Monitor it so you know when it breaks

With TTL, you:

  1. Insert a row when the export is requested, with an expires_at timestamp set to now + 24 hours
  2. Tell your database to watch that field
  3. Do nothing else

The database handles the rest.

How to Set It Up

DynamoDB

DynamoDB has TTL built in at the table level. You enable it once, point it at a specific attribute, and any item that has that attribute set to a Unix timestamp in the past gets deleted automatically.

First, enable TTL on your table. You can do this in the AWS console under the table settings, or with the CLI:

aws dynamodb update-time-to-live \
  --table-name export_requests \
  --time-to-live-specification \
    "Enabled=true, AttributeName=expires_at"

Then when you insert a record, include the expires_at field as a Unix timestamp:

import boto3
import time

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("export_requests")

expires_at = int(time.time()) + (24 * 60 * 60)  # now + 24 hours

table.put_item(Item={
    "request_id": "abc-123",
    "user_id": "user-456",
    "status": "pending",
    "created_at": int(time.time()),
    "expires_at": expires_at,
})

That is the entire setup. DynamoDB will look at expires_at on every item and delete any item whose timestamp is in the past. It typically does this within a few minutes of expiry, though AWS does not guarantee an exact deletion window — just that expired items will be removed within 48 hours. In practice, it is usually much faster.

One thing worth knowing: expired items are still readable until they are actually deleted. So if your application code needs to enforce the expiry strictly, you should also check the timestamp yourself before treating a record as valid. Use TTL for cleanup, not for access control.

MongoDB

MongoDB calls the same feature a TTL index and it works slightly differently. Instead of enabling it at the collection level, you create a special index on a date field with an expireAfterSeconds option.

db.export_requests.createIndex(
  { expires_at: 1 },
  { expireAfterSeconds: 0 }
)

The expireAfterSeconds: 0 means “delete the document when the expires_at time is reached.” MongoDB’s background thread checks for expired documents roughly every 60 seconds, so there can be a slight delay between when a document expires and when it is actually deleted. Same guidance applies: do not rely on the TTL for access control logic.

When inserting a document:

db.export_requests.insertOne({
  request_id: "abc-123",
  user_id: "user-456",
  status: "pending",
  created_at: new Date(),
  expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),  // now + 24 hours
})

MongoDB uses actual Date objects for this rather than Unix timestamps. Keep that in mind if you are switching between the two.

Why This Beats a Background Job

I have written the cron job version of this more times than I care to admit, and there are a few ways it goes wrong.

The deletion query scans the table. In Postgres or MySQL this usually means a sequential scan or an index scan on the timestamp column, followed by deletes. Under load this can cause lock contention. You end up batching deletes with LIMIT and sleeping between batches to avoid hammering the database. Now your cleanup script is 80 lines long and has retry logic.

The job itself has to run somewhere. If it is a Lambda on a schedule it can silently fail with no alerting. If it is a cron on a server you have to remember to migrate it when you move infrastructure. If it is a Kubernetes CronJob you have to manage the job spec, the service account, the resource limits.

And then there is the classic: the job runs every hour, but it only deletes records that are more than 24 hours old. So at any given moment you can have up to 25 hours of stale data sitting in the table. Probably fine. Probably.

Database-native TTL moves all of that complexity into the storage layer where it belongs. The database knows every record in the table. It does not need to query for expired ones. It tracks them natively and removes them as part of its normal operation. No external process, no query overhead on your end, no ops burden.

A Few Things to Watch Out For

TTL is eventually consistent. Both DynamoDB and MongoDB are explicit about this. Expired records are not deleted the instant the clock ticks past their expiry time. If your use case requires strict expiry enforcement, check the timestamp in your application code. TTL is for cleanup, not enforcement.

Deleted TTL records can show up in DynamoDB Streams. If you have a stream on your DynamoDB table and you are using it to trigger Lambdas or feed an event bus, expired TTL deletions will appear as REMOVE events. You may or may not want to react to those. Make sure your stream consumer handles them correctly.

Set the timestamp at write time, not relative to creation time. It is tempting to store something like ttl_hours: 24 and compute the expiry in a background process. Do not. Compute the absolute Unix timestamp at write time and store it directly. It is simpler, easier to debug, and exactly what the database expects.

TTL changes do not apply retroactively in MongoDB. If you add a TTL index to an existing collection, MongoDB will start expiring documents based on their expires_at field going forward. Documents that were inserted before the index existed will also be evaluated. If they do not have the indexed field, they will not be deleted. If they do have it and the date has passed, they will be. Know what is in your collection before enabling this on a live table.

Closing Thoughts

TTL is not a glamorous feature. It does not show up in architecture diagrams. Nobody puts it in a blog post title and goes viral. But it is one of those small, practical things that makes systems cleaner and operations quieter.

Every time I build something that stores temporary data, the TTL goes in on day one now. Sessions, OTPs, verification tokens, pending job records, short-lived API tokens. If the data has a natural expiry window, the database should know about it.

The alternative is a table that slowly fills with ghosts. You will notice it eventually, when a query gets slow or a backup takes twice as long or someone asks why there are 40 million rows in a table that should have a few thousand. Save yourself the conversation. Set the expiry when you write the data and let the database do the rest.