Find Overprovisioned DynamoDB Read and Write Capacity

Close-up of a database server front panel with drive bays and small indicator lights

Photo by Ile Ristov on Unsplash

To find DynamoDB overprovisioned capacity, compare each provisioned table’s and global secondary index’s read and write capacity units with what it consumed last month. Read ConsumedReadCapacityUnits and ConsumedWriteCapacityUnits from CloudWatch, divide each 5-minute Sum by 300 to get units per second, and flag capacity whose peak stayed well below the provisioned value.

Provisioned capacity is usually set once, at launch or during an incident, and rarely revisited. You pay for what you provision whether you use it or not, so a table sized for last year’s traffic spike keeps costing money every hour. This example is for engineers who own DynamoDB tables in provisioned mode and want numbers before they change anything. You get a read-only TypeScript script for the AWS SDK for JavaScript v3 that checks every provisioned table and GSI in a region against the previous calendar month and suggests a value that keeps the peak near 70% utilization.

It’s part of our AWS SDK v3 examples for cost and operations. To see how much DynamoDB actually adds to the bill first, the example that breaks down last month’s AWS costs by service puts it in context. Capacity is the cost side of a table; for the recovery side, the script to enable DynamoDB point-in-time recovery on every table checks which tables can be restored.

How do you measure DynamoDB overprovisioned capacity?

One read capacity unit (RCU) covers one strongly consistent read per second of an item up to 4 KB, or two eventually consistent reads. One write capacity unit (WCU) covers one write per second of up to 1 KB. DynamoDB publishes consumption to CloudWatch every minute as a total, so the per-second rate is the Sum divided by the period length.

Figure How the script gets it
Provisioned ProvisionedThroughput from DescribeTable, for the table and each GSI (current value)
Average used Total consumed units for the month ÷ seconds in the month
Peak used Highest 5-minute Sum ÷ 300
Suggested Peak ÷ 0.7, rounded up, shown only when the peak was below 50% of provisioned

The script uses 5-minute periods because CloudWatch keeps 5-minute data for 63 days, which covers the whole previous month. Short spikes inside a 5-minute window are averaged out, which is one reason the suggestion keeps 30% headroom.

What does this script do?

  1. Find provisioned tablespaginateListTables lists every table in the region; DescribeTable returns its billing mode and capacity. Tables in on-demand mode (PAY_PER_REQUEST) are skipped.
  2. Include each GSIGlobal secondary indexes have their own provisioned capacity, so each one becomes a separate row with the GlobalSecondaryIndexName dimension.
  3. Read last month’s consumptionGetMetricData fetches up to 500 metric queries per request and follows NextToken until every datapoint is in.
  4. Compare and suggestFor each read and write setting it prints provisioned, average, peak, peak utilization and, when the peak stayed under 50%, a suggested value and the number of spare units.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • @aws-sdk/client-dynamodb and @aws-sdk/client-cloudwatch.
  • A read-only AWS profile for the region the tables live in (AWS_REGION).

Which IAM permissions does it need?

dynamodb:ListTables and cloudwatch:GetMetricData don’t support resource-level permissions. dynamodb:DescribeTable can be scoped to table ARNs; narrow table/* to specific tables or a naming prefix if you like.

dynamodb-capacity-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListTablesAndReadMetrics",
      "Effect": "Allow",
      "Action": [
        "dynamodb:ListTables",
        "cloudwatch:GetMetricData"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DescribeTables",
      "Effect": "Allow",
      "Action": "dynamodb:DescribeTable",
      "Resource": "arn:aws:dynamodb:*:*:table/*"
    }
  ]
}

The script never calls UpdateTable, so no write permission is needed. If you add that step, draft the new policy with the IAM policy generator for TypeScript code and review it for least privilege before you attach it.

The full script

dynamodb-overprovisioned.ts

// dynamodb-overprovisioned.ts
// Compares each provisioned DynamoDB table's (and GSI's) read and write capacity with
// what it actually consumed last calendar month, and flags overprovisioned capacity.
// Read-only: it suggests new values but never calls UpdateTable.
import { DynamoDBClient, paginateListTables, DescribeTableCommand } from "@aws-sdk/client-dynamodb";
import {
  CloudWatchClient,
  GetMetricDataCommand,
  type MetricDataQuery,
  type Dimension,
} from "@aws-sdk/client-cloudwatch";

const region = process.env.AWS_REGION ?? "us-east-1";
const PERIOD = 300; // 5-minute buckets; CloudWatch keeps 5-minute data for 63 days
const THRESHOLD = 0.5; // flag when peak use is below 50% of provisioned
const TARGET = 0.7; // suggested capacity keeps the peak at about 70%

const dynamodb = new DynamoDBClient({ region });
const cloudwatch = new CloudWatchClient({ region });

type Target = { table: string; index?: string; mode: "read" | "write"; provisioned: number };
type Result = Target & { avg: number; peak: number };

// Previous calendar month in UTC, e.g. 2026-08-01T00:00Z to 2026-09-01T00:00Z.
function lastMonth(): { start: Date; end: Date } {
  const now = new Date();
  const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
  const start = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() - 1, 1));
  return { start, end };
}

async function provisionedTargets(): Promise<Target[]> {
  const targets: Target[] = [];
  for await (const page of paginateListTables({ client: dynamodb }, {})) {
    for (const name of page.TableNames ?? []) {
      const { Table } = await dynamodb.send(new DescribeTableCommand({ TableName: name }));
      // On-demand tables report 0 provisioned units; there is nothing to right-size.
      if (!Table || Table.BillingModeSummary?.BillingMode === "PAY_PER_REQUEST") continue;
      const add = (index: string | undefined, rcu?: number, wcu?: number) => {
        if (rcu) targets.push({ table: name, index, mode: "read", provisioned: rcu });
        if (wcu) targets.push({ table: name, index, mode: "write", provisioned: wcu });
      };
      add(undefined, Table.ProvisionedThroughput?.ReadCapacityUnits, Table.ProvisionedThroughput?.WriteCapacityUnits);
      for (const gsi of Table.GlobalSecondaryIndexes ?? []) {
        add(gsi.IndexName, gsi.ProvisionedThroughput?.ReadCapacityUnits, gsi.ProvisionedThroughput?.WriteCapacityUnits);
      }
    }
  }
  return targets;
}

async function measure(targets: Target[], start: Date, end: Date): Promise<Result[]> {
  const sums = new Map<string, number[]>();
  for (let i = 0; i < targets.length; i += 500) {
    const queries: MetricDataQuery[] = targets.slice(i, i + 500).map((t, j) => {
      const dims: Dimension[] = [{ Name: "TableName", Value: t.table }];
      if (t.index) dims.push({ Name: "GlobalSecondaryIndexName", Value: t.index });
      return {
        Id: `q${i + j}`,
        MetricStat: {
          Metric: {
            Namespace: "AWS/DynamoDB",
            MetricName: t.mode === "read" ? "ConsumedReadCapacityUnits" : "ConsumedWriteCapacityUnits",
            Dimensions: dims,
          },
          Period: PERIOD,
          Stat: "Sum",
        },
      };
    });
    let NextToken: string | undefined;
    do {
      const res = await cloudwatch.send(
        new GetMetricDataCommand({ StartTime: start, EndTime: end, MetricDataQueries: queries, NextToken }),
      );
      for (const r of res.MetricDataResults ?? []) {
        if (r.Id) sums.set(r.Id, [...(sums.get(r.Id) ?? []), ...(r.Values ?? [])]);
      }
      NextToken = res.NextToken;
    } while (NextToken);
  }

  const seconds = (end.getTime() - start.getTime()) / 1000;
  return targets.map((t, k) => {
    const values = sums.get(`q${k}`) ?? [];
    const total = values.reduce((a, b) => a + b, 0);
    // A Sum over 300 seconds divided by 300 is the average units per second in that window.
    const peak = values.length ? Math.max(...values) / PERIOD : 0;
    return { ...t, avg: total / seconds, peak };
  });
}

async function main(): Promise<void> {
  const { start, end } = lastMonth();
  console.log(`Region ${region}, ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}`);
  const targets = await provisionedTargets();
  if (targets.length === 0) {
    console.log("No tables in provisioned capacity mode.");
    return;
  }

  const results = await measure(targets, start, end);
  const rows = results.map((r) => {
    const peakUse = r.peak / r.provisioned;
    const suggested = Math.max(1, Math.ceil(r.peak / TARGET));
    const over = peakUse < THRESHOLD && suggested < r.provisioned;
    return {
      Table: r.index ? `${r.table} / ${r.index}` : r.table,
      Mode: r.mode,
      Provisioned: r.provisioned,
      "Avg used": Number(r.avg.toFixed(2)),
      "Peak used": Number(r.peak.toFixed(2)),
      "Peak %": `${(peakUse * 100).toFixed(1)}%`,
      Suggested: over ? suggested : "-",
      Overprovisioned: over ? `${r.provisioned - suggested} units` : "no",
    };
  });
  console.table(rows);
  const flagged = rows.filter((r) => r.Overprovisioned !== "no").length;
  console.log(`${flagged} of ${rows.length} capacity settings peaked below ${THRESHOLD * 100}% of provisioned.`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Command names and input fields match the @aws-sdk/client-dynamodb package in the aws-sdk-js-v3 repository. The GetMetricData batching is the same pattern used to get Lambda invocation counts with CloudWatch metrics and to find the size of each S3 bucket from CloudWatch.

How do you run it?

Terminal

npm install @aws-sdk/client-dynamodb @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript

# Every provisioned table and GSI in one region, previous calendar month
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx dynamodb-overprovisioned.ts

Run it once per region that holds tables. GetMetricData requests are billed as CloudWatch API calls; for a monthly check the amount is small, and the example to get this month’s CloudWatch cost shows where it lands on the bill.

Sample output

Output

Region us-east-1, 2026-08-01 to 2026-09-01
┌─────────┬────────────────────────┬─────────┬─────────────┬──────────┬───────────┬─────────┬───────────┬─────────────────┐
│ (index) │ Table                  │ Mode    │ Provisioned │ Avg used │ Peak used │ Peak %  │ Suggested │ Overprovisioned │
├─────────┼────────────────────────┼─────────┼─────────────┼──────────┼───────────┼─────────┼───────────┼─────────────────┤
│ 0       │ 'orders'               │ 'read'  │ 400         │ 38.2     │ 121.6     │ '30.4%' │ 174       │ '226 units'     │
│ 1       │ 'orders'               │ 'write' │ 200         │ 21.7     │ 142.9     │ '71.5%' │ '-'       │ 'no'            │
│ 2       │ 'orders / by-customer' │ 'read'  │ 100         │ 4.1      │ 9.8       │ '9.8%'  │ 15        │ '85 units'      │
│ 3       │ 'orders / by-customer' │ 'write' │ 200         │ 21.7     │ 142.9     │ '71.5%' │ '-'       │ 'no'            │
│ 4       │ 'sessions'             │ 'read'  │ 50          │ 31.4     │ 47.2      │ '94.4%' │ '-'       │ 'no'            │
│ 5       │ 'sessions'             │ 'write' │ 50          │ 12.3     │ 18.5      │ '37.0%' │ 27        │ '23 units'      │
└─────────┴────────────────────────┴─────────┴─────────────┴──────────┴───────────┴─────────┴───────────┴─────────────────┘
3 of 6 capacity settings peaked below 50% of provisioned.

Table names and figures are illustrative. Read row 2 as: the by-customer index peaked at 9.8 RCU against 100 provisioned, so about 15 would still leave headroom.

Should you lower capacity or switch to on-demand?

Lowering capacity fits tables with steady traffic and a clear peak. Before you do, check three things:

  • Auto scaling. If the table uses auto scaling, the current provisioned value moves on its own. Lower the scaling policy’s minimum instead of the table’s capacity, or your change is overwritten.
  • GSI writes. Every write to the table that touches an index also consumes that index’s WCU. An underprovisioned GSI can throttle writes to the base table, so lower index write capacity carefully.
  • Burst and decrease limits. DynamoDB keeps up to 5 minutes of unused capacity for short bursts, which hides some spikes from throttling but not from your peak metric. It also limits how many times per day you can decrease capacity, so make one considered change rather than several small ones.

When average use is a small fraction of the peak, traffic is spiky and on-demand mode may cost less, since it bills per request instead of per provisioned unit. Compare both with your own request counts before switching.

Troubleshooting

  • Every value is 0. The table had no traffic, or you’re in the wrong region. Check AWS_REGION. If the Region is right, the table may not be needed at all; the script to find unused DynamoDB tables with no reads or writes checks 30 days of traffic for on-demand tables too.
  • AccessDeniedException on DescribeTable or GetMetricData. Add the policy above; the guide to troubleshoot AWS access denied errors step by step covers SCPs and boundaries.
  • A table is missing. It’s in on-demand mode, so there’s no provisioned capacity to compare.
  • Peak looks lower than the throttling you saw. 5-minute sums smooth out bursts. Change PERIOD to 60 for a window within the last 15 days, where 1-minute data is still kept.

Ask ChatWithCloud instead

For a quick check of one table, run ChatWithCloud and ask “Compare the provisioned read and write capacity of my orders table with what it consumed last month.” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and explains the numbers. How ChatWithCloud runs AWS SDK code on your machine describes the loop. Changes run without a confirmation step, so ask with a read-only profile and apply capacity changes yourself; the guide to connect ChatWithCloud to your AWS account safely shows the setup.

Frequently asked questions

How do I know if my DynamoDB table is overprovisioned?

Compare peak consumed capacity with provisioned capacity over a representative period, such as last month. If the peak stayed well below provisioned, for example under 50%, you’re paying for units you don’t use.

How do I calculate consumed capacity per second from CloudWatch?

Request the Sum statistic for ConsumedReadCapacityUnits or ConsumedWriteCapacityUnits and divide it by the period in seconds. A 5-minute Sum divided by 300 gives average units per second in that window.

Does a GSI have its own read and write capacity?

Yes. In provisioned mode each global secondary index has its own RCU and WCU settings, which is why the script reports each index separately.

Does this script change my tables?

No. It only reads table settings and CloudWatch metrics and prints suggestions. Apply changes yourself with UpdateTable, the console or your infrastructure code.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud