AWS DynamoDB: NoSQL Database Patterns
Amazon DynamoDB is a fully managed NoSQL database service that delivers single-digit millisecond performance at any scale. However, its performance and cost-effectiveness depend heavily on how well you model your data. Unlike relational databases, DynamoDB rewards developers who design their schema around access patterns rather than entity relationships.
This post covers the foundational concepts and proven patterns for building efficient DynamoDB applications.
Core Concepts
Before diving into patterns, let's establish the building blocks.
Primary Keys
DynamoDB tables require a primary key that uniquely identifies each item. There are two types:
- Partition key (simple primary key): A single attribute that determines the physical partition where the item is stored.
- Composite key (partition key + sort key): Enables multiple items to share a partition key, sorted by the sort key.
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.create_table(
TableName='Orders',
KeySchema=[
{'AttributeName': 'PK', 'KeyType': 'HASH'}, # Partition key
{'AttributeName': 'SK', 'KeyType': 'RANGE'} # Sort key
],
AttributeDefinitions=[
{'AttributeName': 'PK', 'AttributeType': 'S'},
{'AttributeName': 'SK', 'AttributeType': 'S'}
],
BillingMode='PAY_PER_REQUEST'
)
Think in Access Patterns
The golden rule: design your table based on how you query, not how you store. List every access pattern your application needs before designing the schema.
Pattern 1: Single-Table Design
A common instinct is to create one table per entity, mimicking relational design. In DynamoDB, the recommended approach is often the opposite: store multiple entity types in a single table.
Consider an application with Users and their Orders. Using generic key names (PK, SK), we can co-locate related items:
| PK | SK | Attributes |
|---|---|---|
| USER#123 | PROFILE | name, email |
| USER#123 | ORDER#1001 | total, status |
| USER#123 | ORDER#1002 | total, status |
Fetching a user and all their orders becomes a single query:
response = table.query(
KeyConditionExpression=Key('PK').eq('USER#123')
)
To fetch just the profile, add a sort key condition:
response = table.query(
KeyConditionExpression=Key('PK').eq('USER#123') & Key('SK').eq('PROFILE')
)
Pattern 2: Global Secondary Indexes (GSIs)
When you need to query by attributes other than the primary key, use a Global Secondary Index. A GSI has its own partition and sort key and is maintained automatically by DynamoDB.
For example, to query orders by status:
# Add a GSI with StatusKey as partition key
GlobalSecondaryIndexes=[
{
'IndexName': 'StatusIndex',
'KeySchema': [
{'AttributeName': 'status', 'KeyType': 'HASH'},
{'AttributeName': 'createdAt', 'KeyType': 'RANGE'}
],
'Projection': {'ProjectionType': 'ALL'}
}
]
Query pending orders sorted by creation time:
response = table.query(
IndexName='StatusIndex',
KeyConditionExpression=Key('status').eq('PENDING')
)
Tip: GSIs are eventually consistent and have their own provisioned throughput. Use sparse indexes (only indexing items that have the GSI key attribute) to reduce cost.
Pattern 3: Composite Sort Keys
Sort keys support range queries using begins_with, between, and comparison operators. By concatenating hierarchical data into the sort key, you enable flexible querying.
# SK format: COUNTRY#USA#STATE#CA#CITY#SF
response = table.query(
KeyConditionExpression=Key('PK').eq('LOCATIONS') &
Key('SK').begins_with('COUNTRY#USA#STATE#CA')
)
This lets a single index answer queries at different granularity levels.
Pattern 4: Adjacency List for Many-to-Many
For many-to-many relationships (e.g., users belonging to multiple groups), the adjacency list pattern models both sides of the relationship:
| PK | SK |
|---|---|
| USER#123 | GROUP#A |
| USER#123 | GROUP#B |
| GROUP#A | USER#123 |
| GROUP#A | USER#456 |
Query all groups for a user via the base table, and all users in a group by inverting the keys with a GSI.
Pattern 5: Write Sharding for Hot Partitions
If a single partition key receives disproportionate traffic (a "hot partition"), throughput can throttle. Distribute writes by appending a suffix:
import random
shard = random.randint(0, 9)
pk = f"POPULAR_ITEM#{shard}"
To read all data, query each shard and merge results. This trades read complexity for improved write scalability.
Best Practices Summary
- Minimize table count. Single-table design reduces round trips and operational overhead.
-
Avoid scans. Use
Querywith well-designed keys instead ofScanon production hot paths. - Use on-demand capacity for unpredictable workloads and provisioned with auto-scaling for steady ones.
- **Enable point
Top comments (0)