Skip to main content
emnode / learn
Compliance

Make DynamoDB tables scale capacity with demand

Security Hub DynamoDB.1 — a fixed-provisioned table either throttles your app or burns money on idle capacity; on-demand or auto scaling fixes both.

11 min·10 sections·AWS

Last reviewed

Remediates AWS Security Hub: DynamoDB.1

DynamoDB.1: the basics

Why a fixed-capacity table is a liability in both directions

DynamoDB bills throughput two ways. In provisioned mode you pre-buy read capacity units (RCUs) and write capacity units (WCUs) by the hour — you pay for what you reserve whether traffic arrives or not, and requests above your ceiling get throttled with a ProvisionedThroughputExceededException. In on-demand mode (also called pay-per-request) you pay per actual request and the table absorbs spikes automatically. The DynamoDB.1 control checks that a table can scale with demand at all.

The control fails when a table is in provisioned mode with no auto scaling configured — a static RCU/WCU setting that someone picked once and never revisited. It passes if the table is in on-demand mode, or in provisioned mode with Application Auto Scaling attached to its read and write capacity. By default the control only checks that one of those is true; optionally you can set parameters like minProvisionedReadCapacity and targetReadUtilization to require specific floors and utilization targets.

It's flagged because a fixed ceiling is wrong almost all the time. Set it too low and a traffic spike throttles real users; set it too high to be safe and you pay for headroom that sits idle 95% of the day. Security Hub files this under resilience and high availability — the failure mode it cares about is the throttling that takes your app down — but the cost angle is just as real, which is why it lands on the FinOps radar.

In this lesson you'll learn the difference between DynamoDB's provisioned and on-demand capacity modes, why a static provisioned table is both a throttling risk and a cost trap, and how DynamoDB.1 decides pass or fail. You'll see how to switch a table to on-demand, how to attach Application Auto Scaling to a provisioned table's read and write capacity, and how to choose between the two based on traffic shape. You'll also see the CLI to audit your tables, the gotchas (global secondary indexes scale separately, switch frequency limits), and how to keep new tables compliant by default.

Fun fact

The table provisioned for a launch that never came back

An e-commerce team provisioned a DynamoDB table at 4,000 WCUs and 8,000 RCUs ahead of a Black Friday launch — sized for the single busiest hour of the year. The launch went fine. Eleven months later a FinOps audit found the table still pinned at those numbers, running at under 3% average utilization, quietly billing around $1,900 a month in reserved throughput it never used. Switching it to provisioned-with-auto-scaling (the workload was steady enough that on-demand would have cost slightly more) dropped it to roughly $140 a month and, as a bonus, cleared a Security Hub DynamoDB.1 finding nobody had connected to the cost.

Fixing a DynamoDB.1 finding in action

Dana, an SRE, sees Security Hub flag DynamoDB.1 on a table called sessions-prod. It's in provisioned mode at 2,000 RCUs / 2,000 WCUs with no auto scaling. CloudWatch shows consumed capacity hovering around 120 WCUs most of the day, spiking to maybe 900 at the evening peak — so the table is paying for roughly 10x its typical load and 2x even its peak.

She checks the traffic shape first. It's spiky and front-loaded toward evening, with long quiet stretches overnight — a textbook on-demand candidate. She confirms there are no global secondary indexes that would need separate handling, then switches the table's billing mode to pay-per-request. No capacity numbers to manage, the table absorbs the evening peak on its own, and the DynamoDB.1 finding clears on the next Security Hub evaluation.

For a second table, events-archive, the picture is different: a steady, high write rate around the clock from an ingestion pipeline. There on-demand would cost more, so Dana keeps it provisioned but attaches Application Auto Scaling to both read and write capacity with a 70% utilization target and sensible min/max bounds. Both tables now pass DynamoDB.1, and both bills now track demand instead of a static guess.

First, list every table's billing mode and provisioned throughput so you can spot the static-provisioned ones DynamoDB.1 will fail.

$ aws dynamodb describe-table --table-name sessions-prod --query 'Table.{Name:TableName,Billing:BillingModeSummary.BillingMode,RCU:ProvisionedThroughput.ReadCapacityUnits,WCU:ProvisionedThroughput.WriteCapacityUnits}' --output table
-------------------------------------------------------
| DescribeTable |
+--------------+----------------+--------+------------+
| Billing | Name | RCU | WCU |
+--------------+----------------+--------+------------+
| PROVISIONED | sessions-prod | 2000 | 2000 |
+--------------+----------------+--------+------------+
# PROVISIONED with no auto scaling target attached = DynamoDB.1 fail.
# Cross-check consumed capacity in CloudWatch before deciding the mode.

A static PROVISIONED table with no scaling policy is exactly what DynamoDB.1 flags.

For a spiky, unpredictable table, the simplest compliant fix is to switch it to on-demand (pay-per-request) capacity mode.

$ aws dynamodb update-table --table-name sessions-prod --billing-mode PAY_PER_REQUEST
{
"TableDescription": {
"TableName": "sessions-prod",
"TableStatus": "UPDATING",
"BillingModeSummary": {
"BillingMode": "PAY_PER_REQUEST"
}
}
}
# On-demand: no RCU/WCU to manage, absorbs peaks automatically, DynamoDB.1 passes.

PAY_PER_REQUEST removes the static ceiling entirely — best for spiky or unpredictable traffic.

DynamoDB capacity modes under the hooddeep dive

In provisioned mode you set ReadCapacityUnits and WriteCapacityUnits per table (and per global secondary index — GSIs have their own throughput, which is a common blind spot). One RCU is one strongly-consistent read up to 4 KB per second; one WCU is one write up to 1 KB per second. DynamoDB gives you a small burst credit bucket for brief overages, but sustained traffic above your provisioned ceiling throttles with ProvisionedThroughputExceededException. You pay for the provisioned units by the hour regardless of consumption.

DynamoDB.1 passes if the table is in PAY_PER_REQUEST mode, or in PROVISIONED mode with an Application Auto Scaling scalable target registered against dynamodb:table:ReadCapacityUnits and dynamodb:table:WriteCapacityUnits and a target-tracking policy attached. Auto scaling uses CloudWatch alarms on consumed-vs-provisioned utilization to call UpdateTable and adjust capacity toward your target (commonly 70%), within the min and max bounds you set. The control's optional parameters (minProvisionedReadCapacity, targetReadUtilization, and the write equivalents) let you assert specific floors and targets rather than just "some scaling exists."

Two operational limits matter. Switching between provisioned and on-demand is restricted — once you move a table to on-demand you must wait 24 hours before switching it back. And GSIs scale independently: a table can pass DynamoDB.1 on its base capacity while a GSI still throttles, so auto scaling policies (or on-demand mode, which covers indexes automatically) need to cover the indexes too.

# Keep a busy, steady table on PROVISIONED but make it scale: register the
# scalable target, then attach a target-tracking policy at 70% utilization.
aws application-autoscaling register-scalable-target \
  --service-namespace dynamodb \
  --resource-id "table/events-archive" \
  --scalable-dimension "dynamodb:table:WriteCapacityUnits" \
  --min-capacity 50 \
  --max-capacity 4000

aws application-autoscaling put-scaling-policy \
  --service-namespace dynamodb \
  --resource-id "table/events-archive" \
  --scalable-dimension "dynamodb:table:WriteCapacityUnits" \
  --policy-name "events-archive-write-target" \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration \
    '{"TargetValue":70.0,"PredefinedMetricSpecification":{"PredefinedMetricType":"DynamoDBWriteCapacityUtilization"}}'

# Repeat both calls with ReadCapacityUnits / DynamoDBReadCapacityUtilization,
# and again for any global secondary index resource-id (.../index/<name>).

What is the impact of a non-scaling DynamoDB table?

The direct cost is reserved throughput you don't use. A table provisioned at 2,000 RCUs and 2,000 WCUs costs roughly $1,000+ a month in capacity alone, billed flat around the clock. If real utilization sits at 5%, you're paying twenty times over for the privilege of a ceiling nobody revisits. Across a fleet of tables each sized for its own worst case, the overprovisioning compounds into thousands of dollars a month of pure headroom.

The second-order cost is the throttling the control actually exists to prevent. When a static ceiling is set too low — or traffic grows past it over time — DynamoDB rejects requests with ProvisionedThroughputExceededException. That surfaces as failed writes, retry storms, elevated latency, and in the worst case a user-visible outage. The irony is that teams overprovision precisely to avoid this, which is how you end up paying peak rates around the clock and still getting throttled when an unanticipated spike exceeds the guess.

There's a real nuance on the cost side: on-demand is not universally cheaper. On-demand pricing per request is set so that a table running flat-out near 100% utilization all day costs more than the same throughput bought as provisioned-with-auto-scaling. The win from on-demand comes from spiky, bursty, or unpredictable workloads where you'd otherwise have to provision for a peak you rarely hit. Choosing the wrong mode for the traffic shape can raise the bill while still passing the control — so the compliance pass and the cost optimization are related but not identical.

Finally, this is a resilience and availability finding in Security Hub's taxonomy (it maps to NIST recovery and high-availability controls), so a failing DynamoDB.1 weighs on your security and compliance posture scores, not just your cost reports. Clearing it improves two scorecards at once — the cost dashboard and the compliance dashboard — which is part of why it's worth prioritizing.

How do you make DynamoDB tables scale with demand?

The fix is a short decision-then-configure loop: inventory the static-provisioned tables, read each one's traffic shape, pick on-demand or provisioned-with-auto-scaling accordingly, and make the compliant choice the default for new tables.

1. Inventory tables and read their real utilization

List every table's billing mode with describe-table, then pull ConsumedReadCapacityUnits and ConsumedWriteCapacityUnits from CloudWatch over a representative window (at least two weeks to capture weekly cycles). For each provisioned table compare consumed-vs-provisioned and note the traffic shape: steady, spiky, or bursty. Don't forget global secondary indexes — they carry their own throughput and their own utilization.

2. Match the mode to the traffic shape

Spiky, unpredictable, or low-average-utilization tables go to on-demand (PAY_PER_REQUEST) — it removes the ceiling, absorbs peaks, and covers indexes automatically. Steady, high-volume tables running near capacity all day stay provisioned but get Application Auto Scaling, which is usually cheaper than on-demand at sustained high utilization. The wrong choice can still pass DynamoDB.1 while raising the bill, so let the data decide rather than defaulting everything to on-demand.

3. For provisioned tables, attach target-tracking auto scaling

Register a scalable target against both dynamodb:table:ReadCapacityUnits and dynamodb:table:WriteCapacityUnits (and each GSI), set sensible min and max bounds, and attach a target-tracking policy — 70% utilization is a common starting point. Auto scaling then drives UpdateTable up and down within your bounds as traffic moves. This is what flips a provisioned table from a DynamoDB.1 failure to a pass without giving up provisioned pricing.

4. Make the compliant choice the default for new tables

Set the organization default to on-demand for new tables unless a workload is demonstrably steady and high-volume. Enforce it in infrastructure-as-code modules and with an AWS Config rule (dynamodb-autoscaling-enabled, the same rule behind the control) so a static-provisioned table is caught at creation rather than at the next audit. Remember the 24-hour cooldown when switching a table back from on-demand, so plan mode changes deliberately rather than toggling them.

# Decide the mode from real data, then apply it.
TABLE=sessions-prod

# 1. Pull average consumed write capacity over the last 14 days.
aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name ConsumedWriteCapacityUnits \
  --dimensions Name=TableName,Value=$TABLE \
  --start-time "$(date -u -d '14 days ago' +%FT%TZ)" \
  --end-time "$(date -u +%FT%TZ)" \
  --period 3600 --statistics Average Maximum

# 2a. Spiky / low-utilization table -> on-demand.
aws dynamodb update-table --table-name $TABLE --billing-mode PAY_PER_REQUEST

# 2b. OR steady high-volume table -> keep provisioned, add auto scaling
#     (see the register-scalable-target / put-scaling-policy calls earlier).

Quick quiz

Question 1 of 5

A DynamoDB.1 finding flags a table provisioned at 2,000 RCUs / 2,000 WCUs. CloudWatch shows it running at under 5% utilization most of the day with sharp, unpredictable evening spikes. What's the best fix?

You've completed Make DynamoDB tables scale capacity with demand. You now know why a static-provisioned table is both a throttling risk and a cost trap, how DynamoDB.1 decides pass or fail, and the decision loop — read the traffic shape, then pick on-demand or provisioned-with-auto-scaling — that clears the finding while actually optimizing spend. Next time DynamoDB.1 fires, you'll know it's not a blanket "switch to on-demand" but a per-table judgement the data answers for you.

Back to the library