When I first came across DynamoDB TTL, I thought it was pretty simple:
Add a timestamp to an item → DynamoDB sees that timestamp → item gets deleted.
And technically, that is the basic idea.
But once you start building a real system, TTL becomes much more interesting.
The important question isn't just:
"When should this item be deleted?"
It's:
"What should happen to this data before it disappears?"
In this article, let's look at DynamoDB TTL using a simple AI workflow system as an example.
We'll see how TTL can work together with:
- DynamoDB Streams
- S3 archival
- Scheduled jobs
- Single-table design
- Different data lifetimes
- Event-driven applications
What Are We Building?
Imagine we have an AI workflow system.
A user starts a workflow, and the workflow might execute several steps:
User starts workflow
↓
AI starts processing
↓
Step 1
↓
Tool call
↓
Step 2
↓
Final result
While the workflow is running, we want to:
- Show events to the user in real time.
- Allow the user to replay the workflow afterwards.
- Keep a record that the workflow happened.
- Eventually move large historical data to cheaper storage.
DynamoDB is a good fit for storing the live workflow data.
But there is a problem.
Some of this data is large and temporary, while some of it is small and important.
That's where TTL becomes useful.
One DynamoDB Table, Different Types of Data
Let's say our table contains three types of items.
1. Event items
These represent everything that happened during the workflow.
For example:
RUN_STARTED
STEP_STARTED
TOOL_CALLED
STATE_UPDATED
STEP_COMPLETED
RUN_COMPLETED
There could be hundreds or thousands of these events for one workflow.
Some events might even contain large state snapshots.
2. META item
We also keep one item containing information about the workflow itself.
For example:
{
"type": "META",
"status": "COMPLETED",
"startedAt": "2026-09-20T10:00:00Z",
"completedAt": "2026-09-20T10:05:32Z"
}
This item is small.
And unlike the events, we want to keep it for a very long time.
3. User history item
We might also want to answer a question like:
"Show me my last 20 workflows."
So we keep a small history record associated with the user.
Something like:
USER#123
RUN#2026-09-20T10:00:00Z
RUN#2026-09-19T15:30:00Z
RUN#2026-09-18T08:20:00Z
Again, this information is small and useful long-term.
So Where Does TTL Come In?
Here's the important part.
We only put the TTL attribute on the event items.
For example:
{
"PK": "RUN#123",
"SK": "EVENT#001",
"type": "RUN_STARTED",
"expires_at": 1790589600
}
But our META item doesn't have expires_at:
{
"PK": "RUN#123",
"SK": "META",
"status": "COMPLETED"
}
And the user history item doesn't have it either.
This is completely valid.
TTL is per item, not per table
This is one of the most useful things to understand about DynamoDB TTL.
You enable TTL on a table and tell DynamoDB which attribute contains the expiration timestamp.
But DynamoDB only expires items that actually contain that attribute.
So one table can contain:
┌───────────────────────┐
│ DynamoDB Table │
├───────────────────────┤
│ Event │ → expires
│ Event │ → expires
│ Event │ → expires
│ META │ → stays
│ User History │ → stays
└───────────────────────┘
This is especially useful in single-table designs.
You don't need a separate table just because two types of data have different lifetimes.
But Why Not Delete the Events Immediately?
Because the events still have value.
Imagine the workflow finishes at:
Monday 10:00 AM
We might want to keep the events in DynamoDB for a few days because:
- The UI may need to replay them.
- A developer might need to investigate a failed workflow.
- A customer may reopen the workflow.
- Some downstream process might still need them.
But keeping large event data in DynamoDB forever isn't necessarily a good idea.
So we can introduce another step:
Archive it.
The Data Lifecycle
Let's say we want to archive completed workflow events to S3 after 24 hours.
The lifecycle becomes:
Workflow runs
│
▼
DynamoDB
│
│ 24 hours
▼
Archive to S3
│
│ 7 days
▼
DynamoDB TTL removes events
More specifically:
T+0
│
├── Workflow events written to DynamoDB
│
│
T+24 hours
│
├── Read events from DynamoDB
├── Compress them
├── Write archive to S3
├── Write manifest.json
└── Mark META as archived
│
│
T+7 days
│
└── DynamoDB TTL eventually removes event items
Now notice something important.
There is a gap between archival and deletion.
That gap is intentional.
The TTL Gap Is Your Safety Margin
Suppose:
- Archive after 24 hours
- TTL after 7 days
You have roughly 6 days of safety margin.
Why is that important?
Imagine your archive job fails.
Maybe:
- S3 permissions changed.
- The Lambda deployment introduced a bug.
- One event contains unexpected data.
- The database query fails.
- S3 is temporarily unavailable.
If your TTL is only 25 hours, you might have almost no time to recover.
The original data could disappear before you successfully archive it.
Instead:
24h
│
│ Archive
│
├────────────── 6 days ──────────────┤
│ │
│ Safety / recovery window │
│ │
└────────────────────────────────────┘
│
▼
TTL reap
That six-day period gives your system time to detect and fix failures.
So TTL isn't just about deciding when to delete something.
It's also about deciding how much recovery time you want before deletion becomes possible.
One Important Thing: TTL Is Not an Exact Timer
This is probably the most important TTL concept.
If an item has:
expires_at = 10:00 AM
that does not mean DynamoDB guarantees:
10:00:00 AM → DELETE
TTL deletion happens asynchronously through a background process.
So you should think of TTL more like:
"This item is eligible for deletion after this time."
Not:
"This item will definitely be deleted at this exact time."
That distinction matters a lot.
Don't Use TTL for Time-Critical Security
For example, imagine you have a token that must become invalid exactly at 10:00 AM.
Don't depend on DynamoDB TTL to enforce that.
Instead, check the expiration during the read:
if (token.ExpiresAt <= DateTime.UtcNow)
{
// Token is expired
}
Then TTL can clean the item up later.
This gives you two separate responsibilities:
Application
↓
Enforces expiration
TTL
↓
Cleans up old data
That's a much safer design.
Expired Items Can Still Be Read
Another thing that can surprise people:
An item reaching its TTL doesn't necessarily mean it immediately disappears from queries and scans.
There can be a delay before DynamoDB removes it.
So if your application needs strong expiration semantics, don't assume:
expires_at < now
means:
item no longer exists
Instead, filter it when reading.
For example:
Query DynamoDB
↓
Check expires_at
↓
Is it expired?
/ \
Yes No
↓ ↓
Ignore Use
Again:
TTL handles storage cleanup.
Your application handles business correctness.
What Happens If DynamoDB Streams Are Enabled?
Now things get even more interesting.
Suppose we have DynamoDB Streams enabled because we want to push new workflow events to connected clients.
Something like:
DynamoDB
│
▼
DynamoDB Stream
│
▼
Lambda
│
▼
WebSocket / API
│
▼
Browser
When a new event is inserted:
INSERT
our Lambda can process it.
For example:
{
"eventName": "INSERT",
"dynamodb": {
"NewImage": {
"type": "TOOL_CALLED"
}
}
}
Everything looks fine.
Until TTL starts deleting old events.
TTL Deletions Also Appear in DynamoDB Streams
When DynamoDB TTL removes an item, the stream can contain a:
REMOVE
event.
For example:
{
"eventName": "REMOVE"
}
And this is where an incorrectly written stream consumer can break.
Imagine your Lambda does this:
var image = record.Dynamodb.NewImage;
var eventType = image["type"];
Looks reasonable.
But what happens when the record is:
REMOVE
There is no NewImage.
So your code can fail.
And the scary part?
Your application might work perfectly for weeks.
Then one day, the first TTL deletions happen.
Suddenly:
Lambda errors
Lambda retries
Dead-letter queue messages
Alerts
😅
Check the Event Type First
A safer approach is:
if (record.EventName != "INSERT")
{
return;
}
var image = record.Dynamodb.NewImage;
// Process the new event
The order matters.
Don't access NewImage first and then check whether it's an INSERT.
Instead:
Is this an INSERT?
│
No │ Yes
│ │
▼ ▼
Skip Read NewImage
If you actually need to process deleted items, then configure your DynamoDB Stream view accordingly.
For example, if you need the deleted item's previous contents, you can use a stream configuration that provides the old image.
The important lesson is:
Once you introduce TTL, your DynamoDB Stream consumers need to understand that DELETE/REMOVE events can come from the TTL process too.
A Subtle Design Problem
Here's another interesting problem that can appear in a system like this.
Imagine we have this permanent history item:
USER#123
RUN#2026-09-20T10:00:00Z
The timestamp is part of its key.
Later, the workflow resumes and needs to update that history item.
But instead of storing the timestamp somewhere permanent, the application gets it by reading the original:
RUN_STARTED
event.
And remember...
That event has a TTL.
So we have this dependency:
Permanent History Item
│
│ depends on
▼
Temporary RUN_STARTED Event
Today this might be perfectly fine.
The workflow finishes in a few minutes and the event lives for seven days.
But what happens if someone later changes TTL?
7 days → 1 day
Or what if workflows start running for several days?
Suddenly that assumption becomes dangerous.
A Useful Question for Single-Table Designs
Once you introduce different lifetimes into your DynamoDB table, ask:
"For every item that never expires, does it depend on something that does?"
That's a surprisingly useful design-review question.
You want to avoid situations like:
Permanent data
↓
Temporary data
↓
TTL deletes it
↓
Permanent data becomes impossible to update
If something is important enough to live forever, consider whether the information it needs should also be stored permanently.
Putting Everything Together
Our complete architecture now looks something like this:
┌──────────────┐
│ Workflow │
└──────┬───────┘
│
▼
┌──────────────┐
│ DynamoDB │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
Events META User History
│
│ TTL
▼
DynamoDB Stream
│
▼
Lambda
│
▼
Live UI Updates
After ~24 hours:
Events
│
▼
Archive Job
│
▼
Compress
│
▼
S3
│
▼
manifest.json
After 7 days:
DynamoDB TTL
│
▼
Event items removed
The result is:
DynamoDB
Keeps the data needed for the active/recent workflow experience.
S3
Keeps the historical event data cheaply.
META
Keeps the permanent information about the workflow.
User History
Keeps the user's long-term workflow history.
TTL
Eventually cleans up the large temporary event data.
So, What Is DynamoDB TTL Really?
After looking at a real architecture, I think it's better to think about TTL like this:
DynamoDB TTL isn't a delete button. It's the final step of a data lifecycle.
Before setting a TTL, ask:
1. Does this data need to be archived?
If yes, where?
DynamoDB → S3
2. How long does the archive process need?
Maybe:
24 hours
3. How much safety margin do you need?
Maybe:
Archive after 24h
TTL after 7 days
4. Does the application need strict expiration?
If yes, enforce it during reads.
Don't rely only on TTL.
5. Are DynamoDB Streams enabled?
If yes, make sure consumers handle TTL-generated REMOVE events.
6. Does permanent data depend on temporary data?
If yes, think carefully about that dependency.
Final Thoughts
TTL looks like a small DynamoDB feature.
You add an attribute, enable TTL, and DynamoDB eventually removes the item.
But in a production system, the interesting part isn't the TTL configuration.
The interesting part is everything around it.
A good lifecycle might look like:
Create
↓
Use
↓
Archive
↓
Keep recovery margin
↓
TTL
↓
Eventually remove
Once you start thinking about TTL this way, it becomes much easier to design DynamoDB systems with different data lifetimes.
And perhaps the biggest lesson is:
Don't ask only "When should DynamoDB delete this?"
Ask "What should happen to this data before DynamoDB is allowed to delete it?"
That's where TTL becomes an architectural decision rather than just a checkbox in the AWS console.
Top comments (0)