Design a DynamoDB table and read and write it correctly

Choose keys and capacity mode for a DynamoDB table from its access patterns, then write and read it from TypeScript (AWS SDK v3 document client) with conditional writes, paginated queries and optimistic updates — avoiding scans, hot keys, and the fixed limits that cause ValidationException.

markdownguidev4SHR9-MGGPstage: buildtrust: officialservice: dynamodbinterface: sdkDynamoDB table designpartition keysort keyGSILSIon-demand vs provisionedDynamoDBDocumentClientPutCommandQueryCommandUpdateCommandConditionExpressionattribute_not_existsLastEvaluatedKeyExclusiveStartKeyreserved word ExpressionAttributeNamessingle-table design

Created 18 Oct 2026 · Modified 23 Oct 2026 · 4 versions

Start from the queries, not the entities. Every frequent read should be a GetItem or a Query on a key: the table's own key or a secondary index. Never a Scan. Use on-demand capacity unless the load is steady and forecastable: AWS recommends on-demand for most workloads.

1. Keys from access patterns

Access patternKey design
Fetch one thing by IDPartition key = ID
All of X's items, sorted or ranged (a customer's orders by date)Partition key = X, sort key = sortable string (o#2026-10-18#001)
The same items by another attribute (orders by status)Global secondary index (GSI) on that attribute
Another sort order within the same partitionLocal secondary index (LSI), defined at table creation

Fixed constraints that shape design:

  • Items are at most 400 KB, including attribute names. Nesting goes 32 levels deep.
  • Partition keys are 1–2048 bytes; sort keys 1–1024 bytes.
  • With an LSI, each partition key's item collection is limited to 10 GB (ItemCollectionSizeLimitExceededException). Up to 5 LSIs per table.
  • GSIs: the number per table is an adjustable quota. Read the live value in Service Quotas rather than assuming one. There are 100 projected attributes in total across indexes.
  • Query and Scan return at most 1 MB per call. Paginate with LastEvaluatedKey.
  • Batches: BatchWriteItem takes 25 items / 16 MB, BatchGetItem 100 items / 16 MB. Transactions take 100 items / 4 MB.
  • Expressions are at most 4 KB, with 300 operators, and an IN list of up to 100 values.

Spread writes across partition key values. A single "hot" key throttles even when the table has capacity (UNVERIFIED mechanism detail; the error page attributes throttling to exceeding a table or GSI's throughput).

2. Capacity mode

  • On-demand (the default): pay per request, no planning. It can still return RequestLimitExceeded at the account's throughput limit.
  • Provisioned: you set reads and writes per second and pay for the provisioned hours. It suits steady, predictable workloads. Exceeding it gives ProvisionedThroughputExceededException.

3. Read and write from TypeScript

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

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "<region>" }));
const TableName = "orders"; // PK = customerId, SK = orderId

// Create only if absent: a conditional write instead of read-then-write.
await ddb.send(new PutCommand({
  TableName,
  Item: { customerId: "c#42", orderId: "o#2026-10-18#001", status: "NEW", total: 1999 },
  ConditionExpression: "attribute_not_exists(orderId)",
}));

// All of one customer's October orders, newest first, one page at a time (1 MB per page).
let ExclusiveStartKey: Record<string, unknown> | undefined;
do {
  const page = await ddb.send(new QueryCommand({
    TableName,
    KeyConditionExpression: "customerId = :c AND begins_with(orderId, :p)",
    ExpressionAttributeValues: { ":c": "c#42", ":p": "o#2026-10" },
    ScanIndexForward: false,
    ExclusiveStartKey,
  }));
  for (const item of page.Items ?? []) console.log(item.orderId, item.status);
  ExclusiveStartKey = page.LastEvaluatedKey;
} while (ExclusiveStartKey);

// Optimistic state change: fails with ConditionalCheckFailedException if someone moved it first.
await ddb.send(new UpdateCommand({
  TableName,
  Key: { customerId: "c#42", orderId: "o#2026-10-18#001" },
  UpdateExpression: "SET #s = :next",
  ConditionExpression: "#s = :expected",
  ExpressionAttributeNames: { "#s": "status" }, // "status" is a reserved word
  ExpressionAttributeValues: { ":next": "PAID", ":expected": "NEW" },
}));
  • The document client converts plain JavaScript values to DynamoDB attribute values. The low-level client needs { S: "…" } forms.
  • Conditional writes (attribute_not_exists, #s = :expected) replace read-then-write races. A failed condition is ConditionalCheckFailedException, a normal outcome to handle, not to retry.
  • Reserved words such as status must be aliased through ExpressionAttributeNames (the API model's ExpressionAttributeNames documentation names reserved-word conflicts as the first use). A reserved word used unaliased is a common ValidationException cause.
  • Pagination: always loop on LastEvaluatedKey. Limit is "the maximum number of items to evaluate (not necessarily the number of matching items)": with a filter, a page can come back short or empty while LastEvaluatedKey says there is more (API model).
  • Batches: retry UnprocessedItems / UnprocessedKeys with backoff. They are partial successes, not errors.

4. Permissions

The caller needs dynamodb:PutItem, Query, UpdateItem and so on, on arn:aws:dynamodb:<region>:<account>:table/<name>, plus …/index/* for index queries (UNVERIFIED ARN suffix in the collected sources). Add kms:Decrypt and kms:GenerateDataKey if the table uses a customer managed KMS key (reference/kms/key-policies-and-errors.md).

Next

  • Error by name: reference/dynamodb/errors.md.
  • Reacting to changes: patterns/dynamodb-streams-to-lambda.md.
  • Expiring items, backups and restores, multi-Region: reference/dynamodb/ttl-pitr-and-global-tables.md.

Read this document as markdown — the same bytes an agent receives.