← All posts
·13 min read

Claude Code with DynamoDB: Single-Table Design Done Right

Claude CodeDynamoDBAWSNoSQL
Claude Code with DynamoDB: single-table design done right

Why DynamoDB without CLAUDE.md gets modeled like a SQL database

DynamoDB is not a relational database with a different syntax. It is a key-value and document store where the data model is dictated entirely by the access patterns you need to serve, decided before a single item is written. Get that backwards, design the schema first and figure out the queries later, and you end up with a table you can only read by scanning the whole thing, which is slow, expensive, and gets worse as the table grows. The trap is that DynamoDB will happily let you do it. A Scan works. A table with one entity type per table works. A query that filters in application code after reading everything works. They all work until the table has real data, at which point the cost and latency become the operator's permanent problem.

A Claude Code session that writes DynamoDB code without project rules brings a relational mindset, because that is the database model a model has seen the most. It creates one table per entity, the way you would create one table per entity in Postgres. It writes a Scan with a FilterExpression to find items by a non-key attribute, because that reads like a SELECT ... WHERE. It joins data across tables in application code. It picks provisioned capacity with arbitrary numbers because the API asks for them. Each of these is a relational habit applied to a database that punishes them, and together they describe a DynamoDB deployment that costs ten times more than it should and slows down as it grows.

This guide covers the CLAUDE.md configuration that locks Claude Code into DynamoDB patterns that scale: access-pattern-first design, single-table key conventions, Query over Scan as a hard rule, deliberate global secondary index design, the right capacity mode for the workload, and permission hooks that keep Claude from running a Scan against a production table during development. For the serverless functions that read and write the table, Claude Code with AWS covers the Lambda and IAM layer. For the relational alternative when the access patterns genuinely need joins, Claude Code with Postgres covers the SQL side.

The DynamoDB CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a DynamoDB-backed application it needs to declare the table design philosophy, the key naming conventions, the access patterns the table serves, the index design, the capacity mode, and the hard rules that block the relational habits Claude reaches for most often.

# DynamoDB rules

## Stack
- AWS SDK v3 (@aws-sdk/client-dynamodb + lib-dynamodb DocumentClient)
- Single table: AppData, partition key PK, sort key SK
- All access goes through src/db/repository.ts, NEVER raw client inline
- Table and index names from env vars, NEVER hardcoded

## Design philosophy (MANDATORY)
- Access patterns are listed in docs/access-patterns.md BEFORE schema changes
- Every new query must map to a documented access pattern
- Single-table design: all entities in one table, distinguished by key prefix
- NEVER create a new table per entity type without a documented reason

## Key conventions (MANDATORY)
- PK and SK are strings with a type prefix: USER#<id>, ORDER#<id>
- Item type stored in a `type` attribute for filtering and projections
- GSI keys follow the same prefix convention: GSI1PK, GSI1SK
- Composite sort keys encode hierarchy: ORDER#<id>#ITEM#<id>

## Queries (MANDATORY)
- Read with Query against PK (and SK condition), NEVER Scan in app code
- Scan allowed ONLY for admin/migration scripts, never in request path
- Use begins_with on SK for hierarchical reads
- Pagination via LastEvaluatedKey, NEVER load an unbounded result set
- Projection expressions to fetch only needed attributes

## Indexes (MANDATORY)
- Every GSI exists to serve a documented access pattern
- GSI projection type chosen deliberately (KEYS_ONLY, INCLUDE, ALL)
- Sparse indexes used where only a subset of items need the access pattern
- NEVER add a GSI without recording which access pattern it serves

## Writes (MANDATORY)
- Conditional writes (ConditionExpression) to prevent overwrites and races
- TransactWriteItems for multi-item atomicity, NEVER a sequence of PutItem
- BatchWriteItem for bulk inserts, with unprocessed-item retry
- Optimistic locking via a version attribute on mutable items

## Capacity
- On-demand for unpredictable or spiky traffic
- Provisioned with auto-scaling for steady, predictable load
- NEVER hardcode provisioned RCU/WCU without a load justification

## Hard rules
- NEVER use Scan in the request path
- NEVER create one table per entity without documenting why
- NEVER write without a ConditionExpression where a race is possible
- NEVER load a query result set without pagination
- NEVER add a GSI without a documented access pattern
- ALWAYS list the access pattern before changing the schema
- ALWAYS use TransactWriteItems for multi-item atomicity
- ALWAYS use the DocumentClient, not the low-level marshalled API

Six rules here prevent the majority of incidents Claude generates without them.

The access-pattern-first rule is the one that prevents the entire class of relational-mindset mistakes. DynamoDB design starts with a list of the exact queries the application needs to serve, written down before the schema. Without this discipline, Claude designs a schema that looks reasonable and then discovers it cannot answer a query without a Scan. With the access patterns documented first, every key and index exists to serve a known query, and a new query that does not map to one is a signal that the design needs to change, not that a Scan should be added.

The Query-over-Scan rule is the hard line that keeps reads fast. A Scan reads every item in the table and filters in the engine, which means its cost grows with the table size regardless of how many items match. A Query reads only the items under a partition key, so its cost is proportional to the result, not the table. The rule bans Scan from the request path entirely, allowing it only in admin and migration scripts where a full table read is the actual intent.

The single-table rule is the one that goes most against the relational grain. Putting users, orders, and order items in one table feels wrong to anyone trained on SQL, but it is what lets DynamoDB serve related data in a single query. The key prefix convention (USER#, ORDER#) keeps the entity types distinguishable, and the composite sort key encodes the hierarchy so a single Query can fetch an order and all its items together.

Access patterns first

Before the schema, write down the queries. This document is the contract the table design has to satisfy.

# docs/access-patterns.md

## Access patterns

1. Get a user by ID
2. Get all orders for a user, most recent first
3. Get an order and all its line items in one read
4. Get all orders across all users in a date range (admin)
5. Get a user by email (login)

## Key design

| Pattern | Index | PK              | SK / condition           |
|---------|-------|-----------------|--------------------------|
| 1       | main  | USER#<id>       | PROFILE                  |
| 2       | main  | USER#<id>       | begins_with ORDER#       |
| 3       | main  | ORDER#<id>      | begins_with ORDER#<id>   |
| 4       | GSI1  | ORDER           | GSI1SK between dates     |
| 5       | GSI2  | EMAIL#<email>   | (single item)            |

This table is the design. Each row is a query the application makes, mapped to the index and key condition that serves it. Pattern 2 reads a user's orders by querying the user's partition with a begins_with ORDER# on the sort key. Pattern 4 needs orders across all users, which the main table cannot serve efficiently, so it gets a GSI with a constant partition key and a date-based sort key. Pattern 5 needs lookup by email, which gets its own GSI. Writing this down first is what makes the schema fall out of the requirements rather than the other way around. The CLAUDE.md rule that requires this document before schema changes is what keeps Claude from skipping straight to a table definition.

The repository layer

All table access goes through one module, so the key conventions and query patterns live in one place.

// src/db/repository.ts
import {
  DynamoDBClient,
} from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  QueryCommand,
  PutCommand,
} from "@aws-sdk/lib-dynamodb";

const TABLE = process.env.DDB_TABLE!;
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export interface Order {
  id: string;
  userId: string;
  total: number;
  createdAt: string;
}

export async function getOrdersForUser(
  userId: string,
  limit = 25,
  cursor?: Record<string, unknown>,
): Promise<{ orders: Order[]; nextCursor?: Record<string, unknown> }> {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
      ExpressionAttributeValues: {
        ":pk": `USER#${userId}`,
        ":prefix": "ORDER#",
      },
      ScanIndexForward: false,
      Limit: limit,
      ExclusiveStartKey: cursor,
    }),
  );

  return {
    orders: (result.Items ?? []) as Order[],
    nextCursor: result.LastEvaluatedKey,
  };
}

The repository uses the DocumentClient, which marshals native JavaScript types to and from DynamoDB's attribute format, so the calling code works with plain objects instead of the low-level { S: "..." } shapes. The getOrdersForUser function is a textbook single-table query: it reads one partition (USER#<id>) and uses begins_with on the sort key to fetch only the order items, with ScanIndexForward: false to return them newest-first. The Limit and ExclusiveStartKey implement pagination so the function never loads an unbounded set. This one function embodies three CLAUDE.md rules at once: Query not Scan, single-table key conventions, and pagination.

Global secondary indexes

A GSI serves an access pattern the main table cannot. The CLAUDE.md rule is that every GSI maps to a documented pattern and chooses its projection deliberately.

// query a GSI for orders in a date range (admin pattern 4)
export async function getOrdersInRange(
  start: string,
  end: string,
): Promise<Order[]> {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      IndexName: "GSI1",
      KeyConditionExpression: "GSI1PK = :pk AND GSI1SK BETWEEN :start AND :end",
      ExpressionAttributeValues: {
        ":pk": "ORDER",
        ":start": start,
        ":end": end,
      },
    }),
  );
  return (result.Items ?? []) as Order[];
}

This query serves access pattern 4 from the design document: all orders in a date range. The main table cannot do it because orders are partitioned by user, so there is no single partition holding all orders. GSI1 solves it by writing every order with a constant GSI1PK of ORDER and a GSI1SK of the creation timestamp, which lets a BETWEEN condition select a date range. The projection type for this index matters: if the admin view needs only order summaries, an INCLUDE projection with just those attributes keeps the index small and cheap to maintain. A sparse index, where only items with the GSI1PK attribute appear, is the pattern when only a subset of items need the access pattern.

Conditional and transactional writes

Writes need protection against races and the ability to span multiple items atomically. The CLAUDE.md rules require conditional writes where a race is possible and transactions for multi-item atomicity.

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

// conditional write: fail if the item already exists
export async function createUser(user: {
  id: string;
  email: string;
}): Promise<void> {
  await client.send(
    new PutCommand({
      TableName: TABLE,
      Item: {
        PK: `USER#${user.id}`,
        SK: "PROFILE",
        GSI2PK: `EMAIL#${user.email}`,
        type: "User",
        ...user,
      },
      ConditionExpression: "attribute_not_exists(PK)",
    }),
  );
}

// transaction: write the order and decrement inventory atomically
export async function placeOrder(
  order: Order,
  productId: string,
): Promise<void> {
  await client.send(
    new TransactWriteCommand({
      TransactItems: [
        {
          Put: {
            TableName: TABLE,
            Item: {
              PK: `USER#${order.userId}`,
              SK: `ORDER#${order.id}`,
              type: "Order",
              ...order,
            },
          },
        },
        {
          Update: {
            TableName: TABLE,
            Key: { PK: `PRODUCT#${productId}`, SK: "STOCK" },
            UpdateExpression: "SET quantity = quantity - :one",
            ConditionExpression: "quantity >= :one",
            ExpressionAttributeValues: { ":one": 1 },
          },
        },
      ],
    }),
  );
}

The createUser write uses attribute_not_exists(PK) so two concurrent requests creating the same user do not silently overwrite each other; the second fails the condition. The placeOrder transaction writes the order and decrements inventory in one atomic operation, with a ConditionExpression on the inventory update that fails the whole transaction if stock would go negative. Without the transaction, an order could be written and the inventory decrement could fail separately, leaving the two out of sync. This is the multi-item atomicity the CLAUDE.md rule mandates, and it is the pattern that keeps related writes consistent in a database with no foreign keys. For the broader consistency story when the access patterns outgrow what single-table design handles cleanly, Claude Code with Postgres covers the relational alternative.

Capacity mode

DynamoDB charges by throughput, and the capacity mode determines how. On-demand scales automatically and charges per request. Provisioned reserves capacity and charges for it whether used or not.

// table definition (CDK / CloudFormation), capacity choice documented inline
{
  billingMode: "PAY_PER_REQUEST", // on-demand: spiky, unpredictable traffic
}

// for steady, predictable load, provisioned with auto-scaling is cheaper:
{
  billingMode: "PROVISIONED",
  readCapacity: 20,
  writeCapacity: 10,
  // auto-scaling target 70% utilization, min 5, max 100
}

On-demand is the right default for a new application or a spiky workload, because it scales to zero when idle and absorbs traffic spikes without a capacity plan. Provisioned with auto-scaling is cheaper for steady, predictable load where you can commit to a baseline. The CLAUDE.md rule forbids hardcoding provisioned RCU and WCU without a load justification, because arbitrary numbers either throttle the table or waste money. The decision is a cost question, not a code question, and it belongs in the infrastructure definition with a comment explaining the choice.

Permission hooks for DynamoDB operations

A Scan against a large production table is slow and expensive, and a careless write can corrupt data. Permission hooks in .claude/settings.local.json keep the dangerous operations behind a gate.

{
  "permissions": {
    "allow": [
      "Bash(aws dynamodb describe-table*)",
      "Bash(aws dynamodb query*)",
      "Bash(npm test*)",
      "Bash(npx tsc --noEmit*)"
    ],
    "deny": [
      "Bash(aws dynamodb scan*)",
      "Bash(aws dynamodb delete-table*)",
      "Bash(aws dynamodb batch-write-item*)",
      "Bash(aws dynamodb update-table*)"
    ]
  }
}

The allow list permits describe-table, query, and the test commands Claude needs for development. The deny list catches the operations that are expensive or destructive: scan reads the whole table, delete-table removes it, batch-write-item can overwrite data in bulk, and update-table can change capacity or indexes in ways that take hours to reverse. These should run deliberately, with a human aware of the cost and the consequences. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can develop against the table without the ability to run a full-table Scan or drop a table during a session.

Testing against DynamoDB Local

Tests should run against DynamoDB Local, not the real service, so they are fast, free, and isolated. The pattern points the client at the local endpoint and seeds the table per test.

// tests/repository.test.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { getOrdersForUser } from "../src/db/repository";

const local = DynamoDBDocumentClient.from(
  new DynamoDBClient({
    endpoint: "http://localhost:8000",
    region: "local",
    credentials: { accessKeyId: "local", secretAccessKey: "local" },
  }),
);

test("getOrdersForUser returns orders newest first", async () => {
  await local.send(
    new PutCommand({
      TableName: process.env.DDB_TABLE,
      Item: { PK: "USER#1", SK: "ORDER#a", type: "Order", createdAt: "2026-01-01" },
    }),
  );
  await local.send(
    new PutCommand({
      TableName: process.env.DDB_TABLE,
      Item: { PK: "USER#1", SK: "ORDER#b", type: "Order", createdAt: "2026-02-01" },
    }),
  );

  const { orders } = await getOrdersForUser("1");
  expect(orders[0].id).toBe("b");
});

DynamoDB Local runs the real DynamoDB engine in a container on localhost:8000, so tests exercise the actual query semantics, including key conditions and sort order, without touching AWS or incurring cost. The test seeds two orders and asserts the newest comes back first, validating both the begins_with query and the ScanIndexForward: false ordering. Running these in CI catches a schema or query change that breaks an access pattern before it reaches production. For the test runner and CI setup, Claude Code with Vitest covers the configuration.

Building DynamoDB that scales instead of slowing down

The DynamoDB CLAUDE.md in this guide produces an application where the access patterns are documented before the schema, every read is a Query against a known partition, related entities share a single table with consistent key prefixes, every GSI serves a documented pattern, writes are conditional and transactional, and the capacity mode fits the workload. The result is a table whose cost and latency stay flat as the data grows, instead of degrading the way a relational-style design would.

The underlying principle is the same as any data layer built with Claude Code. DynamoDB without a CLAUDE.md produces code modeled like a SQL database, with one table per entity and a Scan standing in for a WHERE clause, because that is the database model a model knows best. The CLAUDE.md is what makes the access-pattern-driven, single-table patterns the default for Claude.

For the serverless functions and IAM policies that read and write the table, Claude Code with AWS covers the Lambda layer. For the relational database when the access patterns genuinely need joins and ad-hoc queries, Claude Code with Postgres covers the SQL alternative. For the test runner that validates queries against DynamoDB Local, Claude Code with Vitest covers the setup.

Get Claudify. Build DynamoDB tables that serve every query without a single Scan in the request path.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame