What's happening
DynamoDB is rejecting your requests because your table can't keep up with the traffic. Every table has a fixed read/write budget measured in RCU (Read Capacity Units) and WCU (Write Capacity Units). Blow past that budget โ even for a fraction of a second โ and DynamoDB throttles the request immediately:
ProvisionedThroughputExceededException: The level of configured provisioned throughput for the table was exceeded. Consider increasing your provisioning level with the UpdateTable API.
The usual suspects: sudden traffic spikes, a bulk import job, or a Global Secondary Index (GSI) provisioned with less capacity than the base table. Let's find out which one.
Step 1 โ Find out what's being throttled
Don't touch the capacity settings yet. Check CloudWatch first to see where the bottleneck is.
# List throttle metrics for a specific table
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB \
--metric-name ReadThrottleEvents \
--dimensions Name=TableName,Value=YourTableName \
--start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 300 \
--statistics Sum
Run this for both ReadThrottleEvents and WriteThrottleEvents. GSIs have their own throttle counters โ add Name=GlobalSecondaryIndexName,Value=YourIndexName to the dimensions to check them separately.
Then pull the current provisioned numbers:
aws dynamodb describe-table --table-name YourTableName \
--query 'Table.{RCU:ProvisionedThroughput.ReadCapacityUnits, WCU:ProvisionedThroughput.WriteCapacityUnits, BillingMode:BillingModeSummary}'
Now you have a baseline. A spike of 500+ throttle events in 5 minutes is a different problem than a slow, steady 10/minute leak.
Step 2 โ Add exponential backoff (fastest fix)
If you're calling DynamoDB without retry logic, that's your first problem โ not the capacity. The AWS SDK has built-in exponential backoff. Make sure it's turned on and configured aggressively.
Python (Boto3):
import boto3
from botocore.config import Config
config = Config(
retries={
'max_attempts': 10,
'mode': 'adaptive' # backs off automatically on throttling
}
)
dynamodb = boto3.resource('dynamodb', config=config)
table = dynamodb.Table('YourTableName')
Node.js (AWS SDK v3):
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const client = new DynamoDBClient({
maxAttempts: 10, // SDK default is 3 โ too low for throttled workloads
});
mode: 'adaptive' in Boto3 is the smarter choice โ it measures real-time error rates and adjusts retry timing accordingly. For most transient throttling, this alone stops the exceptions without touching capacity at all.
Step 3a โ Increase provisioned capacity
Throttling is sustained, not just a one-time spike? Time to raise the ceiling. You can do this via CLI in seconds:
# Bump write capacity to 100 WCU
aws dynamodb update-table \
--table-name YourTableName \
--provisioned-throughput ReadCapacityUnits=50,WriteCapacityUnits=100
For a GSI with its own throughput settings:
aws dynamodb update-table \
--table-name YourTableName \
--global-secondary-index-updates \
'[{"Update":{"IndexName":"YourGSIName","ProvisionedThroughput":{"ReadCapacityUnits":50,"WriteCapacityUnits":50}}}]'
Capacity changes propagate in seconds. One hard limit to know: AWS allows up to 4 decreases per table per calendar day (UTC). Increases are unlimited, so scale up freely.
Step 3b โ Switch to on-demand mode (simpler option)
Unpredictable traffic? Skip capacity planning entirely. On-demand mode lets DynamoDB handle any request rate automatically.
aws dynamodb update-table \
--table-name YourTableName \
--billing-mode PAY_PER_REQUEST
The trade-off: on-demand costs roughly 6-7x more per million requests than well-tuned provisioned capacity. For a table doing 10M reads/day at steady state, that difference adds up. But for workloads with sharp peaks and long quiet periods, it often works out cheaper than over-provisioning. Switch back to provisioned once you understand your actual traffic pattern.
Step 3c โ Enable auto scaling (best of both worlds)
Auto scaling watches utilization and adjusts provisioned capacity in real time. A target of 70% gives you a 30% headroom buffer for sudden spikes before throttling kicks in.
# Register table as a scalable target
aws application-autoscaling register-scalable-target \
--service-namespace dynamodb \
--resource-id "table/YourTableName" \
--scalable-dimension "dynamodb:table:WriteCapacityUnits" \
--min-capacity 5 \
--max-capacity 500
# Set the scaling policy
aws application-autoscaling put-scaling-policy \
--service-namespace dynamodb \
--resource-id "table/YourTableName" \
--scalable-dimension "dynamodb:table:WriteCapacityUnits" \
--policy-name "WriteAutoScaling" \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration \
'{"TargetValue": 70.0, "PredefinedMetricSpecification": {"PredefinedMetricType": "DynamoDBWriteCapacityUtilization"}}'
Note that auto scaling reacts to sustained load โ it typically takes 1-2 minutes to kick in. It won't save you from a traffic spike that slams the table in the first 60 seconds.
Step 4 โ Reduce hot partition pressure
Sometimes total capacity isn't the problem. A single partition is. DynamoDB splits data by hash key โ when many requests target the same key, that one partition maxes out while the others sit idle. You can have 1000 WCU provisioned and still throttle.
- Avoid sequential or monotonic keys โ auto-increment IDs and Unix timestamps as partition keys are notorious hot partition causes. Use UUIDs or append a random suffix like
userId#shard-3. - Spread bulk writes โ if you're importing 100k rows, scatter them across different partition keys rather than writing them in order.
- Add DAX for read-heavy workloads โ DynamoDB Accelerator is an in-memory cache that sits in front of DynamoDB and absorbs read spikes at microsecond latency. A DAX cluster with 2 nodes can handle tens of millions of reads per second.
- Batch your writes โ
BatchWriteItempacks up to 25 writes into a single API call. Combine it with rate limiting to stay under capacity:
# Python: batch write with rate limiting
import time
def batch_write_with_limit(table, items, wcu_limit=50):
chunk_size = 25 # DynamoDB max items per BatchWriteItem
for i in range(0, len(items), chunk_size):
chunk = items[i:i+chunk_size]
with table.batch_writer() as batch:
for item in chunk:
batch.put_item(Item=item)
time.sleep(1) # pause 1s between chunks to throttle throughput
Verify the fix
Once you've made changes, pull the last 5 minutes of throttle data:
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB \
--metric-name WriteThrottleEvents \
--dimensions Name=TableName,Value=YourTableName \
--start-time $(date -u -d '5 minutes ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 \
--statistics Sum
A count of 0 means clean. Also compare ConsumedWriteCapacityUnits against ProvisionedWriteCapacityUnits โ if you're consistently above 80%, you're one traffic spike away from throttling again. Keep utilization under 80% for sustained loads.
Key takeaways
- Configure adaptive retries first. It's free, takes 2 minutes, and absorbs most transient throttling without touching capacity.
- Set CloudWatch alarms on throttle events. Alarm on
ReadThrottleEventsandWriteThrottleEventswith a threshold of 10+ per minute โ catch problems before users report them. - GSI capacity is independent from the base table. A heavily-queried index with low WCU is a common silent culprit that's easy to miss.
- On-demand isn't always the expensive choice. For tables that are idle 20 hours a day but handle big bursts during peak hours, PAY_PER_REQUEST often beats over-provisioned capacity on cost.

