Photo by Tianlei Wu on Unsplash
To find unused DynamoDB tables, list them with ListTables, read size and capacity with DescribeTable, then sum the CloudWatch metrics ConsumedReadCapacityUnits and ConsumedWriteCapacityUnits over 30 days for each table and each global secondary index. A table where every sum is zero had no reads or writes in that window, yet still pays for storage and any provisioned capacity.
DynamoDB tables are easy to create and easy to forget: a feature flag store from a retired service, a load-test table, the staging copy of a migration. On-demand tables that nobody touches cost only storage, but a provisioned table bills its capacity every hour whether anyone reads it or not. This example is for engineers who want to find unused DynamoDB tables across every Region and put a monthly dollar figure next to each one.
You’ll get a report-only TypeScript script for the AWS SDK for JavaScript v3. It never deletes or changes a table. If a table is in use but oversized, the script to calculate overprovisioned DynamoDB read and write capacity from last month is the right tool instead; this one looks for tables with no traffic at all.
What does an idle DynamoDB table still cost?
An idle table has no request charges, so what’s left is storage, plus provisioned capacity for tables in provisioned mode. These are US East (N. Virginia) list prices for the Standard table class as of September 2026, from the official Amazon DynamoDB pricing page:
| Item | Price | Charged when idle? |
|---|---|---|
| Storage, Standard table class | $0.25 per GB-month | Yes |
| Storage, Standard-IA table class | $0.10 per GB-month | Yes |
| Provisioned read capacity | $0.00013 per RCU-hour | Yes, provisioned mode |
| Provisioned write capacity | $0.00065 per WCU-hour | Yes, provisioned mode |
| On-demand writes / reads | $0.625 per million write request units, $0.125 per million read request units | No |
| Free tier | 25 GB storage, 25 WCU and 25 RCU per Region | Offsets the above |
Other Regions have different prices, and backups, point-in-time recovery, global table replicas and streams are billed separately. The script uses these us-east-1 numbers and ignores the free tier, so treat its dollar column as a ranking, not an invoice.
Worked example. A provisioned table with 10 RCU, 10 WCU and 2 GB of data, using 730 hours in a month:
- Reads: 10 × $0.00013 × 730 = $0.949
- Writes: 10 × $0.00065 × 730 = $4.745
- Storage: 2 × $0.25 = $0.50
- Total: about $6.19 a month, or $74 a year, for a table nobody uses. The same data in on-demand mode would cost $0.50 a month.
Every global secondary index on a provisioned table has its own read and write capacity, which the script adds to the table’s total.
How does the script decide a table is unused?
- Lists tables
ListTables(paginated) in every enabled Region, or the ones you pass with--regions=. - Describes each table
DescribeTablereturnsBillingModeSummary,ProvisionedThroughput,TableSizeBytes,ItemCount,TableClassSummary,DeletionProtectionEnabled,CreationDateTimeand the global secondary indexes. - Sums consumed capacityOne
GetMetricDataquery per table and per index for reads and writes, statisticSum, daily periods over--days(default 30), batched 500 queries per call. - GradesUNUSED if every sum is zero, WRITE ONLY if something writes but nothing reads, TOO NEW if the table is younger than the window, ACTIVE otherwise.
- PricesStorage by table class plus provisioned RCU and WCU for the table and its indexes, per month.
Why the indexes matter
The TableName dimension of ConsumedReadCapacityUnits covers the base table only. A table read exclusively through a Query on a global secondary index shows zero base-table reads, and only the TableName plus GlobalSecondaryIndexName metric reveals the traffic. Skipping the indexes is the most common way a check like this marks a busy table as unused. The guide to query DynamoDB with AWS SDK v3 shows the index queries that produce exactly this pattern.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-dynamodb,@aws-sdk/client-cloudwatchand@aws-sdk/client-ec2. - A profile for the account; the guide to AWS SDK v3 credential providers and profile loading covers SSO and assumed roles.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListRegionsTablesAndMetrics",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"dynamodb:ListTables",
"cloudwatch:GetMetricData"
],
"Resource": "*"
},
{
"Sid": "DescribeTables",
"Effect": "Allow",
"Action": "dynamodb:DescribeTable",
"Resource": "arn:aws:dynamodb:*:*:table/*"
}
]
}
The policy is read-only; nothing in it can read items. The free IAM policy generator for TypeScript AWS SDK code builds a draft like this from your own scripts.
The script to find unused DynamoDB tables
// find-unused-dynamodb-tables.ts
// Finds DynamoDB tables with no consumed reads or writes (table plus global secondary indexes) over the
// last N days, in every enabled Region, and estimates what each one costs per month while idle.
// Report only: it never deletes or changes a table.
// Usage: npx tsx find-unused-dynamodb-tables.ts [--regions=us-east-1,eu-west-1] [--days=30]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import { DynamoDBClient, DescribeTableCommand, paginateListTables } from "@aws-sdk/client-dynamodb";
import { CloudWatchClient, GetMetricDataCommand } from "@aws-sdk/client-cloudwatch";
import type { TableDescription } from "@aws-sdk/client-dynamodb";
import type { MetricDataQuery } from "@aws-sdk/client-cloudwatch";
const args = process.argv.slice(2);
const days = Number(args.find((a) => a.startsWith("--days="))?.split("=")[1] ?? "30");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1];
// US East (N. Virginia) list prices, checked September 2026. Other Regions differ; free tier ignored.
const PRICE = {
storageStandardGb: 0.25,
storageInfrequentAccessGb: 0.1,
rcuHour: 0.00013,
wcuHour: 0.00065,
};
const HOURS_PER_MONTH = 730;
interface Row {
Region: string;
Table: string;
Mode: string;
SizeGB: string;
Items: number;
Reads: number;
Writes: number;
Verdict: string;
IdleUSDPerMonth: string;
}
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg.split(",").map((r) => r.trim()).filter(Boolean);
const out = await new EC2Client({}).send(new DescribeRegionsCommand({})); // enabled Regions only
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
// Monthly cost of keeping the table as it is with zero traffic: storage plus provisioned capacity.
function idleMonthlyCost(t: TableDescription): number {
const gb = (t.TableSizeBytes ?? 0) / 1e9;
const ia = t.TableClassSummary?.TableClass === "STANDARD_INFREQUENT_ACCESS";
let cost = gb * (ia ? PRICE.storageInfrequentAccessGb : PRICE.storageStandardGb);
if (t.BillingModeSummary?.BillingMode !== "PAY_PER_REQUEST") {
const units = [t.ProvisionedThroughput, ...(t.GlobalSecondaryIndexes ?? []).map((g) => g.ProvisionedThroughput)];
for (const u of units) {
cost += (u?.ReadCapacityUnits ?? 0) * PRICE.rcuHour * HOURS_PER_MONTH;
cost += (u?.WriteCapacityUnits ?? 0) * PRICE.wcuHour * HOURS_PER_MONTH;
}
}
return cost;
}
// One query per table and per GSI, for reads and writes. Ids must start with a lowercase letter.
function queriesFor(t: TableDescription, index: number): MetricDataQuery[] {
const targets = [undefined, ...(t.GlobalSecondaryIndexes ?? []).map((g) => g.IndexName)];
const queries: MetricDataQuery[] = [];
targets.forEach((gsi, j) => {
for (const metric of ["ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits"]) {
const dims = [{ Name: "TableName", Value: t.TableName }];
if (gsi) dims.push({ Name: "GlobalSecondaryIndexName", Value: gsi });
queries.push({
Id: `t${index}_${j}_${metric.includes("Read") ? "r" : "w"}`,
MetricStat: { Metric: { Namespace: "AWS/DynamoDB", MetricName: metric, Dimensions: dims }, Period: 86400, Stat: "Sum" },
ReturnData: true,
});
}
});
return queries;
}
async function sums(cw: CloudWatchClient, queries: MetricDataQuery[]): Promise<Map<string, number>> {
const totals = new Map<string, number>();
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
for (let i = 0; i < queries.length; i += 500) {
let NextToken: string | undefined;
do {
const out = await cw.send(new GetMetricDataCommand({ MetricDataQueries: queries.slice(i, i + 500), StartTime: start, EndTime: end, NextToken }));
for (const r of out.MetricDataResults ?? []) {
const total = (r.Values ?? []).reduce((a, b) => a + b, 0);
totals.set(r.Id ?? "", (totals.get(r.Id ?? "") ?? 0) + total);
}
NextToken = out.NextToken;
} while (NextToken);
}
return totals;
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of await listRegions()) {
const ddb = new DynamoDBClient({ region });
try {
const tables: TableDescription[] = [];
for await (const page of paginateListTables({ client: ddb }, {})) {
for (const name of page.TableNames ?? []) {
const { Table } = await ddb.send(new DescribeTableCommand({ TableName: name }));
if (Table) tables.push(Table);
}
}
if (!tables.length) continue;
const queries = tables.flatMap((t, i) => queriesFor(t, i));
const totals = await sums(new CloudWatchClient({ region }), queries);
tables.forEach((t, i) => {
let reads = 0;
let writes = 0;
for (const [id, value] of totals) {
if (!id.startsWith(`t${i}_`)) continue;
if (id.endsWith("_r")) reads += value;
else writes += value;
}
const ageDays = t.CreationDateTime ? (Date.now() - t.CreationDateTime.getTime()) / 86_400_000 : days;
const verdict = ageDays < days ? "TOO NEW" : reads === 0 && writes === 0 ? "UNUSED" : reads === 0 ? "WRITE ONLY" : "ACTIVE";
rows.push({
Region: region,
Table: t.TableName ?? "?",
Mode: t.BillingModeSummary?.BillingMode === "PAY_PER_REQUEST" ? "on-demand" : "provisioned",
SizeGB: ((t.TableSizeBytes ?? 0) / 1e9).toFixed(2),
Items: t.ItemCount ?? 0,
Reads: Math.round(reads),
Writes: Math.round(writes),
Verdict: verdict + (t.DeletionProtectionEnabled ? " (protected)" : ""),
IdleUSDPerMonth: idleMonthlyCost(t).toFixed(2),
});
});
} catch (err) {
rows.push({ Region: region, Table: `ERROR ${err instanceof Error ? err.name : err}`, Mode: "", SizeGB: "", Items: 0, Reads: 0, Writes: 0, Verdict: "", IdleUSDPerMonth: "" });
}
}
const order = ["UNUSED", "WRITE ONLY", "TOO NEW", "ACTIVE"];
const rank = (v: string) => order.indexOf(v.replace(" (protected)", ""));
rows.sort((a, b) => rank(a.Verdict) - rank(b.Verdict) || Number(b.IdleUSDPerMonth) - Number(a.IdleUSDPerMonth));
console.table(rows);
const unused = rows.filter((r) => r.Verdict.startsWith("UNUSED"));
const monthly = unused.reduce((sum, r) => sum + Number(r.IdleUSDPerMonth), 0);
console.log(`${unused.length} table(s) with no reads or writes in ${days} days, about $${monthly.toFixed(2)}/month at us-east-1 list prices.`);
if (unused.length) process.exitCode = 2;
}
main().catch((err) => {
console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-dynamodb @aws-sdk/client-cloudwatch @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Every enabled Region, last 30 days
AWS_PROFILE=cost-audit npx tsx find-unused-dynamodb-tables.ts
# Two Regions, last 90 days (catches monthly and quarterly jobs)
AWS_PROFILE=cost-audit npx tsx find-unused-dynamodb-tables.ts --regions=us-east-1,eu-west-1 --days=90
A 90-day window is safer for tables used by month-end or quarterly jobs. CloudWatch keeps hourly data points for 455 days, so longer windows work too.
Sample output
┌─────────┬─────────────┬─────────────────────┬───────────────┬─────────┬──────────┬──────────┬─────────┬──────────────────────┬─────────────────┐
│ (index) │ Region │ Table │ Mode │ SizeGB │ Items │ Reads │ Writes │ Verdict │ IdleUSDPerMonth │
├─────────┼─────────────┼─────────────────────┼───────────────┼─────────┼──────────┼──────────┼─────────┼──────────────────────┼─────────────────┤
│ 0 │ 'us-east-1' │ 'loadtest-orders' │ 'provisioned' │ '80.00' │ 41200000 │ 0 │ 0 │ 'UNUSED' │ '117.27' │
│ 1 │ 'eu-west-1' │ 'legacy-sessions' │ 'on-demand' │ '12.40' │ 3182044 │ 0 │ 0 │ 'UNUSED (protected)' │ '3.10' │
│ 2 │ 'us-east-1' │ 'audit-events' │ 'on-demand' │ '1.20' │ 912003 │ 0 │ 48213 │ 'WRITE ONLY' │ '0.30' │
│ 3 │ 'us-east-1' │ 'migration-staging' │ 'provisioned' │ '0.35' │ 20511 │ 0 │ 0 │ 'TOO NEW' │ '2.93' │
│ 4 │ 'us-east-1' │ 'orders' │ 'on-demand' │ '45.10' │ 18733120 │ 88412907 │ 4120554 │ 'ACTIVE (protected)' │ '11.28' │
└─────────┴─────────────┴─────────────────────┴───────────────┴─────────┴──────────┴──────────┴─────────┴──────────────────────┴─────────────────┘
2 table(s) with no reads or writes in 30 days, about $120.37/month at us-east-1 list prices.
The names and numbers are illustrative. loadtest-orders is the expensive one: 200 WCU still provisioned from a test, costing $117.27 a month with no traffic. legacy-sessions is on-demand, so it only costs its storage. audit-events receives writes but nobody reads it, which may be fine for an audit log or may mean its consumer died. Note that ItemCount and TableSizeBytes are refreshed by DynamoDB roughly every six hours, not live.
What should you do with an unused table?
- Confirm the owner. Look at tags and search your code and infrastructure repositories for the table name. The script to find untagged AWS resources shows how widespread missing owner tags are.
- Cut cost first, delete later. Switching a provisioned table to on-demand removes the capacity charge immediately and keeps the data. DynamoDB limits how often you can switch capacity modes, so don’t flip it back and forth.
- Keep a copy. Before deleting, take an on-demand backup with
CreateBackupor export to S3. If point-in-time recovery is on, you can also restore from it for a limited time after deletion; the script to enable DynamoDB point-in-time recovery covers the setting. - Delete. Tables marked “(protected)” have deletion protection on; turn it off with
UpdateTablefirst.
DynamoDB is rarely the only idle database. The scripts to find idle RDS instances and find idle ElastiCache clusters use the same metrics-based approach. To see what DynamoDB costs you in total, run the script to get your AWS bill broken down by service.
Troubleshooting and limits
- A table you know is used shows UNUSED. Check whether it’s only read through DynamoDB Streams or exports; stream reads and exports to S3 don’t consume table read capacity. TTL deletions don’t consume write capacity either.
- A global table replica shows WRITE ONLY. Replication writes are consumed write capacity in the replica Region, so a replica nobody reads can look like a write-only table. Decide at the global table level.
AccessDeniedExceptiononGetMetricData. The profile lackscloudwatch:GetMetricData. The guide to troubleshoot AWS IAM access denied errors helps when an SCP is involved.- Slow on accounts with thousands of tables.
DescribeTableruns once per table. Limit the run with--regions=.
Ask ChatWithCloud instead
ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile, and sends the JSON result to the AI model to write the answer. Ask “Which DynamoDB tables had no reads or writes in the last 30 days?” and it can combine ListTables with CloudWatch metrics in the Region of your session. The guide to ask AI why your AWS bill increased shows the same style of cost question. Generated code runs without a confirmation step and could delete a table if asked, so connect ChatWithCloud with a read-only AWS profile and see the ChatWithCloud pricing plans for what a run costs.
Frequently asked questions
How do I know if a DynamoDB table is being used?
Sum ConsumedReadCapacityUnits and ConsumedWriteCapacityUnits in CloudWatch over a period that covers your slowest job, for the table and each global secondary index. All zeros means no reads or writes.
Does an empty DynamoDB table cost money?
An empty on-demand table costs nothing beyond negligible storage. An empty provisioned table still pays for its read and write capacity every hour.
Does CloudWatch report zero for DynamoDB tables with no traffic?
Yes. AWS documents that even tables with zero traffic emit the consumed capacity metrics regularly with zero values, so a sum of 0 is a real measurement, not missing data.
Can I restore a DynamoDB table after deleting it?
Only from a backup: an on-demand backup, an S3 export, or point-in-time recovery if it was enabled before deletion.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud