AWS DynamoDB: Data Modeling, Capacity, Indexes, and Streams
DynamoDB is a fully managed key-value and document database that scales to any throughput with single-digit millisecond latency. The design trade-offs — single-table design, access patterns dictate schema, no ad-hoc queries — require deliberate data modeling upfront. This covers DynamoDB data model (partition keys, sort keys, composite keys), capacity modes (on-demand vs provisioned, auto-scaling), Global Secondary Indexes and Local Secondary Indexes, transactions, DynamoDB Streams for CDC, TTL, and the access pattern-first design methodology that makes DynamoDB work at scale.

DynamoDB is built for one thing: predictable, single-digit millisecond read and write latency at any scale, without managing servers. You get that by sacrificing flexibility — no JOINs, no ad-hoc queries, no schema changes at scale without planning. Every decision about DynamoDB data modeling starts from the question: "what are the exact access patterns this table needs to support?"
If you approach DynamoDB like a relational database, you'll end up with multiple tables, inefficient queries, and unexpected costs. If you design from access patterns first, you get a system that handles millions of requests per second without breaking a sweat.
Core Data Model
Tables, Items, and Attributes
DynamoDB stores data in tables. Each table contains items (rows), and each item has attributes (fields). Items in the same table can have different attributes — DynamoDB is schema-less except for the primary key.
Primary key types:
- Simple primary key: just a partition key. Every item must have a unique partition key value.
- Composite primary key: partition key + sort key. Items with the same partition key are grouped together and sorted by the sort key. Multiple items can share the same partition key as long as their sort keys are unique.
1# Create a table with a composite primary key
2aws dynamodb create-table \
3 --table-name orders \
4 --attribute-definitions \
5 AttributeName=customerId,AttributeType=S \
6 AttributeName=orderId,AttributeType=S \
7 --key-schema \
8 AttributeName=customerId,KeyType=HASH \
9 AttributeName=orderId,KeyType=RANGE \
10 --billing-mode PAY_PER_REQUEST
11
12# Put an item
13aws dynamodb put-item \
14 --table-name orders \
15 --item '{
16 "customerId": {"S": "cust-123"},
17 "orderId": {"S": "ord-456"},
18 "status": {"S": "pending"},
19 "amount": {"N": "99.99"},
20 "createdAt": {"S": "2026-05-10T14:00:00Z"},
21 "items": {"L": [
22 {"M": {"sku": {"S": "SKU-001"}, "qty": {"N": "2"}}}
23 ]}
24 }'The attribute types map to JSON types: S (string), N (number stored as string), B (binary), BOOL, NULL, L (list), M (map), SS (string set), NS (number set), BS (binary set).
Partition Key Design
DynamoDB distributes data across partitions using the partition key. A poorly chosen partition key creates hot partitions — partitions that receive disproportionate traffic while others sit idle. The partition key must distribute access evenly.
Good partition keys:
userIdfor a user-centric application (reads and writes spread across many users)tenantIdfor a multi-tenant SaaS application- A high-cardinality attribute that maps to your traffic pattern
Bad partition keys:
status(most items are "active" or "pending" — highly skewed)date(all traffic today goes to today's partition)- A low-cardinality attribute with uneven distribution
For workloads with unavoidable hot partition keys (a viral post, a popular product), add a random suffix to the partition key and write sharded copies: postId#1, postId#2, ... postId#N. Read from all shards and aggregate.
Capacity Modes
On-Demand
Pay per read and write request unit. No capacity planning required. Scales instantly to any request rate.
aws dynamodb create-table \
--table-name payments \
--attribute-definitions AttributeName=paymentId,AttributeType=S \
--key-schema AttributeName=paymentId,KeyType=HASH \
--billing-mode PAY_PER_REQUESTPricing (us-east-1):
- Write Request Unit (WRU): $1.25 per million
- Read Request Unit (RRU): $0.25 per million
On-demand is right for: new tables where traffic is unknown, tables with unpredictable spikes, development/staging tables.
On-demand is expensive at high sustained throughput. At 10,000 WCU sustained, provisioned with auto-scaling is significantly cheaper than on-demand.
Provisioned with Auto-Scaling
Specify read and write capacity units (RCU/WCU). 1 WCU = 1 write of up to 1 KB per request. 1 RCU = 1 strongly consistent read of up to 4 KB per request (or 2 eventually consistent reads of up to 4 KB each).
1# Create table with provisioned capacity + auto-scaling
2aws dynamodb create-table \
3 --table-name orders \
4 --attribute-definitions \
5 AttributeName=customerId,AttributeType=S \
6 AttributeName=orderId,AttributeType=S \
7 --key-schema \
8 AttributeName=customerId,KeyType=HASH \
9 AttributeName=orderId,KeyType=RANGE \
10 --billing-mode PROVISIONED \
11 --provisioned-throughput ReadCapacityUnits=100,WriteCapacityUnits=50
12
13# Enable auto-scaling
14aws application-autoscaling register-scalable-target \
15 --service-namespace dynamodb \
16 --resource-id table/orders \
17 --scalable-dimension dynamodb:table:WriteCapacityUnits \
18 --min-capacity 10 \
19 --max-capacity 1000
20
21aws application-autoscaling put-scaling-policy \
22 --service-namespace dynamodb \
23 --resource-id table/orders \
24 --scalable-dimension dynamodb:table:WriteCapacityUnits \
25 --policy-name orders-write-scaling \
26 --policy-type TargetTrackingScaling \
27 --target-tracking-scaling-policy-configuration '{
28 "TargetValue": 70.0,
29 "PredefinedMetricSpecification": {
30 "PredefinedMetricType": "DynamoDBWriteCapacityUtilization"
31 }
32 }'Provisioned pricing (us-east-1):
- WCU: $0.00065/hour (~$0.47/month per WCU)
- RCU: $0.00013/hour (~$0.094/month per RCU)
For sustained high throughput, provisioned with auto-scaling is 5-10× cheaper than on-demand. For tables with Reserved Capacity purchases, even cheaper.
Reading and Writing Data
GetItem and PutItem
1import boto3
2from boto3.dynamodb.conditions import Key, Attr
3
4dynamodb = boto3.resource('dynamodb')
5table = dynamodb.Table('orders')
6
7# GetItem — exact primary key lookup (O(1), sub-millisecond)
8response = table.get_item(
9 Key={'customerId': 'cust-123', 'orderId': 'ord-456'},
10 ConsistentRead=True # Strongly consistent — reflects all completed writes
11 # ConsistentRead=False (default) — eventually consistent, uses 0.5 RCU instead of 1 RCU
12)
13item = response.get('Item')
14
15# PutItem — full item replace
16table.put_item(
17 Item={
18 'customerId': 'cust-123',
19 'orderId': 'ord-789',
20 'status': 'pending',
21 'amount': 149.99,
22 },
23 ConditionExpression='attribute_not_exists(orderId)' # Fail if item already exists
24)
25
26# UpdateItem — atomic attribute-level update (does not read item first)
27table.update_item(
28 Key={'customerId': 'cust-123', 'orderId': 'ord-789'},
29 UpdateExpression='SET #s = :new_status, updatedAt = :ts',
30 ExpressionAttributeNames={'#s': 'status'}, # 'status' is a reserved word
31 ExpressionAttributeValues={
32 ':new_status': 'processing',
33 ':ts': '2026-05-10T14:30:00Z',
34 ':expected': 'pending',
35 },
36 ConditionExpression='#s = :expected', # Only update if current status is 'pending'
37)Query and Scan
Query: retrieves all items with a specific partition key, optionally filtered by sort key conditions. Uses the index — efficient.
Scan: reads every item in the table. Avoid for production workloads — cost scales with table size, not result set size.
1# Query — all orders for a customer, newest first
2response = table.query(
3 KeyConditionExpression=Key('customerId').eq('cust-123'),
4 ScanIndexForward=False, # Descending order by sort key
5 Limit=20,
6)
7orders = response['Items']
8
9# Query with sort key range
10response = table.query(
11 KeyConditionExpression=Key('customerId').eq('cust-123') & Key('orderId').begins_with('ord-2026'),
12 FilterExpression=Attr('status').eq('completed'), # Post-filter (still reads all matching items before filtering)
13)FilterExpression on a query reduces the returned items but does NOT reduce the items read from the table — you pay for all items that match the KeyConditionExpression. If your filter is very selective, you're reading and paying for data you don't use. This is the motivation for designing access patterns into the key schema rather than filtering after the fact.
Also note: the Limit parameter caps the number of items evaluated (and charged) before filtering is applied, not the number returned. If you set Limit=20 with a highly selective FilterExpression, the response may return fewer than 20 items (or none), while still consuming capacity for 20 reads. Use pagination (LastEvaluatedKey) to retrieve all matching results.
Global Secondary Indexes (GSI)
GSIs let you query the table using attributes other than the primary key. Each GSI has its own partition key and optional sort key, and maintains its own read/write capacity.
1# Add a GSI to query orders by status + createdAt
2aws dynamodb update-table \
3 --table-name orders \
4 --attribute-definitions \
5 AttributeName=customerId,AttributeType=S \
6 AttributeName=orderId,AttributeType=S \
7 AttributeName=status,AttributeType=S \
8 AttributeName=createdAt,AttributeType=S \
9 --global-secondary-index-updates '[
10 {
11 "Create": {
12 "IndexName": "StatusCreatedAtIndex",
13 "KeySchema": [
14 {"AttributeName": "status", "KeyType": "HASH"},
15 {"AttributeName": "createdAt", "KeyType": "RANGE"}
16 ],
17 "Projection": {"ProjectionType": "ALL"},
18 "ProvisionedThroughput": {"ReadCapacityUnits": 20, "WriteCapacityUnits": 10}
19 }
20 }
21 ]'# Query the GSI
response = table.query(
IndexName='StatusCreatedAtIndex',
KeyConditionExpression=Key('status').eq('pending') & Key('createdAt').gte('2026-05-01'),
ScanIndexForward=False,
)GSI considerations:
- GSIs are eventually consistent — reads from a GSI may not reflect the latest writes to the base table (typically a few milliseconds of lag, but no SLA). You cannot request strongly consistent reads from a GSI —
ConsistentRead=Trueraises aValidationExceptionon GSI queries - Projection:
ALL(copies all attributes — highest read cost, most flexible),KEYS_ONLY(only partition/sort keys — cheapest),INCLUDE(specific attributes) - Each write to the base table that changes a GSI key attribute triggers a write to the GSI — factor GSI write costs into your capacity planning
- A table can have up to 20 GSIs
Local Secondary Indexes (LSI)
LSIs share the same partition key as the base table but allow a different sort key. Unlike GSIs, LSIs provide strongly consistent reads and share read/write capacity with the base table. LSIs must be defined at table creation time and cannot be added later.
LSIs are rarely the right choice — the restriction to the same partition key, the creation-time-only limitation, and the 10 GB item collection limit (per partition key value, across base table + all LSIs — this limit is only enforced on tables that have at least one LSI) make them inflexible. Prefer GSIs unless you specifically need strongly consistent reads on a secondary sort key.
Transactions
DynamoDB transactions provide all-or-nothing writes and reads across multiple items (within the same account and region), up to 100 items per transaction.
1# Write transaction — charge customer and create order atomically
2dynamodb_client = boto3.client('dynamodb')
3
4response = dynamodb_client.transact_write_items(
5 TransactItems=[
6 {
7 'Put': {
8 'TableName': 'orders',
9 'Item': {
10 'customerId': {'S': 'cust-123'},
11 'orderId': {'S': 'ord-789'},
12 'status': {'S': 'pending'},
13 'amount': {'N': '99.99'},
14 },
15 'ConditionExpression': 'attribute_not_exists(orderId)',
16 }
17 },
18 {
19 'Update': {
20 'TableName': 'accounts',
21 'Key': {'customerId': {'S': 'cust-123'}},
22 'UpdateExpression': 'SET balance = balance - :amount',
23 'ConditionExpression': 'balance >= :amount',
24 'ExpressionAttributeValues': {':amount': {'N': '99.99'}},
25 }
26 },
27 ]
28)Transactions cost 2× the normal read/write capacity — a transactional write costs 2 WCUs per KB (rounded up): a 1 KB item costs 2 WCUs, a 3 KB item costs 6 WCUs. Use transactions for operations where partial success is unacceptable (financial transfers, inventory deductions), not for every write.
DynamoDB Streams
DynamoDB Streams captures a time-ordered sequence of every item change — inserts, updates, and deletes. Each stream record contains the item key and optionally the before/after item image. Stream records are available for 24 hours.
# Enable streams on a table (NEW_AND_OLD_IMAGES captures full before/after)
aws dynamodb update-table \
--table-name orders \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGESStream view types:
KEYS_ONLY: just the key attributes of the changed itemNEW_IMAGE: the item's state after the changeOLD_IMAGE: the item's state before the changeNEW_AND_OLD_IMAGES: both (required for detecting what changed)
Common patterns:
- Change data capture (CDC): replicate changes to Elasticsearch/OpenSearch for full-text search, to Aurora for analytics, or to another DynamoDB table in a different region
- Materialized views: maintain denormalized aggregates derived from individual item changes
- Event sourcing: use item changes as the event log, stream to EventBridge Pipes for routing
For Lambda processing of DynamoDB Streams, see AWS Lambda: Functions, Event Sources, Layers, and Serverless Patterns.
TTL (Time to Live)
TTL automatically deletes expired items without consuming write capacity. Items with a TTL attribute value less than the current Unix timestamp are eligible for deletion (typically within 48 hours of expiry).
# Enable TTL on the 'expiresAt' attribute
aws dynamodb update-time-to-live \
--table-name sessions \
--time-to-live-specification Enabled=true,AttributeName=expiresAt1import time
2
3# Session item with TTL — expires in 24 hours
4table.put_item(
5 Item={
6 'sessionId': 'sess-abc123',
7 'userId': 'user-456',
8 'data': {'...': '...'},
9 'expiresAt': int(time.time()) + 86400, # Unix timestamp, 24h from now
10 }
11)TTL deletion is eventual (items may persist for up to 48 hours after expiry) and best-effort. For time-sensitive expiry (security tokens that must not be valid after a specific time), check the TTL attribute in your application rather than relying on DynamoDB to have deleted the item.
Single-Table Design
Single-table design puts multiple entity types in one DynamoDB table using overloaded partition and sort keys. This is the recommended approach when your access patterns require co-locating related data.
The key insight: DynamoDB retrieves items with the same partition key together in a single request via Query. If you keep all related entities (customer, orders, addresses) under the same partition key, you can retrieve them all in one round-trip.
1# Overloaded keys — all entities for a customer under pk=CUSTOMER#cust-123
2items = [
3 # Customer profile
4 {'pk': 'CUSTOMER#cust-123', 'sk': 'METADATA', 'name': 'Alice', 'email': 'alice@example.com'},
5 # Orders for this customer
6 {'pk': 'CUSTOMER#cust-123', 'sk': 'ORDER#2026-05-10#ord-789', 'amount': 99.99, 'status': 'pending'},
7 {'pk': 'CUSTOMER#cust-123', 'sk': 'ORDER#2026-05-09#ord-456', 'amount': 49.99, 'status': 'completed'},
8 # Addresses
9 {'pk': 'CUSTOMER#cust-123', 'sk': 'ADDRESS#shipping', 'street': '123 Main St', 'city': 'New York'},
10]
11
12# Retrieve everything for a customer in one query
13response = table.query(
14 KeyConditionExpression=Key('pk').eq('CUSTOMER#cust-123')
15)
16
17# Retrieve only orders for a customer
18response = table.query(
19 KeyConditionExpression=Key('pk').eq('CUSTOMER#cust-123') & Key('sk').begins_with('ORDER#')
20)
21
22# Retrieve the customer profile only
23response = table.get_item(
24 Key={'pk': 'CUSTOMER#cust-123', 'sk': 'METADATA'}
25)When to use single-table design:
- When access patterns require fetching multiple entity types together in one request
- When you have 1:N or M:N relationships that are always accessed together
- When you want to minimize round-trips in latency-sensitive paths
When to use separate tables:
- When entity types have completely independent access patterns and are never fetched together
- When tables need different capacity modes or TTL settings
- When teams own different entities and need independent operational control
Frequently Asked Questions
When should I use DynamoDB vs RDS/Aurora?
DynamoDB: high-throughput workloads (millions of req/s), key-value and document access patterns, global tables for multi-region active-active, serverless architectures where Lambda drives the reads and writes.
RDS/Aurora: complex queries and JOINs that span multiple entities, ad-hoc reporting, strong ACID transactions across many tables, applications that need SQL.
The wrong choice is using DynamoDB for workloads that need ad-hoc queries or complex relational operations — you'll fight the data model constantly. Use Aurora for relational workloads, DynamoDB for high-scale key-value workloads.
How do I model many-to-many relationships in DynamoDB?
Use an adjacency list: store the relationship as items where the sort key identifies the relationship type and the related entity:
1# User-to-Group M:N relationship in one table
2items = [
3 # User membership records
4 {'pk': 'USER#user-1', 'sk': 'GROUP#group-a', 'role': 'admin'},
5 {'pk': 'USER#user-1', 'sk': 'GROUP#group-b', 'role': 'member'},
6 # Group membership records (inverted index via GSI)
7 {'pk': 'GROUP#group-a', 'sk': 'USER#user-1', 'role': 'admin'},
8 {'pk': 'GROUP#group-a', 'sk': 'USER#user-2', 'role': 'member'},
9]
10
11# Query: all groups for user-1
12table.query(KeyConditionExpression=Key('pk').eq('USER#user-1') & Key('sk').begins_with('GROUP#'))
13
14# Query: all users in group-a
15table.query(KeyConditionExpression=Key('pk').eq('GROUP#group-a') & Key('sk').begins_with('USER#'))This pattern stores each relationship twice (once from each entity's perspective), trading storage for query efficiency.
What's the maximum item size in DynamoDB?
400 KB per item. For larger objects, store them in S3 and keep a reference (S3 object key or presigned URL) in DynamoDB. This is the standard approach for file metadata, large JSON blobs, and binary attachments.
How do DynamoDB Global Tables work?
Global Tables replicate a DynamoDB table to multiple AWS regions with active-active writes. You can write to any replica region and reads reflect the local region's state (eventually consistent — typically < 1 second replication lag across regions).
1# Enable global tables: first create the table in the primary region, then add replicas
2# (Global Tables V2019 — the default for new tables)
3aws dynamodb update-table \
4 --table-name orders \
5 --replica-updates '[
6 {"Create": {"RegionName": "eu-west-1"}},
7 {"Create": {"RegionName": "ap-southeast-1"}}
8 ]'Conflicts (simultaneous writes to the same item in different regions) are resolved with "last writer wins" based on timestamp. Design your write patterns to avoid conflicts — if the same user always writes to the same region, there are no conflicts.
For Lambda functions that read and write DynamoDB using the boto3 DynamoDB resource, see AWS Lambda: Functions, Event Sources, Layers, and Serverless Patterns. For DynamoDB Streams triggering Lambda for change data capture, see the event source mapping section in that post. For Aurora PostgreSQL as the relational alternative when DynamoDB's access pattern constraints are too limiting, see AWS RDS and Aurora: Managed Databases on AWS. For caching strategies that reduce DynamoDB read costs — ElastiCache DAX for microsecond latency and Redis for application-level caching — see AWS ElastiCache: Redis and Valkey Caching Patterns.
Designing a DynamoDB schema for a new access pattern, migrating a relational workload to DynamoDB, or debugging DynamoDB throttling and hot partitions? Talk to us at Coding Protocols — we help platform teams design DynamoDB schemas that handle production scale without hot partitions or runaway costs.


