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
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 pattern | Key design |
|---|---|
| Fetch one thing by ID | Partition 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 partition | Local 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
INlist 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
RequestLimitExceededat 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 isConditionalCheckFailedException, a normal outcome to handle, not to retry. - Reserved words such as
statusmust be aliased throughExpressionAttributeNames(the API model'sExpressionAttributeNamesdocumentation names reserved-word conflicts as the first use). A reserved word used unaliased is a commonValidationExceptioncause. - Pagination: always loop on
LastEvaluatedKey.Limitis "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 whileLastEvaluatedKeysays there is more (API model). - Batches: retry
UnprocessedItems/UnprocessedKeyswith 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.