AWS DynamoDB Single-Table Design Patterns: Complete Guide (2026)
DynamoDB single-table design is one of the most powerful — and most misunderstood — techniques in modern cloud architecture. Instead of modeling your data the way you would in a relational database (one table per entity type), single-table design collapses all your entities — users, orders, products, sessions — into a single DynamoDB table. The result is dramatically fewer read operations, lower latency, reduced cost, and a data model that scales horizontally without the join overhead that plagues SQL at scale. This guide walks through the complete single-table design methodology: from access pattern analysis and key overloading, to GSI strategies, Python boto3 code, and the real pitfalls teams hit in production.
Table of Contents
- 1. What Is Single-Table Design and Why It Matters
- 2. Access Pattern Analysis: Define Relationships First
- 3. Primary Key Design: Partition Key + Sort Key Strategies
- 4. Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI)
- 5. Entity Types and Item Collections with PK/SK Prefixes
- 6. Python boto3: Creating a Single-Table for Users, Orders, Products
- 7. Query Patterns: Fetching Entities Efficiently
- 8. Sparse Indexes for Efficient Filtering
- 9. DynamoDB Streams for Event-Driven Patterns
- 10. Common Pitfalls: Hot Partitions, Large Items, GSI Costs
- 11. Tools: NoSQL Workbench for Modeling
1. What Is Single-Table Design and Why It Matters
In a relational database, normalization encourages you to split data into many tables and use JOINs at query time to reassemble related records. This works well when your query patterns are flexible and your dataset fits comfortably on one machine. But DynamoDB is fundamentally different: it is a distributed key-value store designed for single-digit millisecond latency at any scale. There are no JOINs in DynamoDB. Every piece of data you need in a single request must either live in the same item or be co-located in the same partition, so DynamoDB can retrieve it in one round trip.
Single-table design (STD) is the practice of storing all entity types for a given application workload in a single DynamoDB table, using a carefully chosen primary key scheme to organize and retrieve items efficiently. The technique was popularized by AWS Hero Rick Houlihan and is the recommended approach for DynamoDB-native applications. When done well, STD lets you retrieve a user profile, their latest five orders, and the associated product names in a single Query call — no additional round trips.
Compare the two approaches for an e-commerce application. A multi-table design would create separate tables: Users, Orders, OrderItems, Products. Fetching a user's order history requires at minimum three separate GetItem or Query calls. In single-table design, all those entities share one table, organized so a single Query on PK = "USER#123" returns the user profile, all orders, and — with the right GSI — order items in a single pass.
Single-table design does have trade-offs. The data model is less intuitive to newcomers, migrations require care, and debugging raw table contents is harder without tooling. But for production workloads where performance and cost matter, the benefits consistently outweigh the complexity. The rest of this guide teaches you the methodology to do it correctly.
2. Access Pattern Analysis: Define Relationships First
The cardinal rule of DynamoDB modeling is: know your access patterns before you write a single line of table schema. This is the opposite of relational design, where you normalize first and optimize queries later. In DynamoDB, your access patterns drive every key, index, and projection decision.
Start by listing every read and write operation your application will perform. For an e-commerce platform, a basic access pattern list might look like this:
- Get a user profile by user ID
- Get all orders for a user, sorted by date descending
- Get a single order with all its line items
- Get all products in a category
- Get a product by product ID
- Get all open orders across all users (admin view)
- Get the three most recent orders for a user's homepage widget
- Look up a user by email address (login flow)
Once you have this list, classify each pattern by its primary identifier (what you always know at query time) and its secondary filter (what you want to narrow down). For example, "Get all orders for a user, sorted by date" means you always know the user ID and want to range-scan on a date-based sort key. This maps directly to a Query on PK = "USER#<id>" with a sort key condition like SK BEGINS_WITH "ORDER#".
Access patterns also tell you which attributes need to be projected into GSIs and which patterns require a reverse lookup (e.g., finding the user who owns a given order). Patterns that cannot be served by the base table's primary key will require a GSI. Aim to serve 80–90% of your patterns from the base table; each additional GSI adds write cost and storage overhead.
3. Primary Key Design: Partition Key + Sort Key Strategies
Every DynamoDB table has a partition key (PK) and an optional sort key (SK). In single-table design, you almost always use a composite primary key (PK + SK) to take advantage of DynamoDB's range query capability. The core technique is key overloading: the PK and SK columns do not hold a single type of value — they hold different prefixed values depending on the entity type stored in that item.
Key Overloading Pattern
The standard convention uses string prefixes separated by a hash character. A user item might have PK = "USER#u-001" and SK = "PROFILE". An order placed by that user has PK = "USER#u-001" and SK = "ORDER#2026-06-10#o-789". Because both items share the same PK, they land in the same partition and can be retrieved together with a single Query.
# Single-table key design for an e-commerce workload
Table Name: AppTable
Entity PK SK
----------- ------------------ --------------------------
User USER#<userId> PROFILE
Order USER#<userId> ORDER#<isoDate>#<orderId>
OrderItem ORDER#<orderId> ITEM#<productId>
Product PRODUCT#<productId> METADATA
Category CATEGORY#<catId> PRODUCT#<productId>
Session SESSION#<sessionId> <userId>
Notice several design choices in the schema above. The Order sort key encodes the ISO date before the order ID. This means a range query with SK BEGINS_WITH "ORDER#2026" returns only 2026 orders, and SK BEGINS_WITH "ORDER#" returns all orders sorted chronologically — because DynamoDB sorts lexicographically and ISO dates sort correctly as strings.
Partition Key Cardinality
Choose partition keys that distribute load evenly across DynamoDB's storage nodes. A PK like STATUS#pending that most active orders share is a hot partition waiting to happen. Prefer entity IDs — UUIDs, user IDs — that spread naturally. For high-throughput scenarios, consider adding a shard suffix (USER#u-001#shard-3) and reading from all shards in parallel, then merging client-side.
ORDER#2026-06-10T14:32:00Z#<id>) enables time-range queries for free. Prefer ISO 8601 over Unix timestamps for lexicographic correctness.
4. Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI)
Even with perfectly overloaded base table keys, some access patterns need to look up data by a different attribute. Global Secondary Indexes (GSIs) let you define an entirely new PK + SK pair on the same table, projecting selected attributes, so DynamoDB can serve queries against that alternate key space efficiently.
GSI Design Principles
A GSI is essentially a separate, automatically maintained copy of your table (or a subset of it) keyed differently. You can have up to 20 GSIs per table. Each GSI has its own read and write capacity, and writes to the base table propagate to GSIs asynchronously (eventual consistency). GSI reads are always eventually consistent; you cannot request strongly consistent reads from a GSI.
# GSI definitions for the AppTable
GSI1:
PK: GSI1PK (e.g., "EMAIL#user@example.com" for user lookup by email)
SK: GSI1SK (e.g., "USER#u-001")
Projection: ALL
GSI2:
PK: GSI2PK (e.g., "STATUS#open" for fetching all open orders)
SK: GSI2SK (e.g., "2026-06-10T14:32:00Z" — order date for sorting)
Projection: KEYS_ONLY + status, userId, total
GSI3:
PK: GSI3PK (e.g., "CATEGORY#electronics" for products by category)
SK: GSI3SK (e.g., "PRICE#00149.99#PRODUCT#p-555" for price-range queries)
Projection: INCLUDE — name, price, imageUrl
LSI vs GSI
Local Secondary Indexes (LSIs) share the same partition key as the base table but use a different sort key. LSIs must be defined at table creation time and cannot be added later — this is a critical constraint. LSIs share the base table's provisioned capacity and are strongly consistent. Use LSIs sparingly: they are most useful when you need a consistent read on an alternate sort dimension within the same partition (e.g., sort orders by total price instead of date, within a user's partition). For most new tables, GSIs are the more flexible choice.
GSI Overloading
Just like the base table, your GSI PK and SK attributes can be overloaded. A single GSI1 can serve multiple entity lookups: GSI1PK = "EMAIL#..." for user-by-email, and GSI1PK = "PRODUCT#p-555" for order-items-by-product. Not every item in your table needs to populate the GSI attributes — items without GSI1PK simply do not appear in that GSI, which is the basis for sparse indexes.
5. Entity Types and Item Collections with PK/SK Prefixes
An item collection in DynamoDB is the set of all items that share the same partition key value. In single-table design, item collections are the fundamental unit of co-located data. A User item collection — all items with PK = "USER#u-001" — contains the user's profile, all their orders, and any other user-scoped entities. This is what enables single-query retrieval of an entire entity graph.
Entity Discriminator Attribute
Because every entity type shares the same table, add a string attribute (commonly named EntityType or type) to every item so your application code can distinguish between a User item and an Order item returned in the same Query result. This is especially important when you Query a partition and get back a heterogeneous list of items.
# Item shapes for different entity types in AppTable
user_item = {
"PK": "USER#u-001",
"SK": "PROFILE",
"EntityType": "User",
"userId": "u-001",
"email": "alice@example.com",
"name": "Alice Johnson",
"createdAt": "2026-01-15T08:00:00Z",
"GSI1PK": "EMAIL#alice@example.com",
"GSI1SK": "USER#u-001"
}
order_item = {
"PK": "USER#u-001",
"SK": "ORDER#2026-06-10T14:32:00Z#o-789",
"EntityType": "Order",
"orderId": "o-789",
"userId": "u-001",
"status": "shipped",
"total": 149.99,
"createdAt": "2026-06-10T14:32:00Z",
"GSI2PK": "STATUS#shipped",
"GSI2SK": "2026-06-10T14:32:00Z"
}
order_item_line = {
"PK": "ORDER#o-789",
"SK": "ITEM#p-555",
"EntityType": "OrderItem",
"orderId": "o-789",
"productId": "p-555",
"quantity": 2,
"unitPrice": 74.99
}
product_item = {
"PK": "PRODUCT#p-555",
"SK": "METADATA",
"EntityType": "Product",
"productId": "p-555",
"name": "Mechanical Keyboard",
"category": "electronics",
"price": 74.99,
"stock": 230,
"GSI3PK": "CATEGORY#electronics",
"GSI3SK": "PRICE#074.99#PRODUCT#p-555"
}
Notice how the Order item populates GSI2PK/GSI2SK for the admin "all open orders" access pattern, while the User item populates GSI1PK/GSI1SK for email-based login lookup. The Product item populates GSI3PK/GSI3SK for category browsing with price sorting. Each entity only populates the GSI attributes relevant to its own access patterns — nothing else.
6. Python boto3: Creating a Single-Table for Users, Orders, Products
Let us build the AppTable from scratch using Python boto3 and populate it with users, orders, and products following the single-table design described above.
Table Creation
import boto3
from datetime import datetime, timezone
import uuid
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
def create_app_table():
table = dynamodb.create_table(
TableName="AppTable",
KeySchema=[
{"AttributeName": "PK", "KeyType": "HASH"},
{"AttributeName": "SK", "KeyType": "RANGE"},
],
AttributeDefinitions=[
{"AttributeName": "PK", "AttributeType": "S"},
{"AttributeName": "SK", "AttributeType": "S"},
{"AttributeName": "GSI1PK", "AttributeType": "S"},
{"AttributeName": "GSI1SK", "AttributeType": "S"},
{"AttributeName": "GSI2PK", "AttributeType": "S"},
{"AttributeName": "GSI2SK", "AttributeType": "S"},
{"AttributeName": "GSI3PK", "AttributeType": "S"},
{"AttributeName": "GSI3SK", "AttributeType": "S"},
],
GlobalSecondaryIndexes=[
{
"IndexName": "GSI1",
"KeySchema": [
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
{"AttributeName": "GSI1SK", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "ALL"},
},
{
"IndexName": "GSI2",
"KeySchema": [
{"AttributeName": "GSI2PK", "KeyType": "HASH"},
{"AttributeName": "GSI2SK", "KeyType": "RANGE"},
],
"Projection": {
"ProjectionType": "INCLUDE",
"NonKeyAttributes": ["status", "userId", "total", "createdAt"],
},
},
{
"IndexName": "GSI3",
"KeySchema": [
{"AttributeName": "GSI3PK", "KeyType": "HASH"},
{"AttributeName": "GSI3SK", "KeyType": "RANGE"},
],
"Projection": {
"ProjectionType": "INCLUDE",
"NonKeyAttributes": ["name", "price", "imageUrl"],
},
},
],
BillingMode="PAY_PER_REQUEST",
)
table.wait_until_exists()
print(f"Table created: {table.table_name}")
return table
Writing Items with a Repository Class
from boto3.dynamodb.conditions import Key, Attr
from decimal import Decimal
table = dynamodb.Table("AppTable")
class UserRepository:
def create_user(self, user_id: str, email: str, name: str) -> dict:
now = datetime.now(timezone.utc).isoformat()
item = {
"PK": f"USER#{user_id}",
"SK": "PROFILE",
"EntityType": "User",
"userId": user_id,
"email": email,
"name": name,
"createdAt": now,
"GSI1PK": f"EMAIL#{email}",
"GSI1SK": f"USER#{user_id}",
}
table.put_item(Item=item)
return item
def get_user_by_email(self, email: str) -> dict | None:
resp = table.query(
IndexName="GSI1",
KeyConditionExpression=Key("GSI1PK").eq(f"EMAIL#{email}"),
Limit=1,
)
items = resp.get("Items", [])
return items[0] if items else None
class OrderRepository:
def create_order(self, user_id: str, order_id: str,
total: float, status: str = "pending") -> dict:
now = datetime.now(timezone.utc).isoformat()
item = {
"PK": f"USER#{user_id}",
"SK": f"ORDER#{now}#{order_id}",
"EntityType": "Order",
"orderId": order_id,
"userId": user_id,
"status": status,
"total": Decimal(str(total)),
"createdAt": now,
"GSI2PK": f"STATUS#{status}",
"GSI2SK": now,
}
table.put_item(Item=item)
return item
def add_order_item(self, order_id: str, product_id: str,
quantity: int, unit_price: float) -> dict:
item = {
"PK": f"ORDER#{order_id}",
"SK": f"ITEM#{product_id}",
"EntityType": "OrderItem",
"orderId": order_id,
"productId": product_id,
"quantity": quantity,
"unitPrice": Decimal(str(unit_price)),
}
table.put_item(Item=item)
return item
7. Query Patterns: Fetching Entities Efficiently
The payoff for careful key design is expressive, efficient query patterns. Here are the most common access patterns implemented in Python boto3, showing how single-table design reduces multiple round trips to one.
Get User Profile + All Orders in One Query
def get_user_with_orders(user_id: str) -> dict:
"""
Returns the user profile and all their orders in a single Query call.
Items are sorted by SK; PROFILE sorts before ORDER# lexicographically.
"""
resp = table.query(
KeyConditionExpression=Key("PK").eq(f"USER#{user_id}"),
ScanIndexForward=False, # newest orders first
)
items = resp.get("Items", [])
result = {"user": None, "orders": []}
for item in items:
if item["EntityType"] == "User":
result["user"] = item
elif item["EntityType"] == "Order":
result["orders"].append(item)
return result
def get_recent_orders(user_id: str, limit: int = 5) -> list:
"""Fetch the N most recent orders for a user."""
resp = table.query(
KeyConditionExpression=(
Key("PK").eq(f"USER#{user_id}") &
Key("SK").begins_with("ORDER#")
),
ScanIndexForward=False,
Limit=limit,
)
return resp.get("Items", [])
def get_orders_by_status(status: str, since: str = None) -> list:
"""Admin pattern: fetch all orders with a given status via GSI2."""
key_cond = Key("GSI2PK").eq(f"STATUS#{status}")
if since:
key_cond &= Key("GSI2SK").gte(since)
resp = table.query(
IndexName="GSI2",
KeyConditionExpression=key_cond,
ScanIndexForward=False,
)
return resp.get("Items", [])
def get_products_by_category(category: str,
min_price: float = None,
max_price: float = None) -> list:
"""Browse products in a category, optionally filtered by price range."""
key_cond = Key("GSI3PK").eq(f"CATEGORY#{category}")
if min_price is not None and max_price is not None:
lo = f"PRICE#{min_price:09.2f}"
hi = f"PRICE#{max_price:09.2f}\xff"
key_cond &= Key("GSI3SK").between(lo, hi)
resp = table.query(
IndexName="GSI3",
KeyConditionExpression=key_cond,
)
return resp.get("Items", [])
The get_user_with_orders function issues exactly one DynamoDB Query and splits the heterogeneous result set by EntityType in Python. This is the core value proposition of single-table design: what used to be a user fetch plus an orders fetch (two round trips, two network calls, two billing events) becomes one. At 1 million requests per day, that is 500,000 fewer DynamoDB calls — and at PAY_PER_REQUEST pricing, roughly half the read cost.
JavaScript (AWS SDK v3) Example
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
const client = new DynamoDBClient({ region: "us-east-1" });
async function getUserWithOrders(userId) {
const cmd = new QueryCommand({
TableName: "AppTable",
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: marshall({ ":pk": `USER#${userId}` }),
ScanIndexForward: false,
});
const response = await client.send(cmd);
const items = (response.Items || []).map(unmarshall);
return {
user: items.find(i => i.EntityType === "User") || null,
orders: items.filter(i => i.EntityType === "Order"),
};
}
async function getProductsByCategory(category, minPrice, maxPrice) {
const lo = `PRICE#${String(minPrice).padStart(9, "0")}`;
const hi = `PRICE#${String(maxPrice).padStart(9, "0")}\xFF`;
const cmd = new QueryCommand({
TableName: "AppTable",
IndexName: "GSI3",
KeyConditionExpression: "GSI3PK = :cat AND GSI3SK BETWEEN :lo AND :hi",
ExpressionAttributeValues: marshall({
":cat": `CATEGORY#${category}`,
":lo": lo,
":hi": hi,
}),
});
const response = await client.send(cmd);
return (response.Items || []).map(unmarshall);
}
8. Sparse Indexes for Efficient Filtering
A sparse index is a GSI (or LSI) where only a subset of items in the base table have values for the GSI key attributes. Items without those attributes are not projected into the GSI at all, creating a small, focused index that is cheap to read and write against.
Sparse indexes are ideal for "status-based" queries where most items are in a terminal state (e.g., "delivered") but you only ever need to query active ones (e.g., "pending" or "processing"). Rather than indexing all orders, only write GSI4PK and GSI4SK attributes when an order enters an active state, and remove them (with an UpdateItem) when the order reaches a terminal state.
def activate_order(order_pk: str, order_sk: str, user_id: str, created_at: str):
"""Add sparse index attributes when order becomes active."""
table.update_item(
Key={"PK": order_pk, "SK": order_sk},
UpdateExpression="SET GSI4PK = :pk, GSI4SK = :sk",
ExpressionAttributeValues={
":pk": "ACTIVE_ORDER",
":sk": created_at,
},
)
def complete_order(order_pk: str, order_sk: str):
"""Remove sparse index attributes when order is complete — removes from GSI."""
table.update_item(
Key={"PK": order_pk, "SK": order_sk},
UpdateExpression="REMOVE GSI4PK, GSI4SK",
)
def get_all_active_orders() -> list:
"""Efficiently query only active orders via the sparse index."""
resp = table.query(
IndexName="GSI4",
KeyConditionExpression=Key("GSI4PK").eq("ACTIVE_ORDER"),
ScanIndexForward=True,
)
return resp.get("Items", [])
In a system with 10 million total orders but only 50,000 active at any time, the sparse GSI4 holds 50,000 items instead of 10 million. Queries against it are 200x faster and consume far fewer read capacity units. This pattern is especially effective when combined with DynamoDB's TTL feature: set a TTL attribute on items that should expire, and they will automatically be removed from both the base table and all GSIs.
9. DynamoDB Streams for Event-Driven Patterns
DynamoDB Streams captures a time-ordered log of every change to a table — inserts, updates, and deletes — and makes that log available for up to 24 hours. Each stream record contains the old image, the new image, or both, depending on the stream view type you configure. When combined with AWS Lambda, streams enable powerful event-driven architectures directly from your DynamoDB table.
Enabling Streams
import boto3
dynamodb_client = boto3.client("dynamodb", region_name="us-east-1")
# Enable stream on existing table
dynamodb_client.update_table(
TableName="AppTable",
StreamSpecification={
"StreamEnabled": True,
"StreamViewType": "NEW_AND_OLD_IMAGES", # capture both for change detection
},
)
# Get the stream ARN (needed for Lambda trigger)
resp = dynamodb_client.describe_table(TableName="AppTable")
stream_arn = resp["Table"]["LatestStreamArn"]
print(f"Stream ARN: {stream_arn}")
Lambda Handler for Stream Events
import json
def lambda_handler(event, context):
for record in event["Records"]:
event_name = record["eventName"] # INSERT, MODIFY, REMOVE
new_image = record.get("dynamodb", {}).get("NewImage", {})
old_image = record.get("dynamodb", {}).get("OldImage", {})
entity_type = new_image.get("EntityType", {}).get("S", "")
if entity_type == "Order" and event_name == "INSERT":
order_id = new_image["orderId"]["S"]
total = float(new_image["total"]["N"])
user_id = new_image["userId"]["S"]
print(f"New order {order_id} for user {user_id}: ${total:.2f}")
# Trigger: send confirmation email, update inventory, push to analytics
elif entity_type == "Order" and event_name == "MODIFY":
old_status = old_image.get("status", {}).get("S")
new_status = new_image.get("status", {}).get("S")
if old_status != new_status:
order_id = new_image["orderId"]["S"]
print(f"Order {order_id} status changed: {old_status} -> {new_status}")
# Trigger: notify customer, update shipping system
return {"statusCode": 200}
DynamoDB Streams integrate naturally with Amazon EventBridge via EventBridge Pipes, allowing you to route DynamoDB change events to any downstream target — SQS queues, Step Functions, external APIs — with optional filtering and transformation. This decouples your data store from your business logic without adding polling or cron jobs.
10. Common Pitfalls: Hot Partitions, Large Items, GSI Costs
Single-table design eliminates many relational anti-patterns but introduces its own failure modes. Understanding these pitfalls before they hit production saves significant debugging time and money.
Hot Partitions
DynamoDB distributes data across partitions by hashing the partition key. If many requests target the same PK value — for example, a viral product page where PK = "PRODUCT#p-001" receives 10,000 reads per second — that partition becomes a bottleneck. Symptoms include ProvisionedThroughputExceededException or elevated P99 latency on PAY_PER_REQUEST.
Solutions: (1) Add a shard suffix to high-traffic PKs and scatter reads/writes across shards, merging in application code. (2) Use ElastiCache as a read-through cache for popular items. (3) Enable DynamoDB Accelerator (DAX) for microsecond read latency on hot items without application code changes.
Item Size Limit (400 KB)
DynamoDB enforces a hard 400 KB limit per item. Storing large blobs, nested lists of unbounded size, or long history logs in a single item is an anti-pattern. If your item might grow unboundedly (e.g., storing all order events in one item's list attribute), split it into child items. Instead of a events list in the Order item, write separate items with PK = "ORDER#o-789" and SK = "EVENT#<timestamp>".
GSI Projection Costs
Using ProjectionType: ALL on every GSI is tempting but expensive. A table with 10 GSIs all projecting ALL attributes effectively stores each item 11 times. Use KEYS_ONLY or INCLUDE projections, and include only the attributes actually read in that GSI's query patterns. If you realize post-creation that your GSI projection is wrong, you must delete and recreate the GSI — there is no in-place projection change.
Over-Indexing with Scans
A common mistake is reaching for a Scan operation when an access pattern does not fit existing indexes. Scans read every item in the table and are both slow and expensive at scale. If you need a new access pattern, add a GSI. If the pattern is truly ad-hoc and infrequent (e.g., a monthly compliance report), consider exporting the table to S3 and querying it with Athena instead of scanning DynamoDB directly.
Missing EntityType Discriminator
Forgetting to add an EntityType attribute causes chaos when your application receives a heterogeneous Query result and cannot distinguish a User item from an Order item. Enforce EntityType as a required field in your repository layer, and validate it on write.
11. Tools: NoSQL Workbench for Modeling
Designing a single-table schema mentally — or on paper — is error-prone. AWS provides NoSQL Workbench for Amazon DynamoDB, a free desktop GUI (available for Windows, macOS, Linux) that visualizes item collections, simulates queries, and generates code stubs in Python, JavaScript, and Java.
Key NoSQL Workbench Features
- Data modeler: Define facets (entity types), attributes, and key schemas visually. Group items by their access patterns to validate coverage before writing code.
- Visualizer: See how items actually lay out in a partition. Spot collisions between different entity types sharing the same PK+SK combination at design time.
- Operation builder: Point-and-click Query and GetItem builder that generates boto3 and AWS SDK v3 code you can paste directly into your application.
- Aggregate view: Visualize item collections across all facets to understand how co-located your related entities actually are.
To use NoSQL Workbench with a live table, connect it to your AWS account via an IAM role or access key. You can import existing table data, run queries against production (with read-only IAM permissions for safety), and export your model as a CloudFormation template or CDK construct for use with AWS CDK.
IaC: Defining AppTable with CDK
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as cdk from "aws-cdk-lib";
export class AppTableStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const table = new dynamodb.Table(this, "AppTable", {
tableName: "AppTable",
partitionKey: { name: "PK", type: dynamodb.AttributeType.STRING },
sortKey: { name: "SK", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
removalPolicy: cdk.RemovalPolicy.RETAIN,
pointInTimeRecovery: true,
});
table.addGlobalSecondaryIndex({
indexName: "GSI1",
partitionKey: { name: "GSI1PK", type: dynamodb.AttributeType.STRING },
sortKey: { name: "GSI1SK", type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.ALL,
});
table.addGlobalSecondaryIndex({
indexName: "GSI2",
partitionKey: { name: "GSI2PK", type: dynamodb.AttributeType.STRING },
sortKey: { name: "GSI2SK", type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.INCLUDE,
nonKeyAttributes: ["status", "userId", "total", "createdAt"],
});
table.addGlobalSecondaryIndex({
indexName: "GSI3",
partitionKey: { name: "GSI3PK", type: dynamodb.AttributeType.STRING },
sortKey: { name: "GSI3SK", type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.INCLUDE,
nonKeyAttributes: ["name", "price", "imageUrl"],
});
}
}
Defining your DynamoDB table as CDK infrastructure ensures it is reproducible across environments (dev, staging, prod) and auditable in version control. Pair it with CloudWatch alarms on ConsumedReadCapacityUnits, ThrottledRequests, and SuccessfulRequestLatency to detect hot partitions and capacity issues early.
Read Next
AWS DynamoDB Complete Guide
Deep dive into DynamoDB fundamentals: capacity modes, data types, transactions, and backup strategies.
AWS Lambda Serverless Guide
Build serverless functions that integrate with DynamoDB Streams, API Gateway, and event-driven architectures.