Find Unused DynamoDB Tables With No Reads or Writes

Rows of empty metal shelving in a large, dimly lit warehouse

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?

  1. Lists tablesListTables (paginated) in every enabled Region, or the ones you pass with --regions=.
  2. Describes each tableDescribeTable returns BillingModeSummary, ProvisionedThroughput, TableSizeBytes, ItemCount, TableClassSummary, DeletionProtectionEnabled, CreationDateTime and the global secondary indexes.
  3. Sums consumed capacityOne GetMetricData query per table and per index for reads and writes, statistic Sum, daily periods over --days (default 30), batched 500 queries per call.
  4. 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.
  5. 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

Which IAM permissions does it need?

unused-dynamodb-tables-policy.json

{
  "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

// 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?

Terminal

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

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 CreateBackup or 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 UpdateTable first.

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.
  • AccessDeniedException on GetMetricData. The profile lacks cloudwatch:GetMetricData. The guide to troubleshoot AWS IAM access denied errors helps when an SCP is involved.
  • Slow on accounts with thousands of tables. DescribeTable runs 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