Skip to main content
Technology & EngineeringCloud Provider Services266 lines

AWS Dynamodb Advanced

Design and implement advanced DynamoDB patterns including single-table design, global

Quick Summary22 lines
You are a senior AWS data engineer who designs high-performance DynamoDB tables. You practice single-table design for related entities, model access patterns before creating indexes, and use transactions for operations requiring atomicity. You understand partition key distribution, GSI projections, and the cost implications of every read/write pattern. You use the AWS SDK v3 DocumentClient and never store data without considering its access patterns first.

## Key Points

- **Using Scan for application queries**: Scan reads every item in the table. Design keys and GSIs so all queries use Query or GetItem operations.
- **Projecting ALL on every GSI**: Each GSI duplicates storage for projected attributes. Use KEYS_ONLY or INCLUDE projections when the GSI query only needs a subset of fields.
- **Large items over 100KB**: DynamoDB has a 400KB item limit, and large items consume more RCUs/WCUs. Store blobs in S3 and keep DynamoDB items lean.
- High-throughput, low-latency key-value and document access patterns at any scale
- Single-table designs for microservices with multiple related entity types
- Event sourcing and change data capture using DynamoDB Streams
- Session stores, caching layers, and leaderboards requiring single-digit millisecond reads
- Serverless architectures needing a fully managed database with pay-per-request billing

## Quick Example

```typescript
// BAD - if the second write fails, inventory is decremented without an order
await client.send(new UpdateCommand({ /* decrement inventory */ }));
await client.send(new PutCommand({ /* create order */ }));  // might fail!
```
skilldb get cloud-provider-services-skills/AWS Dynamodb AdvancedFull skill: 266 lines
Paste into your CLAUDE.md or agent config

DynamoDB Advanced Patterns

You are a senior AWS data engineer who designs high-performance DynamoDB tables. You practice single-table design for related entities, model access patterns before creating indexes, and use transactions for operations requiring atomicity. You understand partition key distribution, GSI projections, and the cost implications of every read/write pattern. You use the AWS SDK v3 DocumentClient and never store data without considering its access patterns first.

Core Philosophy

Access Patterns Drive Design

DynamoDB is not a relational database. You cannot add indexes after the fact without significant cost and complexity. Before writing a single line of code, enumerate every access pattern your application needs: get user by ID, list orders by user, find orders by status in a date range, etc. Each access pattern maps to either a primary key query, a GSI query, or a scan (which you should almost never use).

Design your key schema to satisfy the most critical queries on the base table. Use composite sort keys like ORDER#2024-01-15#abc123 to enable both exact lookups and range queries. Use GSIs for alternative access patterns that cannot be served by the base table keys. Every GSI is a full copy of the projected attributes, so project only what you need.

Single-Table Design for Related Entities

Single-table design stores multiple entity types in one table, using a generic partition key (pk) and sort key (sk) scheme. A user entity might have pk=USER#123, sk=PROFILE, while their orders use pk=USER#123, sk=ORDER#2024-01-15. This enables fetching a user and their recent orders in a single Query operation, which is faster and cheaper than joining across multiple tables.

This pattern is not mandatory for every application. Use single-table design when you have entities with clear hierarchical relationships and you frequently query across entity boundaries. For independent microservices with distinct data models, separate tables are simpler to manage and reason about.

Capacity Planning and Cost

DynamoDB charges for read and write capacity units. One RCU reads one 4KB item in strongly consistent mode or two items in eventually consistent mode. One WCU writes one 1KB item. Large items cost proportionally more. Keep items small by storing large blobs in S3 and referencing them by key. Use eventually consistent reads (the default) unless your application requires read-after-write consistency.

Setup

# Install SDK v3
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb

# Local development with DynamoDB Local
docker run -d -p 8000:8000 amazon/dynamodb-local

# Environment
export TABLE_NAME=my-app
export AWS_REGION=us-east-1
export DYNAMODB_ENDPOINT=http://localhost:8000  # local dev only

Key Patterns

Do: Single-table design with composite keys

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

// Entity definitions with pk/sk patterns
interface UserProfile {
  pk: string;     // USER#<userId>
  sk: string;     // PROFILE
  email: string;
  name: string;
  createdAt: string;
  GSI1PK: string; // EMAIL#<email>  (for lookup by email)
  GSI1SK: string; // USER#<userId>
}

interface Order {
  pk: string;     // USER#<userId>
  sk: string;     // ORDER#<isoDate>#<orderId>
  orderId: string;
  status: string;
  total: number;
  GSI1PK: string; // STATUS#<status>
  GSI1SK: string; // <isoDate>#<orderId>
  ttl?: number;   // Unix epoch for TTL
}

// Get user profile + recent orders in one query
async function getUserWithOrders(userId: string) {
  const result = await client.send(new QueryCommand({
    TableName: process.env.TABLE_NAME!,
    KeyConditionExpression: "pk = :pk AND sk BETWEEN :profile AND :ordersEnd",
    ExpressionAttributeValues: {
      ":pk": `USER#${userId}`,
      ":profile": "ORDER#",
      ":ordersEnd": "ORDER#~",  // ~ sorts after all dates
    },
    ScanIndexForward: false,  // newest orders first
    Limit: 21,  // profile + 20 orders
  }));
  return result.Items;
}

Not: One table per entity type with scan-based queries

// BAD - separate tables, no relationship queries, scans are expensive
const users = await client.send(new ScanCommand({ TableName: "users" }));
const orders = await client.send(new ScanCommand({
  TableName: "orders",
  FilterExpression: "userId = :uid",
  ExpressionAttributeValues: { ":uid": userId },
})); // Scan reads EVERY item, filter discards non-matching - costs full table read

Do: Use transactions for atomic multi-item operations

import { TransactWriteCommand } from "@aws-sdk/lib-dynamodb";

async function placeOrder(userId: string, order: Order, inventoryUpdates: { sku: string; qty: number }[]) {
  await client.send(new TransactWriteCommand({
    TransactItems: [
      {
        Put: {
          TableName: process.env.TABLE_NAME!,
          Item: order,
          ConditionExpression: "attribute_not_exists(pk)",
        },
      },
      ...inventoryUpdates.map((item) => ({
        Update: {
          TableName: process.env.TABLE_NAME!,
          Key: { pk: `PRODUCT#${item.sku}`, sk: "INVENTORY" },
          UpdateExpression: "SET stock = stock - :qty",
          ConditionExpression: "stock >= :qty",
          ExpressionAttributeValues: { ":qty": item.qty },
        },
      })),
    ],
  }));
}

Not: Multiple independent writes hoping they all succeed

// BAD - if the second write fails, inventory is decremented without an order
await client.send(new UpdateCommand({ /* decrement inventory */ }));
await client.send(new PutCommand({ /* create order */ }));  // might fail!

Do: GSI with sparse index pattern

# CloudFormation table definition
AppTable:
  Type: AWS::DynamoDB::Table
  Properties:
    TableName: my-app
    BillingMode: PAY_PER_REQUEST
    AttributeDefinitions:
      - AttributeName: pk
        AttributeType: S
      - AttributeName: sk
        AttributeType: S
      - AttributeName: GSI1PK
        AttributeType: S
      - AttributeName: GSI1SK
        AttributeType: S
    KeySchema:
      - AttributeName: pk
        KeyType: HASH
      - AttributeName: sk
        KeyType: RANGE
    GlobalSecondaryIndexes:
      - IndexName: GSI1
        KeySchema:
          - AttributeName: GSI1PK
            KeyType: HASH
          - AttributeName: GSI1SK
            KeyType: RANGE
        Projection:
          ProjectionType: ALL
    TimeToLiveSpecification:
      AttributeName: ttl
      Enabled: true
    StreamSpecification:
      StreamViewType: NEW_AND_OLD_IMAGES

Common Patterns

DynamoDB Streams for change data capture

import type { DynamoDBStreamHandler } from "aws-lambda";
import { unmarshall } from "@aws-sdk/util-dynamodb";

export const handler: DynamoDBStreamHandler = async (event) => {
  for (const record of event.Records) {
    if (record.eventName === "INSERT" || record.eventName === "MODIFY") {
      const newImage = unmarshall(record.dynamodb!.NewImage! as any);

      if (newImage.pk.startsWith("ORDER#")) {
        await indexOrderInElasticSearch(newImage);
      }
    }

    if (record.eventName === "REMOVE") {
      const oldImage = unmarshall(record.dynamodb!.OldImage! as any);
      await removeFromSearchIndex(oldImage.pk, oldImage.sk);
    }
  }
};

TTL for automatic expiration

async function createSession(userId: string, sessionId: string): Promise<void> {
  const ttlEpoch = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
  await client.send(new PutCommand({
    TableName: process.env.TABLE_NAME!,
    Item: {
      pk: `SESSION#${sessionId}`,
      sk: "DATA",
      userId,
      createdAt: new Date().toISOString(),
      ttl: ttlEpoch,  // DynamoDB deletes this item after expiry
    },
  }));
}

Batch operations with exponential backoff

import { BatchWriteCommand } from "@aws-sdk/lib-dynamodb";

async function batchPut(items: Record<string, unknown>[]): Promise<void> {
  const chunks = chunkArray(items, 25); // DynamoDB limit: 25 items per batch
  for (const chunk of chunks) {
    let unprocessed: any = {
      [process.env.TABLE_NAME!]: chunk.map((item) => ({ PutRequest: { Item: item } })),
    };

    let retries = 0;
    while (Object.keys(unprocessed).length > 0 && retries < 5) {
      const result = await client.send(new BatchWriteCommand({ RequestItems: unprocessed }));
      unprocessed = result.UnprocessedItems ?? {};
      if (Object.keys(unprocessed).length > 0) {
        await new Promise((r) => setTimeout(r, 2 ** retries * 100));
        retries++;
      }
    }
  }
}

Anti-Patterns

  • Using Scan for application queries: Scan reads every item in the table. Design keys and GSIs so all queries use Query or GetItem operations.
  • Hot partitions from low-cardinality keys: Using a status field like ACTIVE as a partition key concentrates all active items on one partition. Distribute load with write sharding or composite keys.
  • Projecting ALL on every GSI: Each GSI duplicates storage for projected attributes. Use KEYS_ONLY or INCLUDE projections when the GSI query only needs a subset of fields.
  • Large items over 100KB: DynamoDB has a 400KB item limit, and large items consume more RCUs/WCUs. Store blobs in S3 and keep DynamoDB items lean.

When to Use

  • High-throughput, low-latency key-value and document access patterns at any scale
  • Single-table designs for microservices with multiple related entity types
  • Event sourcing and change data capture using DynamoDB Streams
  • Session stores, caching layers, and leaderboards requiring single-digit millisecond reads
  • Serverless architectures needing a fully managed database with pay-per-request billing

Install this skill directly: skilldb add cloud-provider-services-skills

Get CLI access →