Skip to content

DynamoDB TTL: Soft Deletes, Expiry, and the Patterns Around Them

DynamoDB TTL deletes items after a timestamp. That’s the whole description, and it undersells the feature badly. You can use TTL to expire sessions, soft-delete records, enforce retention policies, fan out archival writes via Streams, and keep tables small without writing any cleanup code.

The two things you have to understand are the 48-hour deletion lag and the fact that TTL fires the Stream record on delete. Once those two facts are second nature, TTL becomes a real design tool, not just a cleanup tool.

The mechanics, in 60 seconds

You designate one numeric attribute as the TTL attribute (typically ttl or expiresAt). You write a Unix epoch timestamp (seconds, not milliseconds) into that attribute. DynamoDB scans for expired items continuously and deletes them in the background.

const SEVEN_DAYS = 7 * 24 * 60 * 60
await SessionEntity.put({
  sessionId,
  userId,
  ttl: Math.floor(Date.now() / 1000) + SEVEN_DAYS,
}).go()

Three details that catch people:

  1. Seconds, not milliseconds. Use Math.floor(Date.now() / 1000), not Date.now(). If you set milliseconds, the TTL is interpreted as a date thousands of years in the future and never fires.
  2. Deletion is best-effort, within ~48 hours of expiry. Items are eventually deleted, not immediately. Most fire within an hour, but you cannot rely on it.
  3. TTL doesn’t charge for deletes. Normal DeleteItem costs WCU. TTL deletes are free.

That last point is what makes TTL a design tool, not just a cleanup tool.

Pattern 1: Pure expiry (the easy case)

Anything that genuinely doesn’t need to exist after a deadline:

  • Session tokens: 24-hour TTL, deleted automatically when the user logs out (set TTL low) or naturally expires
  • Email verification codes: 1-hour TTL
  • Pending invitations: 7-day TTL after which the invite is gone for good
  • OTP / 2FA codes: 10-minute TTL
  • Cache entries: whatever your cache TTL would be in Redis

The pattern is just “set TTL on insert, never touch it again.” No cleanup job, no cron, no Lambda.

The 48-hour lag matters here only if you have business logic that says “after expiry, the item must be gone.” If a user’s expired session token is still in the table for an extra few hours, your code reading it should already check the timestamp - so the lag is invisible.

Pattern 2: Read-after-expiry filtering

If your code can safely query a slightly-stale TTL set, just query it. If not, filter on the timestamp at read time as a backstop:

const result = await SessionEntity.query
  .byUser({ userId })
  .where(({ ttl }, { gt }) => gt(ttl, nowEpoch()))
  .go()

Items that have logically expired but haven’t been physically deleted yet are filtered out. Cost: you pay RCU for the filtered items (filter happens after the read), but you don’t have to wait on TTL deletion.

This is the standard pattern. TTL handles eventual physical cleanup; the read-time filter handles freshness. Your storage stays bounded; your queries are correct immediately.

Pattern 3: Soft delete with permanent purge

The most common “user-facing delete” pattern in modern apps: don’t actually remove anything immediately. Set a deletedAt timestamp, mark the item, and let TTL hard-delete it after some retention window (30 days, 90 days, whatever).

async function softDelete(articleId: string) {
  const now = new Date()
  const THIRTY_DAYS = 30 * 24 * 60 * 60
  await ArticleEntity.update({ articleId })
    .set({
      deletedAt: now.toISOString(),
      ttl: Math.floor(now.getTime() / 1000) + THIRTY_DAYS,
    })
    .go()
}

Three things you get for free:

  1. Restore window. A user who deletes by accident has 30 days to recover. Restore = clear deletedAt and ttl.
  2. No cleanup job. The 30-day retention is enforced by DynamoDB itself.
  3. A sparse index on deletedAt gives you a “recently deleted” view without indexing live items. The sparse index pattern is the partner technique here.

You also need a read-time filter on deletedAt so soft-deleted items don’t appear in normal queries. The same filter pattern as above:

ArticleEntity.query.byAuthor({ authorId })
  .where(({ deletedAt }, { notExists }) => notExists(deletedAt))
  .go()

Pattern 4: Retention windows for compliance

Some data has to be kept for a regulatory window and then irrevocably deleted. GDPR-style “right to be forgotten” deadlines, financial records with X-year retention, audit logs that must purge after Y months.

TTL is the mechanism. Set the TTL at write time to the retention window:

const ONE_YEAR = 365 * 24 * 60 * 60
await AuditEntity.put({
  eventId,
  userId,
  action,
  ttl: Math.floor(Date.now() / 1000) + ONE_YEAR, // hard-delete after 1 year
}).go()

Two cautions:

  • Document the 48-hour lag. Your compliance officer needs to know that “deleted at the 1-year mark” actually means “deleted within 48 hours of the 1-year mark.” Most regulations are fine with this; some aren’t.
  • TTL doesn’t delete from S3 or backups. If you have point-in-time recovery enabled or you’re streaming to S3 (next pattern), the data lingers there until you explicitly purge those.

Pattern 5: Archive on expiry via Streams

When an item TTLs out, DynamoDB writes a REMOVE record to the Stream with userIdentity.principalId = "dynamodb.amazonaws.com" (this is how you distinguish a TTL delete from a user-initiated delete). You can route that Stream record to:

  • S3 via Firehose, for long-term archive
  • Another DynamoDB table for cold storage at lower throughput
  • OpenSearch / Athena if you want querying after the live row is gone
  • A Lambda that writes a tombstone record so future code knows the item used to exist
// Lambda triggered by DynamoDB Stream
export async function handler(event: DynamoDBStreamEvent) {
  for (const record of event.Records) {
    if (record.eventName !== "REMOVE") continue
    if (record.userIdentity?.principalId !== "dynamodb.amazonaws.com") continue
    // This is a TTL-driven delete - archive it
    const oldImage = unmarshall(record.dynamodb.OldImage)
    await archiveToS3(oldImage)
  }
}

This is how you get “old IoT readings flow to S3 for cheap querying” without writing a custom cleanup pipeline. The IoT time-series pattern uses exactly this pattern. DynamoDB Streams: fan-out, CDC, and projections goes deeper on the streaming side.

Pattern 6: Sliding TTL (touch-to-extend)

Sessions that should expire only after inactivity, not at a fixed time. Each access updates the TTL forward:

async function touchSession(sessionId: string) {
  const FIFTEEN_MIN = 15 * 60
  await SessionEntity.update({ sessionId })
    .set({ ttl: Math.floor(Date.now() / 1000) + FIFTEEN_MIN })
    .go()
}

Run this on every authenticated request and the session keeps living as long as the user is active.

The trade-off: every request now writes. For high-frequency endpoints, you don’t want to update TTL on every call - throttle it (e.g. only update if the existing TTL is more than 5 minutes old). This is a common edge of correctness vs cost.

Common bugs

Setting TTL in milliseconds means items never expire. The TTL value has to be in seconds.

Watch what happens with items that should never expire. If you sometimes write ttl: undefined and sometimes ttl: <timestamp>, be sure you understand: items with the attribute missing never expire (TTL ignores items without the attribute). Items with ttl: 0 are immediately expired. Don’t accidentally write ttl: null.

The read-time filter is easy to forget. Soft-deleted items show up in queries until TTL fires. Always filter at read time on deletedAt (sparse) or ttl > now (best-effort fresh).

Don’t rely on TTL for time-critical deletes. “Delete this user’s data within 1 hour of cancellation” is not a TTL job. Use an explicit DeleteItem (or batch writes) and treat TTL as a backstop, not the primary mechanism.

TTL deletes look like regular deletes in Streams - same eventName: "REMOVE" - but the userIdentity block is different. If your downstream cares about distinguishing them (audit logs often do), check userIdentity.principalId.

Mental model

TTL is a free background job that does one thing: deletes items where attribute >= threshold. Anything you want done at the moment of expiry - notify the user, archive elsewhere, decrement a counter - has to happen via the Stream. Anything you want enforced before the TTL physically fires has to happen via a read-time filter.

In a single-table design, TTL slots cleanly into the workflow: a sparse index for the live “recently deleted” or “expiring soon” view; a read-time filter for correctness during the eventual-consistency window; a Stream + Lambda for the side-effects of expiry; and TTL itself for the cheap physical cleanup.

When all four work together, you get a system that ages out old data, exposes the right surface to user code, archives what needs archiving, and never grows unbounded - all without scheduled jobs.


The cheapest GB you can store in DynamoDB is the one you’ve already TTL’d out. The patterns at singletable.dev lean on TTL where it pays - analytics events, IoT readings, and chat messages all combine TTL with sparse indexes for retention windows. I’m building singletable.dev to make these design choices visible at schema time.

Tejovanth N

Tejovanth builds on DynamoDB in production: rasika.life, rekha.app, rrmstays. All single-table with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.