Scaling DynamoDB: From Prototype to Production
Amazon DynamoDB is a fully managed NoSQL database service that provides fast and predictable performance with seamless scalability. While getting started with DynamoDB is straightforward—often just a few lines of code to create a table and start reading and writing data—scaling it to handle production workloads requires careful planning around data modeling, capacity provisioning, indexing, and monitoring. This tutorial walks you through the journey of taking a DynamoDB-backed application from a quick prototype to a robust, production-ready system.
What Is DynamoDB Scaling?
DynamoDB scaling refers to the strategies and configurations that allow your database to handle increasing amounts of traffic, data volume, and query complexity without degrading performance. DynamoDB abstracts away the underlying infrastructure, but you are still responsible for making smart decisions about partition keys, secondary indexes, capacity modes, and access patterns. Scaling is not just about throwing more throughput at a table; it is about designing your schema and configuration so that load distributes evenly across partitions and queries remain efficient as your dataset grows.
Why Scaling Matters
In a prototype, you might have a few hundred items and minimal traffic. Everything works fine with default settings. But as you move to production, several challenges emerge:
- Hot partitions: If all your reads and writes target a single partition key, that partition becomes a bottleneck regardless of how much total capacity you provision.
- Cost explosion: Over-provisioning capacity to handle peak loads can become expensive, while under-provisioning leads to throttling and failed requests.
- Query inefficiency: Without proper indexing, you may resort to full table scans that consume enormous amounts of read capacity.
- Latency spikes: Poorly designed access patterns can cause unpredictable response times that violate your application's SLAs.
- Data growth: As items accumulate, the way you model relationships and retrieve data must continue to perform at scale.
Addressing these concerns early prevents painful refactors later and ensures your application can grow without hitting a wall.
Capacity Modes: Provisioned vs. On-Demand
One of the first scaling decisions you make is choosing a capacity mode. DynamoDB offers two options: provisioned throughput and on-demand mode.
Provisioned mode lets you specify the number of reads and writes per second your table can handle. You can use auto-scaling to adjust these values automatically based on actual utilization. This mode is cost-effective for predictable workloads but requires tuning.
On-demand mode instantly accommodates workloads as they ramp up or down. You pay per request rather than for provisioned capacity. This is ideal for unpredictable workloads, new applications, or rapid prototyping where you do not yet know your traffic patterns.
Here is how you create a table in provisioned mode with auto-scaling using the AWS SDK for Python (boto3):
import boto3
dynamodb = boto3.client('dynamodb')
# Create the table with provisioned capacity
response = dynamodb.create_table(
TableName='Users',
KeySchema=[
{'AttributeName': 'user_id', 'KeyType': 'HASH'},
{'AttributeName': 'created_at', 'KeyType': 'RANGE'}
],
AttributeDefinitions=[
{'AttributeName': 'user_id', 'AttributeType': 'S'},
{'AttributeName': 'created_at', 'AttributeType': 'S'}
],
BillingMode='PROVISIONED',
ProvisionedThroughput={
'ReadCapacityUnits': 5,
'WriteCapacityUnits': 5
}
)
# Wait for the table to become active
waiter = dynamodb.get_waiter('table_exists')
waiter.wait(TableName='Users')
# Set up auto-scaling for write capacity
application_autoscaling = boto3.client('application-autoscaling')
# Register the scalable target
application_autoscaling.register_scalable_target(
ServiceNamespace='dynamodb',
ResourceId='table/Users',
ScalableDimension='dynamodb:table:WriteCapacityUnits',
MinCapacity=5,
MaxCapacity=100
)
# Create the scaling policy
application_autoscaling.put_scaling_policy(
PolicyName='UsersWriteScalingPolicy',
ServiceNamespace='dynamodb',
ResourceId='table/Users',
ScalableDimension='dynamodb:table:WriteCapacityUnits',
PolicyType='TargetTrackingScaling',
TargetTrackingScalingPolicyConfiguration={
'TargetValue': 70.0,
'PredefinedMetricSpecification': {
'PredefinedMetricType': 'DynamoDBWriteCapacityUtilization'
},
'ScaleOutCooldown': 60,
'ScaleInCooldown': 60
}
)
print("Table created with auto-scaling configured.")
For on-demand mode, the table creation is simpler since you do not need to configure auto-scaling:
response = dynamodb.create_table(
TableName='Users',
KeySchema=[
{'AttributeName': 'user_id', 'KeyType': 'HASH'},
{'AttributeName': 'created_at', 'KeyType': 'RANGE'}
],
AttributeDefinitions=[
{'AttributeName': 'user_id', 'AttributeType': 'S'},
{'AttributeName': 'created_at', 'AttributeType': 'S'}
],
BillingMode='PAY_PER_REQUEST'
)
A common strategy is to start with on-demand mode during development and early production, then switch to provisioned mode with auto-scaling once your traffic patterns stabilize and you can predict capacity needs.
Designing for Even Partition Distribution
DynamoDB stores data across multiple partitions. The number of partitions is determined by your data size and throughput requirements. Each partition can handle up to 3,000 read capacity units or 1,000 write capacity units per second. If your access pattern concentrates traffic on a single partition key, you will hit these limits long before you exhaust your table's total capacity.
To avoid hot partitions, choose partition keys with high cardinality and even distribution. For example, if you are storing user activity logs, using user_id as the partition key distributes writes across all users. But if one user generates a disproportionate amount of activity, that user's partition becomes hot.
One technique to mitigate this is to append a random suffix to the partition key, a strategy known as write sharding:
import random
def generate_sharded_key(user_id, shard_count=10):
"""Append a random shard suffix to distribute writes evenly."""
shard = random.randint(0, shard_count - 1)
return f"{user_id}#{shard}"
# When writing an item
sharded_key = generate_sharded_key("user_12345")
dynamodb.put_item(
TableName='UserActivity',
Item={
'user_id_shard': {'S': sharded_key},
'timestamp': {'S': '2024-01-15T10:30:00Z'},
'activity_type': {'S': 'login'},
'details': {'S': 'User logged in from mobile app'}
}
)
The trade-off is that when reading, you need to query all shards and merge the results. This works well for write-heavy workloads where you can afford slightly more expensive reads:
def get_user_activities(user_id, shard_count=10):
"""Query all shards for a user's activities."""
all_items = []
for shard in range(shard_count):
sharded_key = f"{user_id}#{shard}"
response = dynamodb.query(
TableName='UserActivity',
KeyConditionExpression='user_id_shard = :pk',
ExpressionAttributeValues={
':pk': {'S': sharded_key}
}
)
all_items.extend(response.get('Items', []))
# Sort by timestamp after merging
all_items.sort(key=lambda x: x['timestamp']['S'], reverse=True)
return all_items
Using Secondary Indexes Effectively
DynamoDB allows you to query data only by the table's primary key. To query by alternative attributes, you need secondary indexes. There are two types: Global Secondary Indexes (GSIs) and Local Secondary Indexes (LSIs).
Global Secondary Indexes can have a different partition key and sort key than the base table. They are ideal for supporting additional access patterns. GSIs have their own capacity settings and are eventually consistent.
Local Secondary Indexes share the same partition key as the base table but allow a different sort key. They are strongly consistent and consume capacity from the base table. LSIs must be created when the table is created and cannot be added later.
Here is an example of creating a GSI to support querying users by email:
# Add a GSI to an existing table
dynamodb.update_table(
TableName='Users',
AttributeDefinitions=[
{'AttributeName': 'email', 'AttributeType': 'S'}
],
GlobalSecondaryIndexUpdates=[
{
'Create': {
'IndexName': 'EmailIndex',
'KeySchema': [
{'AttributeName': 'email', 'KeyType': 'HASH'}
],
'Projection': {
'ProjectionType': 'ALL'
},
'ProvisionedThroughput': {
'ReadCapacityUnits': 5,
'WriteCapacityUnits': 5
}
}
}
]
)
Querying the GSI is straightforward:
response = dynamodb.query(
TableName='Users',
IndexName='EmailIndex',
KeyConditionExpression='email = :email',
ExpressionAttributeValues={
':email': {'S': 'user@example.com'}
}
)
for item in response.get('Items', []):
print(item)
Be mindful of GSI costs. Every write to the base table also writes to all GSIs, multiplying your write capacity consumption. Use the Projection setting to include only the attributes you need in the index to reduce storage and write costs.
Handling Large Datasets with Pagination and Batching
As your data grows, you will need to handle large result sets and bulk operations efficiently. DynamoDB automatically paginates query and scan results, returning up to 1 MB of data per request. You must handle the LastEvaluatedKey to fetch subsequent pages:
def scan_all_items(table_name, filter_expression=None, expression_values=None):
"""Scan an entire table with pagination handling."""
all_items = []
params = {'TableName': table_name}
if filter_expression:
params['FilterExpression'] = filter_expression
if expression_values:
params['ExpressionAttributeValues'] = expression_values
while True:
response = dynamodb.scan(**params)
all_items.extend(response.get('Items', []))
if 'LastEvaluatedKey' not in response:
break
params['ExclusiveStartKey'] = response['LastEvaluatedKey']
return all_items
# Example usage
items = scan_all_items('Users')
print(f"Retrieved {len(items)} items")
For bulk writes, use BatchWriteItem to write up to 25 items in a single request, which is far more efficient than individual PutItem calls:
def batch_write_items(table_name, items):
"""Batch write up to 25 items at a time."""
batch_size = 25
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
request_items = {
table_name: [
{'PutRequest': {'Item': item}} for item in batch
]
}
response = dynamodb.batch_write_item(RequestItems=request_items)
# Handle any unprocessed items
unprocessed = response.get('UnprocessedItems', {})
while unprocessed:
response = dynamodb.batch_write_item(RequestItems=unprocessed)
unprocessed = response.get('UnprocessedItems', {})
# Example: bulk insert users
users_to_insert = [
{
'user_id': {'S': f'user_{i}'},
'email': {'S': f'user_{i}@example.com'},
'created_at': {'S': f'2024-01-{i:02d}T00:00:00Z'}
}
for i in range(1, 101)
]
batch_write_items('Users', users_to_insert)
print("Batch write complete.")
Implementing Caching to Reduce Load
Even with optimal table design, caching frequently accessed data can dramatically reduce DynamoDB read consumption and improve latency. Amazon ElastiCache (Redis or Memcached) is a common choice, or you can use DynamoDB Accelerator (DAX), which is a managed caching service designed specifically for DynamoDB.
Here is a simple caching pattern using Redis alongside DynamoDB:
import redis
import json
redis_client = redis.Redis(host='your-cache-endpoint', port=6379, db=0)
def get_user_with_cache(user_id):
"""Get a user from cache, falling back to DynamoDB."""
cache_key = f"user:{user_id}"
# Try cache first
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Fall back to DynamoDB
response = dynamodb.get_item(
TableName='Users',
Key={'user_id': {'S': user_id}}
)
if 'Item' not in response:
return None
user = response['Item']
# Cache the result with a TTL of 300 seconds
redis_client.setex(cache_key, 300, json.dumps(user, default=str))
return user
DAX provides a similar benefit with less code since it integrates directly with the DynamoDB API. You simply point your SDK at the DAX cluster instead of DynamoDB, and caching happens transparently:
from amazondax import AmazonDaxClient
# Connect to DAX instead of DynamoDB directly
dax = AmazonDaxClient(
endpoint_url='your-dax-cluster-endpoint',
region_name='us-east-1'
)
# Use DAX client exactly like the DynamoDB client
response = dax.get_item(
TableName='Users',
Key={'user_id': {'S': 'user_12345'}}
)
Monitoring and Alerting
Production scaling requires visibility into your table's performance. CloudWatch provides several key metrics for DynamoDB:
- ConsumedReadCapacityUnits / ConsumedWriteCapacityUnits: Actual throughput consumed.
- ProvisionedReadCapacityUnits / ProvisionedWriteCapacityUnits: Configured throughput.
- ThrottledRequests: Requests that were throttled due to exceeding capacity.
- SystemErrors: Internal service errors.
- SuccessfulRequestLatency: Latency of successful requests.
Set up CloudWatch alarms to alert you when throttling occurs or when consumed capacity approaches provisioned limits:
cloudwatch = boto3.client('cloudwatch')
# Create an alarm for throttled write requests
cloudwatch.put_metric_alarm(
AlarmName='UsersTableWriteThrottles',
AlarmDescription='Alert when write requests are throttled',
Namespace='AWS/DynamoDB',
MetricName='ThrottledRequests',
Dimensions=[
{'Name': 'TableName', 'Value': 'Users'}
],
Statistic='Sum',
Period=300,
EvaluationPeriods=1,
Threshold=1,
ComparisonOperator='GreaterThanOrEqualToThreshold',
TreatMissingData='notBreaching',
AlarmActions=[
'arn:aws:sns:us-east-1:123456789012:AlertsTopic'
]
)
# Create an alarm for high consumed write capacity
cloudwatch.put_metric_alarm(
AlarmName='UsersTableHighWriteConsumption',
AlarmDescription='Alert when write consumption exceeds 80% of provisioned',
Namespace='AWS/DynamoDB',
MetricName='ConsumedWriteCapacityUnits',
Dimensions=[
{'Name': 'TableName', 'Value': 'Users'}
],
Statistic='Sum',
Period=300,
EvaluationPeriods=2,
Threshold=400,
ComparisonOperator='GreaterThanThreshold',
TreatMissingData='notBreaching',
AlarmActions=[
'arn:aws:sns:us-east-1:123456789012:AlertsTopic'
]
)
Best Practices for Production DynamoDB
- Design your access patterns first: Before creating any tables, enumerate every query your application needs to make. DynamoDB schema design is driven by access patterns, not by normalizing data.
- Use single-table design when appropriate: For complex applications with many access patterns, a single-table design using composite keys and overloaded attributes can reduce the number of GSIs and improve efficiency. However, this approach has a steeper learning curve.
- Choose partition keys wisely: Ensure high cardinality and even access distribution. Avoid monotonically increasing keys like timestamps as partition keys, as new writes always go to the same partition.
- Minimize GSI projections: Only project the attributes you actually need in your indexes to save on storage and write costs.
- Use conditional writes for concurrency control: Conditional writes prevent lost updates and can implement optimistic locking without additional infrastructure.
- Implement exponential backoff for retries: When throttling occurs, retry with exponential backoff and jitter. The AWS SDKs handle this automatically, but be aware of it when writing custom retry logic.
- Use TTL for automatic data expiration: If you have time-sensitive data like session tokens or logs, enable Time to Live to let DynamoDB automatically delete expired items at no additional cost.
- Enable point-in-time recovery: This provides continuous backups of your table data for the last 35 days, protecting against accidental writes or deletes.
- Use global tables for multi-region availability: If your application serves users across the globe, DynamoDB global tables replicate your data across regions for low-latency access and disaster recovery.
- Test at scale before launch: Use load testing tools to simulate production traffic and verify that your table design and capacity settings handle expected loads without throttling.
Here is an example of enabling TTL and point-in-time recovery:
# Enable TTL on a timestamp attribute
dynamodb.update_time_to_live(
TableName='UserSessions',
TimeToLiveSpecification={
'AttributeName': 'expires_at',
'Enabled': True
}
)
# Enable point-in-time recovery
dynamodb.update_continuous_backups(
TableName='Users',
PointInTimeRecoverySpecification={
'PointInTimeRecoveryEnabled': True
}
)
Conclusion
Scaling DynamoDB from a prototype to a production system is as much about data modeling as it is about configuration. By choosing the right capacity mode, designing partition keys for even distribution, leveraging secondary indexes strategically, implementing caching, and setting up robust monitoring, you can build an application that handles millions of requests per day with consistent single-digit millisecond latency. The key is to understand your access patterns upfront, test under realistic load, and continuously refine your approach as your application evolves. DynamoDB rewards thoughtful design with exceptional performance and reliability, making it a powerful foundation for production-scale applications.