DynamoDB Modeling skill for Claude Code
Keys designed from queries, not from entity diagrams. Claude refuses to write a key until an access-pattern inventory with rates exists, then maps each pattern to one GetItem, Query, or transaction. It co-locates entities read together, adds sparse or multi-attribute GSIs only where the base table falls short, enforces uniqueness with guard items and condition expressions, and shards keys that would exceed per-partition throughput.
- name
dynamodb-modeling - size201 lines · ≈2,700 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs Amazon DynamoDB tables from an access-pattern inventory - single-table vs multi-table choice, partition and sort key design, item collections, GSIs (including multi-attribute GSI keys), sparse indexes, TransactWriteItems, conditional writes for uniqueness and optimistic locking, write sharding for hot partitions, TTL, and Streams. Use when starting a DynamoDB schema, adding a query the table cannot serve, seeing ThrottlingException or hot keys, migrating relational data to DynamoDB, or reviewing PK/SK and GSI definitions. Try asking
- Design a DynamoDB table for orders, line items and warehouse stock
- We get ThrottlingException on one tenant's partition - fix the key design
- I need to list open orders per warehouse without scanning the table
Use it when
- Starting a new DynamoDB table or service schema
- A new feature needs a query the current keys cannot serve
- CloudWatch shows throttling concentrated on a few partition keys
- Moving a relational model onto DynamoDB
Skip it when
- The workload is ad-hoc analytics with unknown queries - use duckdb-analytics or a relational store
- You are modeling documents in MongoDB - use mongodb-modeling
How to check the result
Every inventory row runs as code against DynamoDB Local using only GetItem, Query, or transactions, with ConsumedCapacity per pattern recorded and no Scan in the data layer.
The common mistake it prevents
Relying on FilterExpression to narrow a Query: filters run after the read, so you still pay capacity for every item the key condition matched.
A DynamoDB design is done when every access pattern in the inventory maps to exactly one
GetItem, Query, or transaction against a named key or index, no pattern needs a
Scan or a client-side join, and no single partition key value receives more than its
share of traffic. Model from queries backwards; the entity diagram is an input, not the
design.
Workflow
- [ ] 1. Write the access-pattern inventory (with rates and consistency needs)
- [ ] 2. Decide single-table vs one-table-per-service-entity
- [ ] 3. Design base-table PK/SK and item collections
- [ ] 4. Add GSIs only for patterns the base table cannot serve
- [ ] 5. Define write paths: conditions, transactions, idempotency
- [ ] 6. Check hot partitions, item size, and fan-out
- [ ] 7. Prove it: run every pattern against DynamoDB Local or a dev table
1. Access-pattern inventory
Do not write a key until this table exists. Ask the user for missing rates; guess order-of-magnitude rather than skip the column.
| # | Pattern | Type | Key inputs | Sort/filter | Rate (peak/s) | Consistency |
|---|---|---|---|---|---|---|
| 1 | Get user by id | read | userId | - | 800 | eventual |
| 2 | List a user’s orders, newest first | read | userId | createdAt desc, page 20 | 300 | eventual |
| 3 | Get order with its line items | read | orderId | - | 200 | strong |
| 4 | List open orders for a warehouse | read | warehouseId, status=OPEN | createdAt | 20 | eventual |
| 5 | Place order (order + items + stock decrement) | write | - | - | 50 | atomic |
Use the worksheet in references/access-pattern-worksheet.md when the domain has more than about eight patterns or several teams own entities.
2. Single-table or not
| Situation | Choice |
|---|---|
| Patterns fetch several entity types together (order + items, user + settings) | Single table; co-locate them in one item collection |
| Entities are independent, accessed separately, owned by different teams | One table per entity; simpler IAM, metrics, backups |
| Need per-entity capacity mode, PITR, or stream consumers | Separate tables |
| Ad-hoc analytics or unknown future queries dominate | Not DynamoDB; use Postgres or export to S3/Athena |
Default: single table per service when entities are read together; otherwise do not force it. Single-table is a tool for avoiding joins, not a goal.
3. Base-table keys
- Use generic attribute names
PKandSKwith typed prefixes so one table holds many entity types:USER#123,ORDER#2026-10-19T08:15:00Z#9f2c. - The partition key picks the item collection; the sort key orders and ranges within it.
Querysupports=,<,<=,>,>=,BETWEEN, andbegins_withon the sort key only. - Put the time component in the SK as an ISO-8601 string or zero-padded number so lexical
order equals chronological order. Use
ScanIndexForward=falsefor newest-first. - Store an entity’s own attributes plainly (
userId,email) as well as in keys; keys are for access, attributes are for the application. Never parse keys in application logic. - Add an
entityTypeattribute to every item for filtering, exports, and migrations.
Example layout for the inventory above:
| Entity | PK | SK | Notes |
|---|---|---|---|
| User | USER#<userId> | PROFILE | pattern 1 via GetItem |
| Order (user view) | USER#<userId> | ORDER#<createdAt>#<orderId> | pattern 2 via Query, begins_with ORDER# |
| Order (canonical) | ORDER#<orderId> | META | pattern 3 |
| Line item | ORDER#<orderId> | ITEM#<lineNo> | pattern 3: one Query returns META + items |
| Stock | SKU#<sku> | STOCK | decremented in pattern 5 transaction |
Duplicating the order summary under the user is intentional denormalization. Keep the duplicate small (summary fields only) and update both in one transaction or via Streams.
4. GSIs
- Add a GSI only for a pattern the base table cannot answer. Each GSI duplicates every projected item write and costs write capacity on every change to its key attributes.
- GSI reads are eventually consistent only. If a pattern needs strong consistency, it must use the base table.
- Project
KEYS_ONLYorINCLUDEthe fields the pattern renders;ALLdoubles storage and write cost for wide items. - Sparse index: an item appears in a GSI only if it has the GSI key attributes. Set
GSI1PK = WAREHOUSE#<id>#OPENonly while an order is open andREMOVEit on close; the index then holds just the open set (pattern 4). - Overloaded GSI (
GSI1PK/GSI1SKreused by several entity types) keeps the index count low; default limit is 20 GSIs per table. - Since November 2025, GSIs accept multi-attribute keys (up to four attributes each for the
partition and sort key), which removes the need for concatenated synthetic keys such as
country#state#cityon new indexes. Sort-key attributes are matched left to right, like a composite index. Prefer this for new GSIs when the SDK version in use supports it; keep synthetic keys on the base table, where the PK/SK are still single attributes. - LSIs must be created with the table, share the partition’s 10 GB item-collection limit, and allow strong reads. Use only when you need an alternate sort order with strong consistency and the collection will stay small.
5. Write paths
Uniqueness and invariants come from condition expressions, not from reads before writes.
# boto3 - create user only if email is unclaimed, atomically
client.transact_write_items(TransactItems=[
{"Put": {"TableName": T, "Item": {"PK": {"S": f"USER#{uid}"}, "SK": {"S": "PROFILE"}, "email": {"S": email}},
"ConditionExpression": "attribute_not_exists(PK)"}},
{"Put": {"TableName": T, "Item": {"PK": {"S": f"EMAIL#{email.lower()}"}, "SK": {"S": "EMAIL"}, "userId": {"S": uid}},
"ConditionExpression": "attribute_not_exists(PK)"}},
], ClientRequestToken=request_id) # idempotent for 10 minutes
# Optimistic locking on a version attribute
table.update_item(
Key={"PK": f"ORDER#{oid}", "SK": "META"},
UpdateExpression="SET #s = :new, version = version + :one",
ConditionExpression="version = :expected",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":new": "SHIPPED", ":one": 1, ":expected": v},
)
| Need | Tool |
|---|---|
| Atomic counter | UpdateItem with ADD (not idempotent on retry; pair with a request-id guard item if double counts matter) |
| All-or-nothing across items/tables | TransactWriteItems (up to 100 actions, 4 MB, same Region; costs 2x WCU) |
| Bulk load, independent items | BatchWriteItem (25 items per call; retry UnprocessedItems with backoff) |
| Uniqueness on a non-key attribute | Guard item keyed on that value, written in the same transaction |
| React to changes (projections, search sync, audit) | DynamoDB Streams to Lambda; consumers must be idempotent |
| Expire sessions, idempotency keys | TTL attribute (epoch seconds); deletion is lazy, typically within days - filter expired items in reads |
Handle ConditionalCheckFailedException as a domain outcome (409, “already exists”), and
TransactionCanceledException by reading CancellationReasons to see which item failed.
6. Hot partitions, size, fan-out
- A single partition serves about 3,000 RCU and 1,000 WCU per second. Adaptive capacity rebalances, but one hot key value still caps out. Watch CloudWatch Contributor Insights for the most-accessed keys.
- Write sharding for high-write keys (a global counter, a popular tenant’s event feed):
append a suffix
#0..N-1chosen by hash or random, and query all N shards in parallel on read. Pick N from peak write rate / 1,000 with headroom. - Items are capped at 400 KB including attribute names. Move blobs to S3 and store the key. Long attribute names cost on every item; keep them short but readable.
Queryreturns at most 1 MB per page before filters apply.FilterExpressionreduces bytes returned, not capacity consumed - if a filter discards most items, the key design is wrong.- Unbounded item collections (all events for a tenant forever) grow hot and slow; bucket by
time in the PK (
TENANT#42#2026-10) when a collection could exceed a few GB or sustained high write rates.
Capacity mode
Default to on-demand for new or spiky workloads; on-demand prices dropped substantially in late 2024 and it can be capped with maximum throughput settings. Switch to provisioned with auto scaling only when traffic is steady and the cost model shows savings.
Gotchas
Scanin a request path is a design bug. Accept it only for offline jobs, withSegment/TotalSegmentsparallelism and a rate limit.- Reserved words (
status,name,date,count) must go throughExpressionAttributeNames. - Numbers are sent as strings in the low-level API and come back as
Decimalin boto3’s resource layer; JSON-encoding them needs a custom encoder. - Changing a key schema means a new table or new GSI plus backfill. GSI backfill on a large table takes hours and consumes write capacity; plan it as a migration.
- Pagination: always loop on
LastEvaluatedKey; never assume one call returns everything. Expose an opaque cursor (base64 of the key), not page numbers. - Strongly consistent reads cost 2x and are unavailable on GSIs and global table replicas in other Regions.
- Global tables are last-writer-wins by default for concurrent writes in different Regions; design writes to be Region-affine if conflicts matter.
Verification
- Run DynamoDB Local (
docker run -p 8000:8000 amazon/dynamodb-local) or a dev table. - Load a seed fixture covering each entity type, including an edge case per pattern (empty collection, max page, duplicate email).
- Execute every inventory row as code and assert it uses only
GetItem/Query/transaction calls - grep the data layer forscan(and fail review on hits. - Check
ConsumedCapacity(ReturnConsumedCapacity="TOTAL") per pattern and multiply by peak rate to estimate cost and per-key load. - If a pattern needs a filter discarding over half the read items, revisit step 3 or 4.
Output: design note
## Table: orders-service (on-demand, PITR on, TTL attr `expiresAt`)
| # | Pattern | Operation | Key condition | Index |
|---|---|---|---|---|
| 2 | User's orders newest first | Query | PK=USER#id AND begins_with(SK,'ORDER#'), ScanIndexForward=false | base |
| 4 | Open orders per warehouse | Query | GSI1PK=WAREHOUSE#id#OPEN | GSI1 (sparse, INCLUDE total,status) |
**Write invariants:** email uniqueness via EMAIL# guard item; order + stock in one transaction.
**Hot-key risk:** none above 200 WCU/key at peak; counters sharded x8.
**Deferred:** full-text search on products -> Streams to OpenSearch. Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/access-pattern-worksheet.md
# Access-pattern worksheet
Use this when the domain has many patterns or several owners. Fill it in with the user
before proposing keys; revisit it whenever a new feature adds a query.
## 1. Entities and relationships
| Entity | Identifier | Cardinality to parent | Max per parent | Avg item size | Mutability |
|---|---|---|---|---|---|
| Tenant | tenantId | - | - | 1 KB | rare |
| User | userId | tenant 1:N | 50k | 2 KB | occasional |
| Order | orderId | user 1:N | unbounded | 3 KB | status changes |
| LineItem | (orderId, lineNo) | order 1:N | 200 | 0.5 KB | immutable |
"Max per parent" decides whether a collection can live under one partition key or needs
time bucketing. Anything unbounded and high-write needs a bucket or shard plan.
## 2. Pattern inventory
| # | Actor | Pattern | Inputs | Output shape | Order | Page size | Peak rate | Consistency | Latency SLO |
|---|---|---|---|---|---|---|---|---|---|
Rules while filling it in:
- One row per distinct key condition. "List orders by user" and "list orders by user and
status" are two rows unless status is a cheap filter on a small collection.
- Record writes too, with the invariants they must keep (uniqueness, counters, stock).
- Mark patterns that are admin-only or batch; they may tolerate a Scan on a schedule or an
export to S3 instead of an index.
## 3. Key assignment
| # | Table / index | PK value | SK condition | Projection | Notes |
|---|---|---|---|---|---|
Check after assignment:
- [ ] Every read row has exactly one key assignment
- [ ] No two GSIs serve the same pattern
- [ ] Each GSI has at least one pattern that only it can serve
- [ ] Each write row lists its condition expression or transaction
- [ ] Strong-consistency rows use the base table
- [ ] Per-key peak load below roughly 1,000 WCU / 3,000 RCU, or a shard plan exists
## 4. Evolution plan
| Future pattern (likely) | Served by | Cost to add later |
|---|---|---|
| Search products by text | Streams -> OpenSearch | new pipeline, no table change |
| Orders by coupon code | new sparse GSI | GSI backfill (hours on large tables) |
Patterns that would need a new base-table key are the expensive ones; raise them with the
user before the first deploy.
## 5. Common pattern recipes
| Need | Recipe |
|---|---|
| One-to-many, read together | Same PK, child SK prefix (`ORDER#id` / `ITEM#n`) |
| Many-to-many (users <-> groups) | Adjacency list: `PK=USER#u SK=GROUP#g` plus inverted GSI (`GSI1PK=GROUP#g GSI1SK=USER#u`) |
| Latest N items | SK with timestamp, `ScanIndexForward=false`, `Limit=N` |
| Status queue | Sparse GSI keyed on status only while pending |
| Leaderboard top N | GSI with fixed PK bucket and numeric score SK; shard bucket if write-heavy |
| Versioned documents | `SK=v0` holds latest copy, `SK=v<n>` history; update both in a transaction |
| Lookup by alternate unique key | Guard item `PK=EMAIL#x` written with `attribute_not_exists` |
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.