DEV Community

Cover image for The DynamoDB TTL Trap: Why Your “Deleted” Data Is Still Showing Up And How to Fix It?

The DynamoDB TTL Trap: Why Your “Deleted” Data Is Still Showing Up And How to Fix It?

When you need to manage temporary data, such as One-Time Passwords (OTPs), password reset tokens, verification codes, or temporary shopping carts, Amazon DynamoDB Time to Live (TTL) looks a great solution.

You store an expiration timestamp, enable TTL, and DynamoDB automatically deletes the item after it expires. So you might naturally assume: “When the TTL timestamp is reached, the item is gone.” That’s the trap!

DynamoDB TTL is asynchronous. It does not guarantee that an item will disappear at the exact moment its expiration timestamp is reached.

AWS states that expired items are usually deleted within two days, while the exact deletion time depends on the workload. Expired items may still be visible in reads, queries, and scans until the background process removes them.

Problem Statement

Imagine you are building a password reset system.
When a user requests a password reset, your application creates a token:

TokenId: abc123
expiresAt: 1758898800
Enter fullscreen mode Exit fullscreen mode

You configure expiresAt as the DynamoDB TTL attribute and set the token to expire after 15 minutes.
The intended flow looks like this:
Intended expired token deletion flow

But the actual DynamoDB behavior is closer to:

Actual expired token deletion flow

The important part is the gap between expiration and physical deletion.
AWS explicitly documents that expired items pending deletion can continue to appear in read and write operations until the background process removes them.

The Risked Implementation

Suppose your password reset endpoint does this:

response = table.get_item(
    Key={"TokenId": token_id}
)

item = response.get("Item")

if item:
    return reset_password(item)
Enter fullscreen mode Exit fullscreen mode

The application is effectively asking:
Does this token exist?
But that is not the same question as:
Is this token still valid?
If the token expired 60 minutes ago but DynamoDB has not physically deleted it yet, get_item() can still return the item.
Your application has now confused database existence with business validity.
That’s the real TTL trap.

The Solution

The fix does not require a cron job, lambda function, second database, or additional AWS service.
You simply need to separate two responsibilities:
1. DynamoDB TTL:
 Clean up expired data eventually.
2. Application logic:
 Decide whether the data is valid right now.

In other words:
Solution Flow

and using Boto3, explicitly validate the expiration timestamp before using the item:

import time
import boto3
from botocore.exceptions import ClientError

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


def verify_reset_token(token_id):
    try:
        response = table.get_item(
            Key={"TokenId": token_id}
        )

        item = response.get("Item")

        if not item:
            return {
                "valid": False,
                "message": "Token not found."
            }

        current_time = int(time.time())
        expires_at = int(item.get("expiresAt", 0))

        if expires_at <= current_time:
            return {
                "valid": False,
                "message": "Token has expired."
            }

        return {
            "valid": True,
            "message": "Token is valid. Proceed!"
        }

    except ClientError:
        return {
            "valid": False,
            "message": "Database error."
        }
Enter fullscreen mode Exit fullscreen mode

Now it doesn’t matter whether DynamoDB has physically deleted the item yet.
The application makes the correct decision based on the expiration timestamp.

What about Query and Scan?

The same principle applies when you are retrieving multiple items.
AWS specifically recommends using a FilterExpression to filter expired items from Query and Scan results.
For example:

import time
from boto3.dynamodb.conditions import Key, Attr

current_time = int(time.time())

response = table.query(
    KeyConditionExpression=Key("UserId").eq(user_id),
    FilterExpression=Attr("expiresAt").gt(current_time)
)

items = response["Items"]
Enter fullscreen mode Exit fullscreen mode

This prevents expired items from being returned to your application.
There is an important DynamoDB detail here, though.
A FilterExpression is applied after DynamoDB reads the items. Therefore, filtering expired items does not reduce the read capacity consumed by the underlying Query. So this pattern is primarily about correctness, not making a poorly designed access pattern cheaper.

What about Global Secondary Indexes?

The same rule applies to data retrieved through a GSI.
TTL deletion eventually removes the item from the base table and its indexes, but the deletion is asynchronous. Until then, an expired item can still participate in your reads.

If your application queries a GSI for temporary data, don’t assume that the absence of a physical delete means the item is valid.
Check the expiration timestamp.

TTL Is a Cleanup Mechanism, Not an Authorization Mechanism so don’t build security logic around the assumption: TTL is expired, so item must be gone, therefore it cannot be used. Instead if TTL expired, let the application check the expiration, and if item is rejected, then DynamoDB eventually deletes it.

The nice part is that you don’t need to introduce another AWS service just to solve this problem, you already have DynamoDB + expiresAt + Application-side item check + TTL cleanup. So no need for cron job, scheduled lambda, redis for expiration or even additional database.

The application-side check is simply part of the request you are already processing.
But expired items do take up storage and read capacity until DynamoDB gets rid of them, AWS said. So TTL is still useful for eventually flushing stale data but should not be relied upon as a precise-time delete feature.

Conclusion

DynamoDB TTL is extremely useful, but its name can create the wrong mental model. TTL does not mean delete this item at this exact second.
It means: This item is eligible for automatic background deletion after this timestamp.
AWS handles the cleanup asynchronously, typically within two days, but the exact timing is not guaranteed. Use DynamoDB TTL for cleanup. Use application logic for expiration.
If your application needs an item to become invalid after 15 minutes, check expiresAt yourself. Let DynamoDB take care of deleting the ghost later, That’s the whole fix, and it costs you $0.

Top comments (0)