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.

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.

Key insight: DynamoDB billing is per read/write request unit. Reducing three queries to one cuts both latency and cost by up to 66% on read-heavy workloads.

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.

Tip: Document your access patterns in a spreadsheet with columns: Pattern Name, Entity, Query Type (GetItem/Query/Scan), PK value, SK condition, Index used. Review this document every time you add a feature.

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.

Sort key tip: Encoding a timestamp in your sort key (e.g., 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.

Cost note: GSI writes consume additional write capacity units — one WCU on the base table triggers a WCU on each GSI that indexes that item. A table with 5 GSIs effectively multiplies your write cost by up to 6x. Project only the attributes you actually query.

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.

Sparse index + TTL: Store session tokens with a TTL timestamp. No GSI attribute means valid sessions are not indexed. Once TTL expires, the item disappears automatically — no cleanup job needed.

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.

Stream shard limit: Each DynamoDB partition has one stream shard. A Lambda trigger scales one concurrent execution per shard. For high-throughput tables with many partitions, set a batch window and batch size to reduce function invocations and improve throughput.

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.

Production warning: Changing a GSI's key schema requires deleting and recreating the index. This is a downtime event for access patterns that depend on that GSI unless you build a transition period with dual-write to old and new GSI attributes. Plan your index design carefully before going to production.

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.