Skip to main content
aws in the trenches advanced cloud engineering for senior developers

DynamoDB Internals and Advanced Data Modeling

8 min read Chapter 4 of 21

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)

DynamoDB Partition Layout

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:

  1. All items with the same partition key live on the same partition (this is the item collection)
  2. Your throughput ceiling is per-partition, not per-table (until adaptive capacity kicks in)
  3. 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:

  1. List all entities: User, Order, OrderItem, Product, Review
  2. List all access patterns: Get user by ID, Get orders for user, Get items in order, Get reviews for product, Get recent orders (global)
  3. Design keys to satisfy queries: Each access pattern must map to a single Query or GetItem
  4. 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.