DynamoDB Internals and Advanced Data Modeling
DynamoDB Internals and Advanced Data Modeling
DynamoDB appears simple from the outside: put items, get items, query by key. The simplicity is a lie that holds until you hit 10,000 WCU, need to model 6 different access patterns in one table, or discover that your “evenly distributed” partition key has a hot partition that throttles 30% of your writes. To use DynamoDB effectively at scale, you need to understand the machine behind the API.
The Partition Architecture
DynamoDB stores data in partitions — internal storage nodes that each handle a slice of the key space. The critical numbers:
- Each partition supports up to 3,000 RCU and 1,000 WCU
- Each partition holds up to 10 GB of data
- Partitions are allocated based on throughput provisioned OR data size (whichever requires more)
- Once a partition is allocated, it is never deallocated (even if you reduce provisioned capacity)
The partition key value determines which partition stores an item. DynamoDB hashes the partition key with a consistent hashing algorithm and routes the request to the owning partition. This means:
- All items with the same partition key live on the same partition (this is the item collection)
- Your throughput ceiling is per-partition, not per-table (until adaptive capacity kicks in)
- Hot partition keys can throttle even if the table-level throughput is well below provisioned capacity
Request Routing and Adaptive Capacity
When you issue a request to DynamoDB, it passes through these layers:
Client → Request Router → Storage Node (Leader) → Replication (2 additional nodes)
For strongly consistent reads: the router goes directly to the leader node. For eventually consistent reads: the router can go to ANY of the 3 replicas (2x throughput). For writes: always to the leader, then synchronously to 1 replica before acknowledging (2 of 3 quorum).
Adaptive Capacity (automatic since 2019) redistributes unused throughput from cold partitions to hot ones. But it has limits:
import boto3
from decimal import Decimal
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('orders')
# BAD: Partition key with time-based hot spot
# All orders in the current minute hit the same partition
def write_order_bad(order):
table.put_item(Item={
'pk': f"ORDER#{order['date']}", # Hot: all today's orders → 1 partition
'sk': order['order_id'],
'amount': Decimal(str(order['amount'])),
'customer_id': order['customer_id']
})
# GOOD: Write-sharded partition key distributes load
import hashlib
def write_order_good(order, shard_count=10):
# Deterministic shard based on order_id
shard = int(hashlib.md5(order['order_id'].encode()).hexdigest(), 16) % shard_count
table.put_item(Item={
'pk': f"ORDER#{order['date']}#SHARD{shard}",
'sk': order['order_id'],
'amount': Decimal(str(order['amount'])),
'customer_id': order['customer_id']
})
# To query all orders for a date, you now query all shards:
def get_all_orders_for_date(date: str, shard_count=10):
items = []
for shard in range(shard_count):
response = table.query(
KeyConditionExpression='pk = :pk',
ExpressionAttributeValues={':pk': f"ORDER#{date}#SHARD{shard}"}
)
items.extend(response['Items'])
return items
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.*;
public class ShardedWrites {
private final DynamoDbClient dynamo = DynamoDbClient.create();
private static final int SHARD_COUNT = 10;
private static final String TABLE_NAME = "orders";
public void writeOrder(Map<String, String> order) {
int shard = Math.abs(order.get("order_id").hashCode()) % SHARD_COUNT;
String pk = "ORDER#" + order.get("date") + "#SHARD" + shard;
dynamo.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.builder().s(pk).build(),
"sk", AttributeValue.builder().s(order.get("order_id")).build(),
"amount", AttributeValue.builder().n(order.get("amount")).build(),
"customer_id", AttributeValue.builder().s(order.get("customer_id")).build()
))
.build());
}
// Parallel scatter-gather across all shards
public List<Map<String, AttributeValue>> getAllOrdersForDate(String date)
throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(SHARD_COUNT);
List<Future<List<Map<String, AttributeValue>>>> futures = new ArrayList<>();
for (int shard = 0; shard < SHARD_COUNT; shard++) {
String pk = "ORDER#" + date + "#SHARD" + shard;
futures.add(executor.submit(() -> {
QueryResponse response = dynamo.query(QueryRequest.builder()
.tableName(TABLE_NAME)
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(
":pk", AttributeValue.builder().s(pk).build()))
.build());
return response.items();
}));
}
List<Map<String, AttributeValue>> allItems = new ArrayList<>();
for (Future<List<Map<String, AttributeValue>>> f : futures) {
allItems.addAll(f.get());
}
executor.shutdown();
return allItems;
}
}
Single-Table Design: The Method
Single-table design means storing multiple entity types in one DynamoDB table, using generic key names (pk, sk) and overloading them with prefixed values. This isn’t about saving money on tables — it’s about enabling access patterns that would require JOINs in SQL, executed in a single Query call.
The methodology:
- List all entities: User, Order, OrderItem, Product, Review
- List all access patterns: Get user by ID, Get orders for user, Get items in order, Get reviews for product, Get recent orders (global)
- Design keys to satisfy queries: Each access pattern must map to a single Query or GetItem
- Use GSIs for additional access patterns that can’t be served by the base table keys
# E-commerce single-table design
# Entity: User, Order, OrderItem, Product
# Base table key design:
# pk | sk | Entity | Access Pattern
# ------------|---------------------|-----------|----------------------------
# USER#123 | PROFILE | User | Get user by ID
# USER#123 | ORDER#2024-01-15#001| Order | Get orders for user (sorted by date)
# ORDER#001 | ITEM#SKU-ABC | OrderItem | Get items in an order
# PRODUCT#ABC | METADATA | Product | Get product by ID
# PRODUCT#ABC | REVIEW#USER#123 | Review | Get reviews for product
def create_user(user_id: str, name: str, email: str):
table.put_item(Item={
'pk': f'USER#{user_id}',
'sk': 'PROFILE',
'entity_type': 'User',
'name': name,
'email': email,
'gsi1pk': f'EMAIL#{email}', # GSI1: lookup user by email
'gsi1sk': f'USER#{user_id}'
})
def create_order(user_id: str, order_id: str, total: str, items: list):
from datetime import datetime
now = datetime.utcnow().isoformat()
# Transaction: Create order + all items atomically
transact_items = [
{
'Put': {
'TableName': 'app-table',
'Item': {
'pk': {'S': f'USER#{user_id}'},
'sk': {'S': f'ORDER#{now}#{order_id}'},
'entity_type': {'S': 'Order'},
'order_id': {'S': order_id},
'total': {'N': total},
'status': {'S': 'PENDING'},
'gsi1pk': {'S': f'ORDER#{order_id}'},
'gsi1sk': {'S': 'METADATA'}
}
}
}
]
for item in items:
transact_items.append({
'Put': {
'TableName': 'app-table',
'Item': {
'pk': {'S': f'ORDER#{order_id}'},
'sk': {'S': f'ITEM#{item["sku"]}'},
'entity_type': {'S': 'OrderItem'},
'quantity': {'N': str(item['quantity'])},
'price': {'N': str(item['price'])}
}
}
})
client = boto3.client('dynamodb')
client.transact_write_items(TransactItems=transact_items)
def get_user_orders(user_id: str, limit=20):
"""Get user's recent orders — single query, sorted by date descending."""
response = table.query(
KeyConditionExpression='pk = :pk AND begins_with(sk, :prefix)',
ExpressionAttributeValues={
':pk': f'USER#{user_id}',
':prefix': 'ORDER#'
},
ScanIndexForward=False, # Descending (most recent first)
Limit=limit
)
return response['Items']
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.time.Instant;
import java.util.*;
public class SingleTableDesign {
private final DynamoDbClient dynamo = DynamoDbClient.create();
private static final String TABLE = "app-table";
public void createOrder(String userId, String orderId, String total,
List<Map<String, String>> items) {
List<TransactWriteItem> transactItems = new ArrayList<>();
// Order entity
transactItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(TABLE)
.item(Map.of(
"pk", attr("USER#" + userId),
"sk", attr("ORDER#" + Instant.now() + "#" + orderId),
"entity_type", attr("Order"),
"order_id", attr(orderId),
"total", numAttr(total),
"status", attr("PENDING"),
"gsi1pk", attr("ORDER#" + orderId),
"gsi1sk", attr("METADATA")
))
.build())
.build());
// Order items
for (Map<String, String> item : items) {
transactItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(TABLE)
.item(Map.of(
"pk", attr("ORDER#" + orderId),
"sk", attr("ITEM#" + item.get("sku")),
"entity_type", attr("OrderItem"),
"quantity", numAttr(item.get("quantity")),
"price", numAttr(item.get("price"))
))
.build())
.build());
}
dynamo.transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(transactItems)
.build());
}
public List<Map<String, AttributeValue>> getUserOrders(String userId, int limit) {
QueryResponse response = dynamo.query(QueryRequest.builder()
.tableName(TABLE)
.keyConditionExpression("pk = :pk AND begins_with(sk, :prefix)")
.expressionAttributeValues(Map.of(
":pk", attr("USER#" + userId),
":prefix", attr("ORDER#")
))
.scanIndexForward(false) // Most recent first
.limit(limit)
.build());
return response.items();
}
private AttributeValue attr(String s) {
return AttributeValue.builder().s(s).build();
}
private AttributeValue numAttr(String n) {
return AttributeValue.builder().n(n).build();
}
}
Transaction Guarantees and Limitations
DynamoDB transactions provide ACID guarantees across up to 100 items in a single request. But the implementation has constraints you must design around:
- 100 item limit per transaction (25 for TransactWriteItems, 100 for TransactGetItems — Wait, no: 100 for both as of 2023)
- 4 MB total request size including all items in the transaction
- All items must be in the same region (no cross-region transactions)
- Transactions cost 2x the WCU/RCU of non-transactional operations
- No read-then-write in one transaction — use ConditionExpressions instead
- Transactions are serializable isolation level — they will conflict and retry on concurrent modifications to the same items
# Idempotent transaction with condition check (optimistic locking)
def transfer_funds(from_account: str, to_account: str, amount: Decimal):
client = boto3.client('dynamodb')
try:
client.transact_write_items(
TransactItems=[
{
'Update': {
'TableName': 'app-table',
'Key': {
'pk': {'S': f'ACCOUNT#{from_account}'},
'sk': {'S': 'BALANCE'}
},
'UpdateExpression': 'SET balance = balance - :amount',
'ConditionExpression': 'balance >= :amount', # Prevent overdraft
'ExpressionAttributeValues': {
':amount': {'N': str(amount)}
}
}
},
{
'Update': {
'TableName': 'app-table',
'Key': {
'pk': {'S': f'ACCOUNT#{to_account}'},
'sk': {'S': 'BALANCE'}
},
'UpdateExpression': 'SET balance = balance + :amount',
'ExpressionAttributeValues': {
':amount': {'N': str(amount)}
}
}
}
],
ClientRequestToken=f'{from_account}-{to_account}-{amount}-{int(time.time())}'
# ClientRequestToken makes this idempotent for 10 minutes
)
except client.exceptions.TransactionCanceledException as e:
reasons = e.response['CancellationReasons']
if reasons[0]['Code'] == 'ConditionalCheckFailed':
raise InsufficientFundsError(f"Account {from_account} has insufficient balance")
raise
Key insight: DynamoDB is not a relational database forced into a key-value API. It’s a purpose-built engine for known access patterns with guaranteed single-digit millisecond latency at any scale. If your access patterns are unknown or frequently changing, use a relational database. DynamoDB punishes schema-on-read thinking.