
Photo by mali maeder on Pexels
DynamoDB TTL not enabled means expired sessions, tokens and events stay in the table, and you pay $0.25 per GB-month to store them (Standard class, us-east-1, September 2026). Find these tables with ListTables and DescribeTimeToLive, look for a Number attribute holding epoch seconds, then call UpdateTimeToLive. TTL deletes don’t consume write throughput.
Tables that hold short-lived data grow forever unless something deletes old items. Time to Live (TTL) is that something: you name an attribute, store an expiry time in it, and DynamoDB removes expired items in the background. This example is for engineers and FinOps reviewers who want every table where DynamoDB TTL is not enabled, what those tables cost to store, and a hint about which attribute could become the TTL attribute.
The script reports by default. It enables TTL only on the one table you name with --apply --table --attribute, because choosing an expiry attribute is a data-retention decision for the table’s owner.
Why does it matter when DynamoDB TTL is not enabled?
Storage is the part of a DynamoDB bill that never goes down on its own. As of September 2026, the AWS Price List for Amazon DynamoDB in US East (N. Virginia), published 11 September 2026, lists:
| DynamoDB storage (us-east-1) | Price |
|---|---|
| Standard table class, first 25 GB-month | $0.00 |
| Standard table class, beyond 25 GB-month | $0.25 per GB-month |
| Standard-Infrequent Access table class | $0.10 per GB-month |
Worked example: a session table gains 15 GB a month, and sessions are useless after 7 days. Without TTL, the bill grows by 15 × $0.25 = $3.75 every month; after a year the table holds 180 GB and costs 180 × $0.25 = $45.00 a month (before the 25 GB free tier), almost all of it dead sessions. With TTL, the table levels off at roughly a week of data, about 3.5 GB. Global secondary indexes store their own copies of projected attributes, so they grow with it.
Deleting old items yourself costs write capacity. TTL doesn’t: the DynamoDB TTL documentation says expired items are deleted within a few days of their expiry time without consuming write throughput. One exception: with global tables, the replicated deletes in other Regions consume replicated write capacity. TTL removes old items; if tables are unused altogether, finding unused DynamoDB tables is the right report.
What makes a good TTL attribute?
- A Number in epoch seconds. Items whose TTL attribute isn’t a Number are ignored. A value in milliseconds is a valid Number, but it reads as a date thousands of years away, so those items never expire.
- Not more than five years in the past. The docs say a TTL further back than that isn’t considered for deletion.
- Written on every insert. TTL only deletes items that carry the attribute. Existing items need a backfill; updating DynamoDB items with UpdateItem in SDK v3 shows the
SETexpression to add it. - Case-sensitive and exact. The name you enable must match the attribute your code writes. To rename it you disable TTL and enable it again.
The script samples a few items per table and reports Number attributes whose values fall between 2000 and 2100 in epoch seconds, attributes in milliseconds, and expiry-named attributes stored as date strings. It also flags table names that suggest short-lived data (sessions, tokens, events, logs, locks, rate limits).
What does the script do?
- Lists tables
paginateListTablesfor each Region. - Checks TTL
DescribeTimeToLivereturnsENABLED,ENABLING,DISABLEDorDISABLING. Tables with TTL on or turning on are skipped. - Sizes the rest
DescribeTablegivesTableSizeBytes,ItemCountand the table class. DynamoDB updates the size about every six hours, so it lags a little. - Samples itemsA
ScanwithLimit: 20(change it with--sample, or 0 to skip) reads only those items. - Enables TTL on requestWith
--apply --table --attribute, callsUpdateTimeToLivefor that table.
Prerequisites
- Node.js 18 or later with
tsxand@aws-sdk/client-dynamodb. - A read-only profile for the report; connecting AWS profiles, SSO and assumed roles covers setup. The sample scan reads item data, so use a role that is allowed to see it.
- For
--apply: code that already writes the TTL attribute on new items, and a backfill plan for old ones.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListTables",
"Effect": "Allow",
"Action": "dynamodb:ListTables",
"Resource": "*"
},
{
"Sid": "InspectTables",
"Effect": "Allow",
"Action": ["dynamodb:DescribeTimeToLive", "dynamodb:DescribeTable", "dynamodb:Scan"],
"Resource": "arn:aws:dynamodb:*:123456789012:table/*"
},
{
"Sid": "EnableTtlOnlyWithApply",
"Effect": "Allow",
"Action": "dynamodb:UpdateTimeToLive",
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/user-sessions"
}
]
}
Drop dynamodb:Scan if you run with --sample 0, and drop the last statement for report-only use. The IAM policy generator for TypeScript code can derive a policy from your own changes to the script.
The script to find DynamoDB tables without TTL
// find-dynamodb-tables-without-ttl.ts
// Lists DynamoDB tables where Time to Live isn't enabled, prices their storage, and samples a few items
// to spot attributes that already hold epoch-second timestamps (ready-made TTL attributes).
// Report only by default. With --apply --table --attribute it enables TTL on one table.
// Usage: npx tsx find-dynamodb-tables-without-ttl.ts [--regions us-east-1,eu-west-1] [--sample 20]
// npx tsx find-dynamodb-tables-without-ttl.ts --regions us-east-1 --apply --table user-sessions --attribute expiresAt
import {
DynamoDBClient,
paginateListTables,
DescribeTimeToLiveCommand,
DescribeTableCommand,
ScanCommand,
UpdateTimeToLiveCommand,
type AttributeValue,
} from "@aws-sdk/client-dynamodb";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const sampleSize = Math.min(Math.max(Number(flag("--sample") ?? 20), 0), 100);
const apply = args.includes("--apply");
const applyTable = flag("--table");
const applyAttribute = flag("--attribute");
// us-east-1 storage prices, AWS Price List for DynamoDB published 11 September 2026 (the first 25 GB-month is free).
const PRICE_GB_STANDARD = 0.25;
const PRICE_GB_IA = 0.1;
const TEMPORARY_NAME = /session|token|otp|cache|event|log|audit|history|notification|job|lock|idempot|rate.?limit|temp|tmp/i;
const EXPIRY_NAME = /^(ttl|expires?(_?at|_?on)?|expir(y|ation)(_?time|_?date)?|valid_?until|purge_?at|delete_?at)$/i;
const MIN_EPOCH_S = 946_684_800; // 2000-01-01
const MAX_EPOCH_S = 4_102_444_800; // 2100-01-01
interface Row {
Table: string;
TTL: string;
SizeGB: string;
Items: number;
StoragePerMonth: string;
EpochAttributes: string;
Hint: string;
cost: number;
}
const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
/** Numbers that look like epoch seconds (TTL-ready) or milliseconds (TTL would never expire them). */
function epochAttributes(items: Record<string, AttributeValue>[]): { seconds: string[]; millis: string[]; dateStrings: string[] } {
const seen = new Map<string, { s: number; ms: number; str: number }>();
for (const item of items) {
for (const [name, value] of Object.entries(item)) {
const stats = seen.get(name) ?? { s: 0, ms: 0, str: 0 };
if (value.N !== undefined) {
const n = Number(value.N);
if (n >= MIN_EPOCH_S && n <= MAX_EPOCH_S) stats.s++;
else if (n >= MIN_EPOCH_S * 1000 && n <= MAX_EPOCH_S * 1000) stats.ms++;
} else if (value.S !== undefined && EXPIRY_NAME.test(name) && !Number.isNaN(Date.parse(value.S))) {
stats.str++;
}
seen.set(name, stats);
}
}
const half = Math.max(1, Math.ceil(items.length / 2));
const pick = (key: "s" | "ms" | "str") => [...seen].filter(([, v]) => v[key] >= half).map(([k]) => k);
return { seconds: pick("s"), millis: pick("ms"), dateStrings: pick("str") };
}
async function inspect(ddb: DynamoDBClient, table: string): Promise<Row | undefined> {
const ttl = await ddb.send(new DescribeTimeToLiveCommand({ TableName: table }));
const status = ttl.TimeToLiveDescription?.TimeToLiveStatus ?? "DISABLED";
if (status === "ENABLED" || status === "ENABLING") return undefined;
const desc = (await ddb.send(new DescribeTableCommand({ TableName: table }))).Table;
const bytes = desc?.TableSizeBytes ?? 0;
const gb = bytes / 1024 ** 3;
const ia = desc?.TableClassSummary?.TableClass === "STANDARD_INFREQUENT_ACCESS";
const cost = gb * (ia ? PRICE_GB_IA : PRICE_GB_STANDARD);
const hints: string[] = [];
let attrs = "-";
if (sampleSize > 0 && (desc?.ItemCount ?? 0) > 0) {
const sample = await ddb.send(new ScanCommand({ TableName: table, Limit: sampleSize }));
const found = epochAttributes(sample.Items ?? []);
attrs = found.seconds.join(", ") || "-";
const named = found.seconds.find((a) => EXPIRY_NAME.test(a));
if (named) hints.push(`'${named}' holds epoch seconds: enable TTL on it`);
if (found.millis.length) hints.push(`${found.millis.join(", ")} in milliseconds: TTL needs seconds`);
if (found.dateStrings.length) hints.push(`${found.dateStrings.join(", ")} is a date string: TTL needs a Number`);
}
if (TEMPORARY_NAME.test(table)) hints.push("name suggests short-lived data");
if (status === "DISABLING") hints.push("TTL is being turned off");
return {
Table: table,
TTL: status,
SizeGB: gb.toFixed(2),
Items: desc?.ItemCount ?? 0,
StoragePerMonth: `$${cost.toFixed(2)}`,
EpochAttributes: attrs,
Hint: hints.join("; ") || "no expiry pattern found: check with the owner",
cost,
};
}
async function enableTtl(ddb: DynamoDBClient): Promise<void> {
try {
const res = await ddb.send(
new UpdateTimeToLiveCommand({ TableName: applyTable, TimeToLiveSpecification: { Enabled: true, AttributeName: applyAttribute } }),
);
console.log(`${applyTable}: TTL enabled on '${res.TimeToLiveSpecification?.AttributeName}'. It takes about an hour to apply to all partitions.`);
} catch (err) {
// UpdateTimeToLive isn't idempotent: it returns ValidationException if TTL is already on or was changed in the last hour.
console.error(`${applyTable}: ${errorText(err)}`);
}
}
async function scanRegion(region: string): Promise<void> {
const ddb = new DynamoDBClient({ region });
const rows: Row[] = [];
let total = 0;
for await (const page of paginateListTables({ client: ddb }, {})) {
for (const table of page.TableNames ?? []) {
total++;
try {
const row = await inspect(ddb, table);
if (row) rows.push(row);
} catch (err) {
console.error(`${region} ${table}: ${errorText(err)}`);
}
}
}
rows.sort((a, b) => b.cost - a.cost);
console.log(`\n${region}: ${rows.length} of ${total} tables have TTL not enabled`);
if (rows.length) console.table(rows.map(({ cost: _cost, ...visible }) => visible));
const storage = rows.reduce((sum, r) => sum + r.cost, 0);
console.log(`Storage on tables without TTL: $${storage.toFixed(2)} a month before the free tier (table data only; indexes are extra).`);
if (apply) await enableTtl(ddb);
}
async function main(): Promise<void> {
if (apply && (!applyTable || !applyAttribute || regions.length !== 1)) {
console.error("--apply needs --table <name> --attribute <name> and exactly one region in --regions");
process.exit(2);
}
for (const region of regions) {
try {
await scanRegion(region);
} catch (err) {
console.error(`${region}: ${errorText(err)}`);
}
}
}
main().catch((err) => {
console.error(errorText(err));
process.exit(1);
});
The sample is a single Scan page, so it costs a handful of read units per table. For reading specific items instead, the guide to querying DynamoDB with AWS SDK v3 covers Query and GetItem.
How do you run it?
npm install @aws-sdk/client-dynamodb
npm install --save-dev tsx typescript @types/node
# Report only, two Regions
AWS_PROFILE=readonly npx tsx find-dynamodb-tables-without-ttl.ts --regions us-east-1,eu-west-1
# Report without reading any items
AWS_PROFILE=readonly npx tsx find-dynamodb-tables-without-ttl.ts --regions us-east-1 --sample 0
# Enable TTL on one table after the owner agrees
AWS_PROFILE=data-admin npx tsx find-dynamodb-tables-without-ttl.ts --regions us-east-1 --apply --table user-sessions --attribute expiresAt
Sample output
us-east-1: 4 of 5 tables have TTL not enabled
┌─────────┬─────────────────┬────────────┬──────────┬────────────┬─────────────────┬─────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Table │ TTL │ SizeGB │ Items │ StoragePerMonth │ EpochAttributes │ Hint │
├─────────┼─────────────────┼────────────┼──────────┼────────────┼─────────────────┼─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'user-sessions' │ 'DISABLED' │ '180.00' │ 950000000 │ '$45.00' │ 'expiresAt' │ "'expiresAt' holds epoch seconds: enable TTL on it; name suggests short-lived data" │
│ 1 │ 'api-audit-log' │ 'DISABLED' │ '310.00' │ 1400000000 │ '$31.00' │ '-' │ 'ts in milliseconds: TTL needs seconds; expiry is a date string: TTL needs a Number; name suggests short-lived data' │
│ 2 │ 'orders' │ 'DISABLED' │ '42.00' │ 61000000 │ '$10.50' │ 'createdAt' │ 'no expiry pattern found: check with the owner' │
│ 3 │ 'rate-limits' │ 'DISABLED' │ '0.40' │ 2000000 │ '$0.10' │ '-' │ 'name suggests short-lived data' │
└─────────┴─────────────────┴────────────┴──────────┴────────────┴─────────────────┴─────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Storage on tables without TTL: $86.60 a month before the free tier (table data only; indexes are extra).
user-sessions: TTL enabled on 'expiresAt'. It takes about an hour to apply to all partitions.
This run used mocked DynamoDB responses. user-sessions is the worked example: expiresAt already holds epoch seconds, so enabling TTL on it is the whole fix. api-audit-log needs code changes first: its timestamps are in milliseconds and its expiry is an ISO string, neither of which TTL will act on. orders has only a createdAt and probably shouldn’t expire at all, which is why the hint says to ask the owner. idempotency-keys already has TTL and isn’t listed.
What should you check before you enable TTL?
- Retention rules. Audit and billing data may have to be kept for years. TTL deletes are permanent; enabling DynamoDB point-in-time recovery gives you a restore window if an expiry value was wrong.
- Reads of expired items. Expired items can still be returned until DynamoDB deletes them, typically within a few days. Filter them out in queries and scans with a condition on the TTL attribute.
- Stream consumers. TTL deletes appear in DynamoDB Streams as service deletions. If a Lambda function reacts to
REMOVEevents, check what it does with them; processing DynamoDB Streams in a TypeScript Lambda shows the record shape. - Timing. Enabling takes about an hour to reach all partitions, and
UpdateTimeToLivecan’t be called twice for the same table within an hour. - Capacity. Once old items are gone, a provisioned table may need less read capacity for scans; calculating overprovisioned DynamoDB read and write capacity shows how to check.
The same retention idea applies across storage services: finding S3 buckets without lifecycle rules and setting CloudWatch Logs retention for all log groups are the equivalents for objects and logs.
Troubleshooting
ValidationExceptionfromUpdateTimeToLive. The DynamoDB guide says the call isn’t idempotent: it returns this when TTL is already enabled, when it was changed within the last hour, or when you try a different attribute while TTL is active.- TTL is
ENABLEDbut nothing is deleted. Check the attribute’s type and unit on a few items. Strings and millisecond values are the usual causes, followed by items that never had the attribute. - Table sizes look stale.
TableSizeBytesandItemCountrefresh about every six hours. - Access denied on
Scan. Some tables deny item reads to most roles. Rerun with--sample 0, or see troubleshooting AWS IAM access denied errors.
Ask ChatWithCloud instead
For a quick answer, ask ChatWithCloud “Which DynamoDB tables in this Region don’t have TTL enabled, and how big are they?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the JSON; how ChatWithCloud runs AWS queries on your machine explains the loop. It can be wrong and runs changes without a confirmation step, so use a read-only profile and enable TTL yourself. If a growing DynamoDB line is what sent you here, asking AI why your AWS bill increased helps confirm it. More reports live in the AWS practical examples library.
Frequently asked questions
How do I check if TTL is enabled on a DynamoDB table?
Call DescribeTimeToLive (or aws dynamodb describe-time-to-live). TimeToLiveStatus is ENABLED, ENABLING, DISABLED or DISABLING, and AttributeName shows the attribute in use.
Do DynamoDB TTL deletes cost anything?
TTL deletes don’t consume write throughput in the table’s Region. With global tables, the replicated deletes in other Regions are charged as replicated writes.
How long does DynamoDB take to delete expired items?
Typically within a few days of the expiry time. Until then the items can still appear in reads, so filter on the TTL attribute.
Can the TTL attribute be in milliseconds?
No. It must be a Number in epoch seconds. A millisecond value looks like a date far in the future, so the item is never deleted.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud