DynamoDB Cost Optimization: Capacity, Storage and Indexes
DynamoDB cost optimization with us-east-1 prices: on-demand vs provisioned break-even, Standard-IA, reserved capacity, GSI projections, TTL and PITR.
A lot of the DynamoDB cost advice still going around was written for a price list that no longer exists. “Move to provisioned once traffic settles down” made sense when on-demand requests cost about seven times as much as fully used provisioned capacity. On 1 November 2024 AWS cut on-demand throughput prices in half, and the gap is now about three and a half times. Tables switched to provisioned years ago, then given generous minimums so nobody got paged, are often paying more than they would on-demand.
Capacity mode is only one dial. Item size, index projections, table class, backups and replicas all multiply the bill, and most were set once and forgotten. Here’s how we go through a DynamoDB bill, table by table, with us-east-1 list prices at the time of writing.
The prices that matter
| Charge | Standard | Standard-IA |
|---|---|---|
| On-demand writes, per million write request units | $0.625 | $0.78 |
| On-demand reads, per million read request units | $0.125 | $0.155 |
| Provisioned write capacity unit (WCU), per hour | $0.00065 | $0.00081 |
| Provisioned read capacity unit (RCU), per hour | $0.00013 | $0.00016 |
| Storage, per GB-month | $0.25 | $0.10 |
Around the edges: point-in-time recovery (PITR) is $0.20 per GB-month, on-demand backups $0.10 per GB-month, restores $0.15 per GB, a full export to S3 $0.10 per GB, and DynamoDB Streams reads $0.02 per 100,000 read request units. The free tier covers 25 GB of Standard storage plus 25 WCUs and 25 RCUs per region.
First find out which line is actually big. Cost Explorer charges $0.01 per API call.
# Read-only: last full month of DynamoDB spend by usage type
# (GNU date first, BSD/macOS date as the fallback)
START=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%F 2>/dev/null || date -u -v1d -v-1m +%F)
END=$(date -u +%Y-%m-01)
aws ce get-cost-and-usage \
--time-period Start="$START",End="$END" \
--granularity MONTHLY --metrics UnblendedCost \
--filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon DynamoDB"]}}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE \
--query 'ResultsByTime[0].Groups[].[Keys[0],Metrics.UnblendedCost.Amount]' \
--output text | sort -t$'\t' -k2 -rn
Usage types ending in RequestUnits are on-demand traffic, CapacityUnit-Hrs is provisioned capacity, and the Timed...Storage types are table, PITR and backup storage. Then list how each table is set up:
# Read-only: capacity mode, table class, size and item count per table
for t in $(aws dynamodb list-tables --query 'TableNames[]' --output text); do
aws dynamodb describe-table --table-name "$t" --query \
'Table.[TableName, BillingModeSummary.BillingMode || `PROVISIONED`, TableClassSummary.TableClass || `STANDARD`, TableSizeBytes, ItemCount]' \
--output text
done
On-demand vs provisioned: the 29% rule
A provisioned WCU costs $0.00065 an hour and absorbs 3,600 one-kilobyte writes in that hour. The same writes on-demand cost $0.00225. So provisioned only wins if your average consumption is above roughly 29% of what you provision. Reads work out to the same ratio. Before the price cut, the line sat near 14%.
Take a table averaging 200 writes a second with peaks of 800 during business hours:
- On-demand: about 526 million writes a month, or $328.50.
- Provisioned flat at 800 WCU: $379.60. That’s 25% utilisation, and worse than on-demand.
- Provisioned with auto scaling at the default 70% target: around 286 WCU on average, or about $136.
The third figure is a best case. Auto scaling runs behind spikes, and DynamoDB limits scale-downs to four at the start of each UTC day plus one more each hour, 27 at most. Spiky traffic lands somewhere between the second and third lines, so measure it:
# Read-only: daily average consumed vs provisioned WCU, last 14 days
TABLE=orders
END=$(date -u +%Y-%m-%dT00:00:00Z)
START=$(date -u -d '14 days ago' +%Y-%m-%dT00:00:00Z 2>/dev/null || date -u -v-14d +%Y-%m-%dT00:00:00Z)
cat > wcu-queries.json <<EOF
[
{"Id": "consumed", "ReturnData": false,
"MetricStat": {"Metric": {"Namespace": "AWS/DynamoDB", "MetricName": "ConsumedWriteCapacityUnits",
"Dimensions": [{"Name": "TableName", "Value": "$TABLE"}]}, "Period": 86400, "Stat": "Sum"}},
{"Id": "used", "Expression": "consumed / 86400", "Label": "consumed WCU per second"},
{"Id": "provisioned", "Label": "provisioned WCU",
"MetricStat": {"Metric": {"Namespace": "AWS/DynamoDB", "MetricName": "ProvisionedWriteCapacityUnits",
"Dimensions": [{"Name": "TableName", "Value": "$TABLE"}]}, "Period": 86400, "Stat": "Average"}}
]
EOF
aws cloudwatch get-metric-data --start-time "$START" --end-time "$END" \
--metric-data-queries file://wcu-queries.json \
--query 'MetricDataResults[].{label: Label, values: Values}' --output json
Divide the first series by the second. Under 0.3 most days means on-demand is probably cheaper. Repeat for reads and for each GSI, which has its own capacity. You can move a table to on-demand four times in a rolling 24 hours and back at any time; the console deletes auto scaling settings when you switch, the CLI keeps them.
If a runaway job is the worry, cap each on-demand table and index with --on-demand-throughput MaxWriteRequestUnits=...; requests over the cap are throttled rather than billed. Provisioned still earns its keep on a flat, high, predictable load, like a pipeline writing at the same rate around the clock. That’s also the only kind of table where reserved capacity is worth a look.
Reserved capacity and Database Savings Plans
Reserved capacity comes in blocks of 100 WCUs or 100 RCUs, for one year or, in some regions, three. AWS puts the saving at up to 54% and 77%, paid as a partial upfront fee plus a lower hourly rate. It covers provisioned Standard-class tables in the region where you bought it, and nothing else: not on-demand, not Standard-IA, not global table replicated writes. You can’t cancel, sell or move it, so size it to the floor auto scaling never drops below.
Database Savings Plans, launched in December 2025, are the only commitment that works for on-demand tables. For DynamoDB, AWS’s launch post gives up to 18% on on-demand throughput and up to 12% on provisioned, for a one-year hourly commitment that follows usage across regions and database services. We compare the two in AWS billing discounts you probably missed.
Standard-IA when storage is the bill
The Standard-Infrequent Access class cuts storage by 60%, to $0.10 per GB-month, and raises reads and writes by about 25%. AWS suggests it once storage passes 50% of a Standard table’s throughput cost. On these list prices the break-even is nearer 42%, so the rule leaves a margin.
A 1 TB table with $150 a month of throughput costs $406 on Standard ($256 storage) and $289.90 on Standard-IA. Order history, audit trails and chat archives often look like that: lots of data, little traffic.
Secondary indexes always use their table’s class, so a busy GSI pays the higher throughput price too. You can only change class twice in a trailing 30 days, so don’t flip it to experiment. The switch is one update-table --table-class STANDARD_INFREQUENT_ACCESS call, and AWS says it has no impact on performance or availability.
TTL: deletes that cost nothing
A TTL delete uses no write capacity. Deleting the same items yourself pays for the scan to find them and every DeleteItem. Sessions, idempotency keys, rate-limit counters and anything else with a shelf life should carry a TTL attribute: a Number holding Unix epoch seconds.
Items go within a few days of expiring, not on the second, so filter on the attribute in reads. On a global table, each replica’s delete bills as a replicated write. If you need the data longer, the deletes appear in DynamoDB Streams as service deletions, and a consumer can copy them to S3 first.
# Read-only: TTL and PITR settings for every table
for t in $(aws dynamodb list-tables --query 'TableNames[]' --output text); do
ttl=$(aws dynamodb describe-time-to-live --table-name "$t" \
--query 'TimeToLiveDescription.TimeToLiveStatus' --output text)
pitr=$(aws dynamodb describe-continuous-backups --table-name "$t" \
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.[PointInTimeRecoveryStatus,RecoveryPeriodInDays]' \
--output text)
printf '%s\tTTL=%s\tPITR=%s\n' "$t" "$ttl" "$pitr"
done
Item size and rounding
Writes bill in 1 KB steps and reads in 4 KB steps, and attribute names count towards item size. A 1.1 KB item costs two write units per write. UpdateItem bills on the larger of the before and after sizes, however little you change, so bumping a view counter on a 3.2 KB profile item costs four write units. Move the counter into its own small item and it costs one.
- Eventually consistent reads cost half as much as strongly consistent ones, and they’re the default. Ask for strong consistency only where stale data hurts.
Querysums the sizes of returned items and rounds once;BatchGetItemrounds each item up to 4 KB first. Twenty 500-byte items cost far less through one query.- Transactions cost double: two units per item for reads and writes, and a cancelled transaction still bills. Use them when several items must change together, and a condition expression on a plain write elsewhere.
- Failed conditional writes and reads of missing items still consume capacity.
Large text and blobs belong in S3 with a pointer in the item.
GSIs that copy the whole table
Each write also writes to every GSI whose key attributes the item has, at the index entry’s own size. Change an indexed key and the index pays twice: one delete, one put. With an ALL projection, each index is close to a second copy of the table, plus 100 bytes of overhead per entry.
Project KEYS_ONLY or INCLUDE with just the attributes the access pattern reads. Use sparse indexes: an item without the index key attribute never reaches the index. You can’t change a projection in place, so create the new index, wait for it to finish building, move the readers, then drop the old one.
Unused indexes are an easier find:
# Read-only: GSI projection, size and reads over the last 30 days
TABLE=orders
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
START=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
aws dynamodb describe-table --table-name "$TABLE" \
--query 'Table.GlobalSecondaryIndexes[].[IndexName,Projection.ProjectionType,IndexSizeBytes]' \
--output text |
while read -r idx proj size; do
reads=$(aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB \
--metric-name ConsumedReadCapacityUnits \
--dimensions Name=TableName,Value="$TABLE" Name=GlobalSecondaryIndexName,Value="$idx" \
--start-time "$START" --end-time "$END" --period 2592000 --statistics Sum \
--query 'sum(Datapoints[].Sum)' --output text)
printf '%s\t%s\t%s bytes\t%s read units\n' "$idx" "$proj" "$size" "$reads"
done
An index with no reads for a month but steady writes is paying for a query nobody runs. Ask around before dropping it, because quarterly reports exist.
Streams and global tables
Streams reads by Lambda triggers are free, unless the function runs on Lambda Managed Instances, and so are the reads global tables make to replicate. The per-request charge shows up when you run your own consumers. Change data capture to Kinesis Data Streams is separate, at $0.10 per million change units plus Kinesis costs.
Global tables cost more. Every write bills as a replicated write, at the normal write price, in every region with a replica. Each replica stores a full copy, and each region’s GSIs pay for their own writes. A three-region table pays roughly three times the writes and storage of one region. AWS’s billing guide says replication carries no cross-region data transfer charge, and seeding a new replica costs $0.15 per GB.
That’s money well spent on active-active traffic or fast regional failover. If the real need is “keep the data if a region goes away”, PITR plus a scheduled cross-region copy in AWS Backup is usually far cheaper. Agree it with whoever owns the recovery objectives before removing a replica.
Backups, PITR and exports
PITR bills $0.20 per GB-month of table size, including local secondary indexes. Since January 2025 the recovery window can be 1 to 35 days, but a shorter window doesn’t lower the price. It’s cheap insurance in production and often pure cost on dev tables you can rebuild from a script.
On-demand backups stay until someone deletes them, and old manual ones are the usual leftover:
# Read-only: user-created backups older than 90 days, written to a file to review
CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-90d +%Y-%m-%dT%H:%M:%SZ)
aws dynamodb list-backups --backup-type USER --time-range-upper-bound "$CUTOFF" \
--query 'BackupSummaries[].[TableName,BackupName,BackupCreationDateTime,BackupSizeBytes,BackupArn]' \
--output text > old-backups.tsv
cat old-backups.tsv
# DESTRUCTIVE: permanently deletes every backup in old-backups.tsv.
# Show the list to each table's owner first and remove any line they want kept.
read -r -p "Delete $(wc -l < old-backups.tsv) backups? Type yes to continue: " ok
if [ "$ok" = "yes" ]; then
cut -f5 old-backups.tsv | while read -r arn; do
aws dynamodb delete-backup --backup-arn "$arn"
done
fi
Exports to S3 get pitched as a cheaper way to read a whole table, and on price alone that’s no longer true. An eventually consistent on-demand scan costs about $0.016 per GB; a full export costs $0.10. Exports win elsewhere: they use no read capacity, so an analytics job can’t throttle production, and Athena can query the files as often as you like. Incremental exports cover 15 minutes to 24 hours, bill on change data processed (10 MB minimum) and contain only changed items, which beats rescanning a big table every night. Exports need PITR turned on.
One cost that isn’t on the DynamoDB bill: reaching DynamoDB from private subnets through a NAT gateway. A gateway endpoint is free, and our NAT gateway guide shows how to check.
FAQ
Is DynamoDB on-demand more expensive than provisioned?
Per request, yes, about 3.5 times a fully used provisioned unit. But provisioned capacity bills whether it’s used or not, so on-demand is cheaper below about 29% average utilisation.
When should I use DynamoDB Standard-IA?
When storage is at least half of what a Standard table spends on reads and writes. It suits large tables with little traffic.
Does DynamoDB TTL cost anything?
The delete uses no write capacity. On global tables, the delete in each other region bills as a replicated write.
Does shortening the PITR recovery period reduce cost?
No. PITR bills on table size whether the window is 1 day or 35. To save, turn it off where it isn’t needed.
Can I buy reserved capacity for on-demand DynamoDB tables?
No. It only covers provisioned Standard-class tables. A Database Savings Plan is the commitment option for on-demand.
Five columns per table
Run the inventory loop and add five columns beside each table: capacity mode, table class, provisioned utilisation, storage as a share of throughput cost, and GSIs with an ALL projection or no reads. Start with the most expensive table and change one setting at a time, a week apart, so you can tell which change saved the money. After the first pass, an automated scan can repeat the checks as new tables appear.
