← Back to DevBytes

Scaling DynamoDB: From Prototype to Production

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:

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles