DynamoDB Data Modeling: Single-Table Design for Production Workloads

Most DynamoDB performance problems trace back to one decision made at the beginning: the data model. Teams that model DynamoDB like a relational database end up with slow queries, expensive scans, and tables that are hard to evolve. DynamoDB does not reward relational thinking. It rewards understanding your access patterns upfront and designing your table structure around them.

In this article I will walk through single-table design, partition key selection, Global Secondary Indexes, and the write patterns that keep your table performing consistently as data and traffic grow.

The Core Principle: Access Patterns First

In a relational database you normalize your data first and let queries drive the access. In DynamoDB you define your access patterns first and let them drive the data model. This is not just a convention. It is the only approach that works at scale.

Before writing a single line of Terraform, answer these questions about your workload. What are every read pattern your application needs? What are every write pattern? Which patterns are high frequency and must be sub-millisecond? Which patterns can tolerate eventual consistency? Which patterns need strong consistency?

Write them all down. Your table structure will be a direct answer to that list.

Setting Up the Table with Terraform

resource "aws_dynamodb_table" "main" {
  name         = "application-data"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "PK"
  range_key    = "SK"

  attribute {
    name = "PK"
    type = "S"
  }

  attribute {
    name = "SK"
    type = "S"
  }

  attribute {
    name = "GSI1PK"
    type = "S"
  }

  attribute {
    name = "GSI1SK"
    type = "S"
  }

  attribute {
    name = "GSI2PK"
    type = "S"
  }

  attribute {
    name = "GSI2SK"
    type = "S"
  }

  global_secondary_index {
    name            = "GSI1"
    hash_key        = "GSI1PK"
    range_key       = "GSI1SK"
    projection_type = "ALL"
  }

  global_secondary_index {
    name            = "GSI2"
    hash_key        = "GSI2PK"
    range_key       = "GSI2SK"
    projection_type = "ALL"
  }

  point_in_time_recovery {
    enabled = true
  }

  server_side_encryption {
    enabled     = true
    kms_key_arn = aws_kms_key.dynamodb.arn
  }

  deletion_protection_enabled = true

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

PAY_PER_REQUEST billing mode removes the need to provision read and write capacity units. You pay per request and DynamoDB scales automatically. For workloads with unpredictable or spiky traffic this is almost always the right starting point. Provisioned mode with reserved capacity costs less at sustained predictable load but requires accurate capacity planning.

Generic attribute names like PK, SK, GSI1PK, and GSI1SK are deliberate. In a single-table design, the same physical attribute serves different logical purposes for different item types. A user item might use PK = “USER#12345” while an order item uses PK = “ORDER#67890”. Generic names avoid confusion when one column holds values of different logical types.

Single-Table Design in Practice

An e-commerce application needs to store users, orders, and order items. In a relational database these are three separate tables. In a single DynamoDB table, they coexist using prefixed key values that encode the entity type.

import boto3
import json
from datetime import datetime

dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
table    = dynamodb.Table("application-data")

def create_user(user_id: str, email: str, name: str):
    table.put_item(Item={
        "PK":     f"USER#{user_id}",
        "SK":     f"PROFILE#{user_id}",
        "GSI1PK": f"EMAIL#{email}",
        "GSI1SK": f"USER#{user_id}",
        "entity_type": "USER",
        "user_id":     user_id,
        "email":       email,
        "name":        name,
        "created_at":  datetime.utcnow().isoformat()
    })

def create_order(order_id: str, user_id: str, total: int, status: str):
    table.put_item(Item={
        "PK":     f"USER#{user_id}",
        "SK":     f"ORDER#{order_id}",
        "GSI1PK": f"ORDER#{order_id}",
        "GSI1SK": f"ORDER#{order_id}",
        "GSI2PK": f"STATUS#{status}",
        "GSI2SK": datetime.utcnow().isoformat(),
        "entity_type": "ORDER",
        "order_id":    order_id,
        "user_id":     user_id,
        "total":       total,
        "status":      status,
        "created_at":  datetime.utcnow().isoformat()
    })

def create_order_item(order_id: str, product_id: str, quantity: int, price: int):
    table.put_item(Item={
        "PK":     f"ORDER#{order_id}",
        "SK":     f"ITEM#{product_id}",
        "entity_type": "ORDER_ITEM",
        "order_id":    order_id,
        "product_id":  product_id,
        "quantity":    quantity,
        "price":       price
    })

def get_user_orders(user_id: str) -> list:
    response = table.query(
        KeyConditionExpression="PK = :pk AND begins_with(SK, :sk_prefix)",
        ExpressionAttributeValues={
            ":pk":        f"USER#{user_id}",
            ":sk_prefix": "ORDER#"
        }
    )
    return response["Items"]

def get_order_with_items(order_id: str) -> list:
    response = table.query(
        IndexName="GSI1",
        KeyConditionExpression="GSI1PK = :pk",
        ExpressionAttributeValues={
            ":pk": f"ORDER#{order_id}"
        }
    )
    return response["Items"]

def get_orders_by_status(status: str, limit: int = 50) -> list:
    response = table.query(
        IndexName="GSI2",
        KeyConditionExpression="GSI2PK = :pk",
        ExpressionAttributeValues={
            ":pk": f"STATUS#{status}"
        },
        ScanIndexForward=False,
        Limit=limit
    )
    return response["Items"]

This design handles four access patterns with zero table scans. Get a user’s profile by user ID. Get all orders for a user. Get a specific order and all its items. Get all orders with a given status sorted by creation time. Each pattern maps to either the base table or one of the GSIs.

The begins_with condition on the sort key is how you query for items of a specific type within a partition. All orders for a user have SK values starting with ORDER#. All order items have SK values starting with ITEM#. This lets you fetch all entity types in a partition or filter to a specific type using the same key structure.

Write Patterns and Atomic Operations

DynamoDB TransactWriteItems lets you write up to 100 items atomically across multiple partitions. Use it when business logic requires multiple writes to succeed or fail together.

def place_order(user_id: str, order_id: str, items: list, total: int):
    transact_items = [
        {
            "Put": {
                "TableName": "application-data",
                "Item": {
                    "PK":          {"S": f"USER#{user_id}"},
                    "SK":          {"S": f"ORDER#{order_id}"},
                    "GSI1PK":      {"S": f"ORDER#{order_id}"},
                    "GSI1SK":      {"S": f"ORDER#{order_id}"},
                    "GSI2PK":      {"S": "STATUS#PENDING"},
                    "GSI2SK":      {"S": datetime.utcnow().isoformat()},
                    "entity_type": {"S": "ORDER"},
                    "order_id":    {"S": order_id},
                    "user_id":     {"S": user_id},
                    "total":       {"N": str(total)},
                    "status":      {"S": "PENDING"}
                },
                "ConditionExpression": "attribute_not_exists(PK)"
            }
        }
    ]

    for item in items:
        transact_items.append({
            "Put": {
                "TableName": "application-data",
                "Item": {
                    "PK":         {"S": f"ORDER#{order_id}"},
                    "SK":         {"S": f"ITEM#{item['product_id']}"},
                    "entity_type":{"S": "ORDER_ITEM"},
                    "product_id": {"S": item["product_id"]},
                    "quantity":   {"N": str(item["quantity"])},
                    "price":      {"N": str(item["price"])}
                }
            }
        })

    client = boto3.client("dynamodb", region_name="us-east-1")
    client.transact_write_items(TransactItems=transact_items)

The ConditionExpression = “attribute_not_exists(PK)” on the order write ensures idempotency. If the same order_id is submitted twice, the second write fails the condition check and the transaction is rejected. This prevents duplicate orders from being created when a client retries after a network timeout.

Hot Partition Prevention

DynamoDB distributes read and write capacity across partitions based on partition key values. If too many requests go to the same partition key, you get throttling regardless of how much capacity you have provisioned or how generous your PAY_PER_REQUEST limit is.

Common hot partition causes include using a low-cardinality value as a partition key like a status field with only a handful of values, using a monotonically increasing timestamp as the partition key so all writes go to the latest partition, or having one customer account with dramatically more traffic than all others.

The fix depends on the cause. For low-cardinality keys, add a random suffix from a small set to distribute writes. For time series data, include a shard number in the key and round-robin writes across shards. For hot customer accounts, use write sharding at the application layer and aggregate on read.

Closing Thoughts

DynamoDB rewards teams who take the time to understand their access patterns before writing the first item. The single-table design pattern looks unfamiliar if your background is relational databases, but it is the approach that makes DynamoDB work at scale with single-digit millisecond latency at any traffic level.

Define your access patterns, design your key structure around them, use GSIs for the patterns that cannot be served by the base table, and use TransactWriteItems anywhere business logic requires atomicity. Build that foundation correctly and DynamoDB will carry you through years of growth without a schema migration.

Enjoy the cloud.

Osama


#AWS #DynamoDB #DatabaseEngineering #NoSQL #CloudArchitecture #Terraform #InfrastructureAsCode #SingleTableDesign #CloudNative #AmazonWebServices #SolutionsArchitect #CloudComputing #BackendEngineering #DataModeling #SystemDesign #TechBlog #CloudInfrastructure #ServerlessArchitecture #DataEngineering #DevOps

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.