Single-Table Design: Start Here
DynamoDB isn't a relational database and it won't behave like one no matter how hard you try. The biggest mindset shift is this: you model for your access patterns, not your data relationships. If you need to query data six different ways, you design your table and indexes to serve those six queries efficiently.
Single-table design puts all your entities in one table using carefully crafted partition keys and sort keys. A table might hold Users, Orders, and OrderItems all in the same place, differentiated by key prefixes.
# Example: E-commerce single-table design
# Entity | PK | SK
# User | USER#alice | PROFILE
# User email | USER#alice | EMAIL#alice@example.com
# Order | USER#alice | ORDER#2024-03-15#ord-123
# Order Item | ORDER#ord-123 | ITEM#sku-456
# Product | PRODUCT#sku-456 | DETAILS
Partition Key Selection
Your partition key determines how DynamoDB distributes data across storage partitions. A key with high cardinality (many unique values) distributes evenly. A key with low cardinality creates hot partitions.
I've seen teams use a date as their partition key for time-series data. All writes go to today's partition, creating a massive hot spot. The fix is either adding a random suffix (write sharding) or using a composite key that includes another high-cardinality attribute.
# Bad: all today's events hit one partition
PK = "2024-03-15"
# Better: spread across 10 shards
PK = "2024-03-15#shard-7" # where shard = hash(event_id) % 10
# Read pattern: scatter-gather across all shards
for shard in range(10):
query(PK=f"2024-03-15#shard-{shard}")
GSI Overloading for Multiple Access Patterns
Global Secondary Indexes let you query by different key combinations. GSI overloading means you design your GSI keys to serve multiple entity types.
Consider this: your GSI1PK holds the entity type, and GSI1SK holds a timestamp. You can query "all orders since date X" using the same GSI that serves "all products in category Y" — because orders set GSI1PK = "ORDER" and products set GSI1PK = "CATEGORY#electronics".
# Primary table access patterns:
# 1. Get user profile: PK=USER#id, SK=PROFILE
# 2. List user's orders: PK=USER#id, SK begins_with ORDER#
# GSI1 access patterns:
# 3. Orders by date (all users): GSI1PK=ORDER, GSI1SK=date
# 4. Products by category: GSI1PK=CAT#electronics, GSI1SK=price
# GSI2 access patterns:
# 5. Lookup by email: GSI2PK=email, GSI2SK=USER
# 6. Order by status: GSI2PK=STATUS#pending, GSI2SK=date
Capacity Planning: On-Demand vs Provisioned
On-demand mode is simpler — you pay per request and DynamoDB scales automatically. But it's roughly 6.5x more expensive per request than provisioned capacity at steady state. For predictable workloads, provisioned capacity with auto-scaling saves significant money.
The crossover point in my experience: if your table handles a consistent baseline of more than ~5 reads or writes per second, provisioned mode starts making financial sense. Below that, on-demand avoids the complexity of capacity planning.
A pattern I've used for tables with unpredictable bursts: provisioned capacity for the baseline, with the auto-scaling target set to 70% utilization and a max that can handle 3x the baseline. This covers normal variation. For truly unpredictable spikes (product launches, marketing campaigns), temporarily switch to on-demand mode, then switch back.
Avoiding Common Anti-Patterns
Scan operations read every item in the table. They're expensive and slow. If you're scanning regularly, your table design doesn't match your access patterns. Go back and add a GSI.
Large items (over 400KB) can't be stored in DynamoDB. If you're hitting this limit, store the large data in S3 and keep a reference (S3 key) in DynamoDB. This is especially common for JSON payloads, document content, or binary data.
Transaction limits matter too. TransactWriteItems supports up to 100 items per transaction. If you need to update more than that atomically, you'll have to rethink your data model or implement application-level consistency guarantees.
Write Sharding for Hot Partitions
When a single partition key receives more than 1,000 WCU or 3,000 RCU per second, you've got a hot partition. DynamoDB will throttle requests to that partition even if you've provisioned enough total capacity for the table.
import random, boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Counters')
SHARD_COUNT = 10
def increment_counter(counter_name, amount=1):
shard = random.randint(0, SHARD_COUNT - 1)
table.update_item(
Key={'PK': f'{counter_name}#shard-{shard}', 'SK': 'COUNT'},
UpdateExpression='ADD hit_count :inc',
ExpressionAttributeValues={':inc': amount}
)
def get_counter(counter_name):
total = 0
for shard in range(SHARD_COUNT):
resp = table.get_item(
Key={'PK': f'{counter_name}#shard-{shard}', 'SK': 'COUNT'})
if 'Item' in resp:
total += resp['Item'].get('hit_count', 0)
return total
Time-to-Live for Automatic Cleanup
TTL is free in DynamoDB. When an item's TTL attribute expires, DynamoDB deletes it automatically within 48 hours. This is perfect for session data, temporary tokens, and cache entries.
A pattern I use frequently: event processing with deduplication. Write each event ID with a TTL of 24 hours. Before processing a new event, check if its ID exists. If it does, it's a duplicate. After 24 hours, the dedup record disappears automatically.
Conditional Writes for Concurrency Control
DynamoDB doesn't have row locks, but conditional expressions give you optimistic concurrency control. Every update includes a condition that the item hasn't changed since you last read it.
def update_order_status(order_id, new_status, expected_version):
try:
table.update_item(
Key={'PK': f'ORDER#{order_id}', 'SK': 'DETAILS'},
UpdateExpression='SET #s = :ns, version = version + :one',
ConditionExpression='version = :expected',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={
':ns': new_status, ':expected': expected_version, ':one': 1
})
return True
except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
return False
DynamoDB Streams for Event-Driven Processing
DynamoDB Streams captures a time-ordered sequence of item-level changes. Every insert, update, and delete generates a stream record. Common use cases include building materialized views, triggering notifications when data changes, and replicating data to Elasticsearch for full-text search.
One operational detail: DynamoDB Streams retains records for 24 hours. If your Lambda consumer falls behind by more than 24 hours, you lose events. Set up a CloudWatch alarm on the iterator age metric to catch this.
Global Tables for Multi-Region Active-Active
DynamoDB Global Tables replicate data across multiple regions with eventual consistency. Unlike Aurora Global Database (which has a single writer region), Global Tables support writes in every region simultaneously. Last-writer-wins conflict resolution handles concurrent updates to the same item.
The tradeoff is that concurrent writes to the same item in different regions can lose one update. If User A updates an item in us-east-1 at the same millisecond that User B updates it in eu-west-1, one write wins and the other is silently discarded. For most use cases this is fine — shopping carts, session data, user preferences rarely have conflicting concurrent writes.
For use cases where conflict resolution matters (inventory counts, financial balances), design your data model to avoid conflicts. Use atomic counters (ADD operations) instead of SET operations. ADD operations on the same attribute from different regions are commutative — both increments apply regardless of order. This eliminates the conflict entirely at the data model level.
Replication lag for Global Tables is typically 500ms-1.5 seconds between regions. Don't depend on immediate consistency for cross-region reads after writes. If your application writes in us-east-1 and reads in eu-west-1, design for eventual consistency or route both read and write for a given entity to the same region.
DynamoDB Accelerator (DAX) Evaluation
DAX is an in-memory cache that sits in front of DynamoDB. Read latency drops from single-digit milliseconds to microseconds. It's a write-through cache, so writes go through DAX to DynamoDB and the cache stays current.
DAX makes sense when your read pattern is highly repetitive — the same items read thousands of times. Think product catalog pages, configuration lookups, reference data. It doesn't help much for scan-heavy workloads or when read patterns are diverse (each request reads a different item).
The cost isn't trivial: a dax.r5.large cluster with 3 nodes (the minimum for production) runs about $600/month. Compare that to the DynamoDB read capacity you'd save. If your table does 10,000 RCU consistently and DAX can absorb 90% of those reads, you're saving $585/month in RCU costs. The break-even is close, so the real justification is latency improvement rather than cost savings.