← DynamoDB patterns

06 - Lifecycle & events

2026-10-20

So far we have looked at data as a snapshot: what goes in the keys, how we read it back, how we keep writes safe. This page is about what happens to data over time, and how to react when it changes.

DynamoDB gives us two building blocks here. TTL expires items after an epoch-seconds timestamp at no write cost. Deletion is lazy, though (it can take hours to days), so we still filter out expired items on read. DynamoDB Streams is change data capture: an ordered log of every insert, update and delete, each carrying the old and/or new image, delivered in order per item for 24 hours. A Lambda (or any other consumer) projects those changes into search indexes, caches, analytics or other tables.

Put them together and we get two very useful patterns. Aggregates via streams: instead of counting at read time, we apply each change as a delta to a running total, so reads become a single GetItem. Soft delete, then archive: we mark an item deleted (and drop it out of sparse indexes), let TTL remove it, and let the stream's REMOVE event copy it to S3.

# Pattern What we'll learn
30 TTL for expiry Enabling TTL, filtering expired items, the milliseconds bug
31 DynamoDB Streams / CDC Reading a stream and projecting it into another store
32 Streams-maintained aggregates Running totals from INSERT/MODIFY/REMOVE deltas
33 Soft delete & archival Soft delete, then TTL, then archive to S3 via the stream

30 · TTL for expiry

Code: 30-ttl-expiry.py · run with just run 30

Problem

Plenty of data has a shelf life: sessions, OTP codes, idempotency records, locks, caches and old events all need deleting eventually. The obvious answer is a cron job that scans for old items and deletes them, but that job costs capacity (every scan and every delete is billed) and it is one more thing to run and monitor.

Pattern

We enable Time To Live on a Number attribute holding an epoch time in seconds. Once that time has passed, DynamoDB deletes the item in the background, for free.

Python
client.update_time_to_live(
    TableName="...",
    TimeToLiveSpecification={"Enabled": True, "AttributeName": "expiresAt"},
)
table.put_item(Item={"PK": "USER#alice", "SK": "SESSION#abc", "expiresAt": int(time.time()) + 3600})

Items decide for themselves whether they expire. An item whose expiresAt is in the past is eligible for deletion, one in the future is kept, and one without the attribute at all never expires:

PK SK expiresAt result
USER#alice SESSION#old now − 60 eligible to delete
USER#alice SESSION#new now + 3600 kept
USER#alice PROFILE (missing) never expires

In practice

The script enables TTL, writes the three items above, then queries the partition twice: once raw, once with a filter that hides anything already expired. Finally it writes a session with a millisecond timestamp to show the most common TTL bug.

Python
raw = table.query(KeyConditionExpression=Key("PK").eq("USER#alice"))["Items"]

fresh = table.query(
    KeyConditionExpression=Key("PK").eq("USER#alice"),
    FilterExpression=Attr("expiresAt").not_exists() | Attr("expiresAt").gt(int(time.time())),
)["Items"]

# Common mistake: milliseconds instead of seconds
table.put_item(Item={"PK": "USER#alice", "SK": "SESSION#bug", "expiresAt": int(time.time() * 1000)})
>>> UpdateTimeToLive p30-ttl-expiry

>>> DescribeTimeToLive p30-ttl-expiry

=== TTL status ===
{
  "TimeToLiveStatus": "ENABLED",
  "AttributeName": "expiresAt"
}

>>> BatchWriteItem
    p30-ttl-expiry: 3 puts, 0 deletes

>>> Query p30-ttl-expiry
    key:        PK = 'USER#alice'

=== Raw query (may include expired items) ===
[
  "PROFILE",
  "SESSION#new",
  "SESSION#old"
]

>>> Query p30-ttl-expiry
    key:        PK = 'USER#alice'
    filter:     (attribute_not_exists(expiresAt) OR expiresAt > 1792496917)

=== Filtered query (live items only) ===
[
  "PROFILE",
  "SESSION#new"
]

>>> PutItem p30-ttl-expiry
    item:       {PK: 'USER#alice', SK: 'SESSION#bug', expiresAt: 1792496917261}

>>> GetItem p30-ttl-expiry
    key:        {PK: 'USER#alice', SK: 'SESSION#bug'}

=== Milliseconds TTL value (wrong!) ===
{
  "SK": "SESSION#bug",
  "expiresAt": 1792496917261,
  "PK": "USER#alice"
}

The raw query still returns SESSION#old even though it expired a minute ago. That is exactly what happens on AWS too, because the TTL sweeper runs whenever it gets round to it. The filtered query is what our application should actually use. The last item has expiresAt: 1792496917261, three extra digits, which TTL reads as a date tens of thousands of years from now, so it will never be deleted.

Why

Trade-offs

Gotchas


31 · DynamoDB Streams / CDC

Code: 31-streams-cdc.py · run with just run 31

Problem

When an item changes, other systems often need to know about it: a search index, a cache, an analytics pipeline, another service. The tempting approach is for the application to write to DynamoDB and then call those systems. But if the process crashes between the two calls, they are out of sync, and nothing will ever notice. This is the classic dual write problem.

Pattern

We turn on DynamoDB Streams. Every insert, update and delete is written to an ordered, durable change log that is kept for 24 hours. A consumer (usually a Lambda) reads that log and pushes changes downstream. The table write becomes the single source of truth, and the stream acts as a built-in transactional outbox.

flowchart LR
    App -->|PutItem / UpdateItem / DeleteItem| T[(DynamoDB table)]
    T -->|change log, 24h| S[Stream]
    S --> L[Consumer / Lambda]
    L --> Search[Search index]
    L --> Cache[Cache]
    L --> Analytics[Analytics]

When we enable a stream we choose what each record carries:

Stream view type Record contains Use for
KEYS_ONLY keys only "something changed, re-read it"
NEW_IMAGE item after the change search sync, replication
OLD_IMAGE item before the change auditing deletes
NEW_AND_OLD_IMAGES both diffs, aggregates (32)

In AWS we don't poll the stream by hand. We attach a Lambda through an event source mapping:

Python
lambda_client.create_event_source_mapping(
    EventSourceArn=table.latest_stream_arn,
    FunctionName="sync-to-search",
    StartingPosition="LATEST",
    BatchSize=100,
    BisectBatchOnFunctionError=True,
    DestinationConfig={"OnFailure": {"Destination": dlq_arn}},
)

In practice

DynamoDB Local supports Streams, so this one runs locally as-is. The script writes, updates and deletes an order. It never calls a second system. Then it reads the stream by hand (describe → shards → iterator → records), the way a Lambda trigger would for us, and replays it into a dict that stands in for a search index.

Python
table = recreate_table("p31-streams-cdc", stream="NEW_AND_OLD_IMAGES")

def handle(record):
    event = record["eventName"]  # INSERT | MODIFY | REMOVE
    keys = record["dynamodb"]["Keys"]
    doc_id = f'{keys["PK"]["S"]}|{keys["SK"]["S"]}'
    if event == "REMOVE":
        search_index.pop(doc_id, None)
    else:
        search_index[doc_id] = record["dynamodb"]["NewImage"]
>>> PutItem p31-streams-cdc
    item:       {PK: 'ORDER#o1', SK: 'META', status: 'PLACED', total: 42}

>>> UpdateItem p31-streams-cdc
    key:        {PK: 'ORDER#o1', SK: 'META'}
    update:     SET status = 'SHIPPED'

>>> DeleteItem p31-streams-cdc
    key:        {PK: 'ORDER#o1', SK: 'META'}

=== Processing stream records ===

>>> DescribeStream

>>> GetShardIterator

>>> GetRecords
    Limit:      100
INSERT  ORDER#o1|META  status: None -> PLACED  index size=1
MODIFY  ORDER#o1|META  status: PLACED -> SHIPPED  index size=1
REMOVE  ORDER#o1|META  status: SHIPPED -> None  index size=0

>>> GetRecords
    Limit:      100

=== Search index after replay ===
{}

Three table writes produce three stream records, in order, each with the before and after status. The consumer adds the order to the index, updates it, then removes it, so the index ends up empty, matching the table. The second, empty GetRecords is the consumer catching up. A real consumer would keep polling.

Why

Trade-offs

Gotchas


32 · Streams-maintained aggregates

Code: 32-streams-aggregates.py · run with just run 32

Problem

"How many orders does this store have, and what's the revenue?" is a perfectly normal question, but DynamoDB has no COUNT(*) or SUM(). Computing the answer at read time means querying every order item and adding them up in the client, which gets slower and more expensive as the data grows.

Pattern

We store the answer as its own item and keep it current from the stream (31). Writers only ever write orders. A stream consumer turns each change into a delta and applies it with an atomic ADD (24 · Atomic counters).

PK SK type attributes
STORE#s1 ORDER#o1 Order total=42
STORE#s1 ORDER#o2 Order total=20
STORE#s1 STATS — orderCount=2, revenue=62

With NEW_AND_OLD_IMAGES on the stream, every record tells us exactly how the totals should move:

Reading the aggregate is then a single GetItem(PK=STORE#s1, SK=STATS).

In practice

The script writes three orders, changes o2's total from 17 to 20, and deletes o3. Then the consumer drains the stream and applies each change as a delta:

Python
def apply(record):
    img_old = record["dynamodb"].get("OldImage", {})
    img_new = record["dynamodb"].get("NewImage", {})
    if (img_new or img_old).get("type", {}).get("S") != "Order":
        return  # ignore our own aggregate writes (they're on the stream too!)

    old_total = int(img_old["total"]["N"]) if img_old else 0
    new_total = int(img_new["total"]["N"]) if img_new else 0
    count_delta = {"INSERT": 1, "REMOVE": -1}.get(record["eventName"], 0)

    store = (img_new or img_old)["PK"]["S"]
    table.update_item(
        Key={"PK": store, "SK": "STATS"},
        UpdateExpression="ADD orderCount :c, revenue :r",
        ExpressionAttributeValues={":c": count_delta, ":r": new_total - old_total},
    )
>>> PutItem p32-streams-aggregates
    item:       {PK: 'STORE#s1', SK: 'ORDER#o1', type: 'Order', total: 42}
...
>>> UpdateItem p32-streams-aggregates
    key:        {PK: 'STORE#s1', SK: 'ORDER#o2'}
    update:     SET total = 20

>>> DeleteItem p32-streams-aggregates
    key:        {PK: 'STORE#s1', SK: 'ORDER#o3'}

=== Consuming stream ===

>>> DescribeStream

>>> GetShardIterator

>>> GetRecords
    Limit:      100

>>> UpdateItem p32-streams-aggregates
    key:        {PK: 'STORE#s1', SK: 'STATS'}
    update:     ADD orderCount 1, revenue 42
INSERT  count +1  revenue +42

>>> UpdateItem p32-streams-aggregates
    key:        {PK: 'STORE#s1', SK: 'STATS'}
    update:     ADD orderCount 1, revenue 17
INSERT  count +1  revenue +17

>>> UpdateItem p32-streams-aggregates
    key:        {PK: 'STORE#s1', SK: 'STATS'}
    update:     ADD orderCount 1, revenue 99
INSERT  count +1  revenue +99
MODIFY  count +0  revenue +3
REMOVE  count -1  revenue -99
    ... (+2 more UpdateItem calls not shown)

>>> GetItem p32-streams-aggregates
    key:        {PK: 'STORE#s1', SK: 'STATS'}

=== Aggregate item (read with one GetItem) ===
{
  "SK": "STATS",
  "orderCount": 2,
  "revenue": 62,
  "PK": "STORE#s1"
}

>>> Query p32-streams-aggregates
    key:        (PK = 'STORE#s1' AND begins_with(SK, 'ORDER#'))

=== Recomputed from orders ===
{
  "orderCount": 2,
  "revenue": 62
}

Each record becomes one ADD: three inserts, then the MODIFY adds only the difference (+3), and the REMOVE takes o3 back out. (The request log collapses the last two UpdateItem calls, but their deltas are printed.) The aggregate item reads orderCount: 2, revenue: 62, which matches the slow way of querying every order and summing.

Why

Trade-offs

Gotchas


33 · Soft delete & archival

Code: 33-soft-delete-archival.py · run with just run 33

Problem

Users delete things and then want them back ("trash", "undo"). Compliance may require us to keep deleted records somewhere. At the same time, we don't want deleted items cluttering our queries, and we don't want to pay to store them in DynamoDB forever.

Pattern

We do it in three stages, each building on a pattern we've already seen:

  1. Soft delete. An UpdateItem sets deletedAt, sets a TTL attribute (expiresAt), and REMOVEs the GSI key attributes. Because GSIs are sparse (10 · Sparse index), the item disappears from list views straight away, but it is still readable by key for undo or a trash view.
  2. Hard delete. When expiresAt passes, DynamoDB TTL deletes the item for free (30).
  3. Archive. A stream consumer (31) sees the REMOVE and writes the old image to S3, where it's cheap to keep and queryable with Athena.
flowchart LR
    A[Active item<br/>in GSI] -->|soft delete:<br/>SET deletedAt, expiresAt<br/>REMOVE GSI keys| B[Soft-deleted<br/>readable by key]
    B -->|undo| A
    B -->|TTL expires| C[Deleted]
    C -->|stream REMOVE<br/>with OLD_IMAGE| D[(S3 archive)]
PK SK title GSI1PK deletedAt expiresAt
DOC#d1 META Q3 plan FOLDER#work
DOC#d2 META Old draft (removed) 1792494255 +30 days

Undo is just the reverse update: SET GSI1PK = :folder, GSI1SK = :sk REMOVE deletedAt, expiresAt.

In practice

DynamoDB Local accepts UpdateTimeToLive but never actually expires items, so the script simulates the hard delete with a plain DeleteItem, and archives into a dict rather than S3. In AWS the consumer would call:

Python
s3.put_object(Bucket="archive", Key=..., Body=json.dumps(old_image))

The soft delete itself is one update:

Python
RETENTION = 30 * 24 * 3600
table.update_item(
    Key={"PK": "DOC#d2", "SK": "META"},
    UpdateExpression="SET deletedAt = :now, expiresAt = :exp REMOVE GSI1PK, GSI1SK",
    ExpressionAttributeValues={":now": int(time.time()), ":exp": int(time.time()) + RETENTION},
)
>>> Query p33-soft-delete-archival
    key:        GSI1PK = 'FOLDER#work'
    IndexName:  GSI1

=== Folder before delete ===
[
  "Q3 plan",
  "Old draft",
  "Budget"
]

>>> UpdateItem p33-soft-delete-archival
    key:        {PK: 'DOC#d2', SK: 'META'}
    update:     SET deletedAt = 1792496918, expiresAt = 1795088918 REMOVE GSI1PK, GSI1SK

>>> Query p33-soft-delete-archival
    key:        GSI1PK = 'FOLDER#work'
    IndexName:  GSI1

=== Folder after soft delete (d2 hidden) ===
[
  "Q3 plan",
  "Budget"
]

>>> GetItem p33-soft-delete-archival
    key:        {PK: 'DOC#d2', SK: 'META'}

=== d2 is still there for undo / trash view ===
{
  "SK": "META",
  "deletedAt": 1792496918,
  "PK": "DOC#d2",
  "title": "Old draft",
  "expiresAt": 1795088918
}

>>> DeleteItem p33-soft-delete-archival
    key:        {PK: 'DOC#d2', SK: 'META'}

>>> DescribeStream

>>> GetShardIterator

>>> GetRecords
archived archive/DOC#d2.json (deleted by TTL: False)

>>> GetRecords

=== Fake S3 bucket ===
{
  "archive/DOC#d2.json": "{\"deletedAt\": {\"N\": \"1792496918\"}, \"SK\": {\"S\": \"META\"}, \"PK\": {\"S\": \"DOC#d2\"}, \"title\": {\"S\": \"Old draft\"}, \"expiresAt\": {\"N\": \"1795088918\"}}"
}

After the soft delete, "Old draft" vanishes from the folder listing without any filter on the index query, yet a GetItem by key still finds it with its deletedAt and expiresAt (30 days out). When it is finally deleted, the stream consumer archives the old image. Here it reports deleted by TTL: False because we deleted it ourselves. On AWS, a real TTL expiry would report True.

Why

Trade-offs

Gotchas


Next up: 07 - Performance & scale, where we look at what happens when too much traffic lands on too few keys.


← 05 - Consistency & concurrency | 07 - Performance & scale →