DynamoDB single-table design stores multiple entity types in one table so a request can retrieve a related item collection through known partition and sort keys. It can reduce network round trips for a well-understood workload, but it is not a rule for every system. Begin with access patterns, consistency needs, item sizes, write rates, and ownership boundaries; choose the table count after those facts are explicit.
- Write each access pattern before inventing key names.
- Use composite sort keys to group related items and retrieve ranges without scans.
- Add a GSI only for a real alternate lookup; sparse indexes can limit which items are copied.
- Query responses are paginated at 1 MB, filters run after items are read, and GSI reads are eventually consistent.
- Measure hot keys with throttling reason metrics and Contributor Insights rather than assuming table-wide capacity is the only constraint.
Start with an access-pattern table
For an order system, list the exact operations and expected scale:
| Operation | Key condition | Consistency |
|---|---|---|
| Get customer profile | PK=CUSTOMER#id, SK=PROFILE | Strong if read from the base table |
| List a customer's orders newest first | PK=CUSTOMER#id, begins_with(SK, ORDER#) | Base-table choice |
| Get one order and its lines | PK=ORDER#id with an order/line sort-key range | Base-table choice |
| List pending orders by month | Sparse GSI keyed by status bucket and time | Eventually consistent |
The partition key is hashed to select an internal partition. Items with the same partition-key value remain an item collection ordered by sort key. A composite sort key such as ORDER#2026-08-01T12:34:56Z#01J... supports prefix and range queries while preserving a deterministic tie-breaker.
A compact item model
// Customer profile
{
PK: "CUSTOMER#c-42",
SK: "PROFILE",
entityType: "Customer",
name: "Asha"
}
// Order summary in the customer's collection
{
PK: "CUSTOMER#c-42",
SK: "ORDER#2026-08-01T12:34:56Z#o-900",
entityType: "OrderSummary",
orderId: "o-900",
status: "PENDING",
GSI1PK: "STATUS#PENDING#2026-08",
GSI1SK: "2026-08-01T12:34:56Z#o-900"
}
// Order aggregate
{
PK: "ORDER#o-900",
SK: "ORDER",
entityType: "Order",
customerId: "c-42",
status: "PENDING"
}Only items carrying both GSI key attributes appear in that index. That makes the status index sparse: profiles and unrelated entities do not create index entries. Keep key grammar documented and validate it in one data-access layer; ambiguous delimiters and ad hoc key construction are difficult to migrate.
Query correctly: key conditions first, then pagination
A DynamoDB Query requires a partition-key equality condition and can add a sort-key condition. A FilterExpression is applied after DynamoDB reads the page, so it does not reduce the read capacity consumed for those evaluated items. Redesign a frequently filtered access pattern as a key condition or index rather than hiding it behind a filter.
Each Query response contains at most 1 MB before filtering. Continue while LastEvaluatedKey is present:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}))
let ExclusiveStartKey
const items = []
do {
const page = await ddb.send(new QueryCommand({
TableName: "Commerce",
KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
ExpressionAttributeValues: {
":pk": "CUSTOMER#c-42",
":prefix": "ORDER#2026-08"
},
ScanIndexForward: false,
ExclusiveStartKey
}))
items.push(...(page.Items ?? []))
ExclusiveStartKey = page.LastEvaluatedKey
} while (ExclusiveStartKey)Set a deliberate page or item limit for interactive APIs; do not accumulate an unbounded result in memory as the example does. GSI queries cannot request strongly consistent reads. If a workflow needs read-after-write behavior, read the authoritative base-table item or use a different coordination design.
Design GSI cost from index entries
A GSI has its own key schema, projections, storage, and throughput behavior. A base-table write consumes index write capacity only when it creates, changes, or removes that index's entry. The units depend on the index entry size, not a universal multiplier. An ALL projection can enlarge every matching index entry; INCLUDE or KEYS_ONLY can be cheaper, but may require a second base-table read.
DynamoDB's default quota is 20 GSIs per table. Treat that as a quota, not a target. Every index adds write paths, failure signals, backfill considerations, and operational cost. Overloading an index with multiple entity types is valid when each type has an unambiguous key grammar and compatible capacity distribution.
Atomic multi-item changes
TransactWriteItems supports up to 100 actions totaling no more than 4 MB in one account and Region. Two actions cannot target the same item. Use condition expressions to protect invariants and a ClientRequestToken for idempotency when retrying the same request.
import { TransactWriteCommand } from "@aws-sdk/lib-dynamodb"
await ddb.send(new TransactWriteCommand({
ClientRequestToken: requestId,
TransactItems: [
{
Update: {
TableName: "Commerce",
Key: { PK: "ORDER#o-900", SK: "ORDER" },
UpdateExpression: "SET #s = :paid",
ConditionExpression: "#s = :pending",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":paid": "PAID", ":pending": "PENDING" }
}
},
{
Put: {
TableName: "Commerce",
Item: { PK: "ORDER#o-900", SK: "PAYMENT#p-12", amount: 4200 },
ConditionExpression: "attribute_not_exists(PK)"
}
}
]
}))Transactional writes consume two write request units for each 1 KB item involved, subject to normal item-size rounding. Calculate cost from every affected item and index entry instead of labeling the whole operation simply “2x.”
Capacity mode and hot keys
On-demand mode removes manual throughput provisioning, but it does not make every instantaneous jump unthrottled. A table can immediately sustain its previous peak and up to twice that peak; a jump beyond twice the previous peak within roughly 30 minutes can throttle. Pre-warm in steps when a launch is expected to exceed that envelope. Provisioned mode with auto scaling can be appropriate for predictable workloads and explicit cost controls.
A concentrated partition-key value can still reach a key-range throughput limit while aggregate table capacity remains available. Look at ThrottlingReason, key-range throttle metrics, application retry telemetry, and DynamoDB Contributor Insights for frequently accessed keys. Shard a key only after evidence identifies a hot access pattern, because write sharding makes reads and ordering more complex.
Monitoring example
aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB --metric-name ConsumedReadCapacityUnits --dimensions Name=TableName,Value=Commerce --statistics Sum --period 60 --start-time 2026-08-01T00:00:00Z --end-time 2026-08-01T01:00:00ZAdd the GlobalSecondaryIndexName dimension to inspect one GSI. Capacity metrics are table/index level; use Contributor Insights when the question is which keys dominate traffic. Also monitor successful-request latency, system and user errors, conditional-check failures, transaction conflicts, and application retry exhaustion.
Official primary sources
- DynamoDB Query API
- Global secondary indexes
- TransactWriteItems API
- Read/write capacity consumption
- On-demand capacity mode
- Key-range throttling mitigation
- Contributor Insights
- AWS SDK for JavaScript v3 DynamoDB examples
Working with JusDB on DynamoDB design
JusDB reviews access patterns, key distribution, index projections, capacity evidence, and migration plans before a key schema becomes expensive to change.
Explore JusDB DynamoDB services → | Talk to a database engineer