DynamoDB Single-Table Design skill for Codex
Every access pattern is one Query, checked by script. Codex designs DynamoDB tables from an agreed access-pattern list, so each read is one GetItem or Query on the table or an overloaded GSI rather than a Scan with filters. It enforces uniqueness and versions with condition expressions and transactions, shards hot keys, and runs a bundled Python checker that fails when a pattern points at a missing index or a key shape no item writes.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- dynamodb-single-table Installs to ~/.agents/skills/dynamodb-single-table/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install dynamodb-single-table The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project dynamodb-single-table Installs to .agents/skills/dynamodb-single-table/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/dynamodb-single-table.zipunzip -o dynamodb-single-table.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) dynamodb-single-table PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $dynamodb-single-table, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs DynamoDB tables from an access-pattern list - single-table key design with PK and SK prefixes, item collections, overloaded and sparse GSIs, adjacency lists, uniqueness and optimistic locking with condition expressions, TransactWriteItems, pagination, TTL, write sharding for hot keys - and checks the design with a bundled access-pattern validator. Covers AWS SDK v3 DocumentClient code and when single-table is the wrong choice. Use when modeling a new DynamoDB table, adding an access pattern or GSI, fixing Scan-heavy or throttled tables, or reviewing ElectroDB or DynamoDB Toolbox entities.
Use it when
- Modeling a new DynamoDB table or entity
- Adding an access pattern or GSI to an existing table
- Hot path uses Scan or heavy FilterExpressions
- Writes need uniqueness or optimistic locking
Use something else when
- The workload needs ad hoc queries, joins or analytics: use postgres-schema-design
- Wiring Lambda handlers and packaging around the table: use aws-lambda
How to check the result
The access-pattern validator reports zero errors, and integration tests against DynamoDB Local or a dev table run each pattern as a GetItem or Query with paging through LastEvaluatedKey.
The mistake it prevents
Using a low-cardinality GSI partition key such as status or date, which throttles writes to the whole table through one hot partition.
Done well means: every access pattern in the written list is served by one GetItem or
Query (or a small fixed number) on the table or a GSI, no hot path uses Scan or a
filter that discards most items, writes that must agree happen in one condition or
transaction, and the key design survives the next three patterns the product will ask for.
When not to use single-table
Use separate tables (still query-first) when entities share no access patterns, when teams own them separately, when you need per-entity streams, backups or capacity settings, or when the workload is analytics. Ad hoc querying and joins belong in a relational store or an export to S3/Athena. State the choice and the reason in the design.
Workflow
- Read
AGENTS.md; find IaC (CDK, SAM, Terraform, SST) and the data layer (raw SDK v3, ElectroDB, DynamoDB Toolbox). - List entities and their relationships (ERD in a few lines).
- Write the access-pattern table with the user. Every read and write, with frequency.
- Design primary keys so most patterns hit the base table’s item collections.
- Add GSIs only for patterns the base table cannot serve; overload them across entities.
- Write sample items per entity with every key attribute.
- Encode the design as JSON and run the validator script.
- Write the data-access functions and integration tests against DynamoDB Local or a dev table.
Access-pattern table
| # | Pattern | Index | Key condition | Notes |
|---|---|---|---|---|
| 1 | Get customer profile | table | PK = CUSTOMER#<id>, SK = PROFILE | GetItem |
| 2 | Customer orders, newest first | table | PK = CUSTOMER#<id>, SK begins_with ORDER# | ScanIndexForward=false |
| 3 | Find customer by email | GSI1 | GSI1PK = EMAIL#<email> | uniqueness item too |
| 4 | Open orders by status, oldest first | GSI2 (sparse) | GSI2PK = STATUS#OPEN#<shard> | only open orders carry GSI2 keys |
Key design rules
- Generic key names (
PK,SK,GSI1PK,GSI1SK) so one table holds many entities. - Prefix values with the entity type:
CUSTOMER#123,ORDER#2026-10-28T10:00:00Z#9f2c. - Put items read together under one partition key (an item collection) and make the sort key order match the read order. ISO-8601 timestamps and zero-padded numbers sort correctly.
- High-cardinality partition keys. A partition serves at most about 3 000 RCU and 1 000 WCU;
a constant PK (
ORDERS) or a single hot tenant throttles regardless of table capacity. - Store an
entityTypeattribute on every item for filtering exports and debugging. - Item size limit is 400 KB. Large blobs go to S3 with a pointer.
GSI patterns
| Pattern | How |
|---|---|
| Inverted index (many-to-many) | GSI with GSI1PK = SK, GSI1SK = PK to query the relationship from the other side |
| Overloaded GSI | Different entities write different meanings into GSI1PK/GSI1SK |
| Sparse index | Write GSI keys only on items that should appear (open orders, flagged users); remove the attributes to drop them |
| Write sharding for hot GSI keys | STATUS#OPEN#<0-9> with a random or hashed suffix; query all shards in parallel and merge |
- GSIs are eventually consistent only;
ConsistentReadis rejected on them. - Project only needed attributes (
KEYS_ONLYorINCLUDE) for large items to cut write cost. - Each GSI adds write cost for every item that carries its keys.
- LSIs must be created with the table and cap each item collection at 10 GB; prefer GSIs.
Writes that must agree
Uniqueness on a non-key attribute (email) with a transaction:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, TransactWriteCommand, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
await doc.send(new TransactWriteCommand({
TransactItems: [
{ Put: { TableName, Item: { PK: `CUSTOMER#${id}`, SK: "PROFILE", email, entityType: "Customer",
GSI1PK: `EMAIL#${email}`, GSI1SK: "CUSTOMER" },
ConditionExpression: "attribute_not_exists(PK)" } },
{ Put: { TableName, Item: { PK: `UNIQUE#EMAIL#${email}`, SK: "UNIQUE", customerId: id },
ConditionExpression: "attribute_not_exists(PK)" } },
],
}));
Optimistic locking with a version attribute:
await doc.send(new UpdateCommand({
TableName,
Key: { PK: `CUSTOMER#${customerId}`, SK: `ORDER#${createdAt}#${orderId}` },
UpdateExpression: "SET #s = :s, version = version + :one",
ConditionExpression: "version = :v",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":s": "PAID", ":v": expectedVersion, ":one": 1 },
}));
- Catch
ConditionalCheckFailedExceptionandTransactionCanceledException(inspectCancellationReasons) and map them to domain errors, not 500s. - Transactions take up to 100 actions and 4 MB, cost double, and conflict with concurrent writes to the same items. Keep them for invariants, not bulk loads.
- Counters:
ADD viewCount :oneis atomic; avoid read-modify-write. - Use
ExpressionAttributeNamesfor reserved words (status,name,date, …).
Reads
const res = await doc.send(new QueryCommand({
TableName,
KeyConditionExpression: "PK = :pk AND begins_with(SK, :sk)",
ExpressionAttributeValues: { ":pk": `CUSTOMER#${id}`, ":sk": "ORDER#" },
ScanIndexForward: false,
Limit: 25,
ExclusiveStartKey: cursor, // from the previous page's LastEvaluatedKey
}));
- Query returns at most 1 MB per call; always handle
LastEvaluatedKey. Encode it as an opaque, signed or encrypted cursor for clients; never accept raw keys from users. Limitapplies beforeFilterExpression; a filtered page can come back empty with more data remaining.FilterExpressionstill charges for every item read. If a pattern filters out most items, it needs a key design or sparse GSI instead.BatchGetItem(up to 100 keys) andBatchWriteItem(up to 25 puts/deletes) can partially fail: retryUnprocessedKeys/UnprocessedItemswith backoff.
Validate the design
Write the design as JSON (table keys, GSIs, sample items with key templates, patterns) and run the bundled checker from the skill folder:
python3 scripts/check_access_patterns.py --example > design.json # starting template
python3 scripts/check_access_patterns.py design.json
It fails when a pattern targets a missing index, uses a sort-key condition on an index without a sort key, asks for consistent reads on a GSI, or has a key shape no sample item writes. It warns on Scans, filters, constant partition keys and GSIs no item populates. Fix every error before writing code.
Operations
- TTL: store epoch seconds in a
ttl-style attribute; deletion happens asynchronously (often within days, not on time), so filter expired items in reads that care. - Capacity: on-demand for new or spiky workloads; provisioned with auto scaling once traffic is steady and cost matters.
- Backfilling a new GSI key onto existing items is a scan-and-update job: run it throttled, idempotently, and with user confirmation against production.
- Deleting items in bulk, dropping a GSI, or deleting a table requires explicit user confirmation; enable point-in-time recovery before risky migrations.
- Local testing: DynamoDB Local (Docker image
amazon/dynamodb-local) or a dev table; the AWS CLI and SDK calls need network and credentials, so ask before running them.
Gotchas
- Designing keys around entities instead of patterns produces a relational schema in DynamoDB that needs Scans.
- Numbers in sort keys sort lexicographically when stored as strings: zero-pad them.
- Changing a key format later means rewriting every item; version prefixes help (
v2#...). Scanwith parallel segments is for exports and migrations only.- Hot keys hide in GSIs: a low-cardinality GSI partition key (status, type, date) throttles writes to the base table.
Output
## DynamoDB design: <table>
**Entities:** <list with key templates>
| Entity | PK | SK | GSI1PK | GSI1SK | Other GSIs |
**Access patterns:** <table: #, pattern, index, key condition>
**Invariants:** <uniqueness items, conditions, transactions>
**Hot-key risks and mitigations:** <sharding, sparse indexes>
**Validator:** check_access_patterns.py result (0 errors, warnings explained) What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "DynamoDB Single-Table Design"
short_description: "Every access pattern is one Query, checked by script"
default_prompt: "$dynamodb-single-table Design a DynamoDB single-table schema for customers, orders and order items" scripts/check_access_patterns.py
#!/usr/bin/env python3
"""Check a DynamoDB single-table design against its access patterns.
Reads a JSON spec describing the table keys, GSIs, sample items and access
patterns, then verifies that every pattern is served by a Query or GetItem on
an existing index, with a key shape that at least one item actually writes.
Usage:
python3 check_access_patterns.py design.json
python3 check_access_patterns.py --example > design.json
Exit code 0 when there are no errors (warnings allowed), 1 otherwise.
"""
from __future__ import annotations
import json
import sys
SK_OPS = {"eq", "begins_with", "between", "lt", "lte", "gt", "gte", "none"}
EXAMPLE = {
"table": {"name": "app", "pk": "PK", "sk": "SK"},
"indexes": [
{"name": "GSI1", "pk": "GSI1PK", "sk": "GSI1SK", "projection": "ALL"}
],
"items": [
{"entity": "Customer", "PK": "CUSTOMER#<customerId>", "SK": "PROFILE",
"GSI1PK": "EMAIL#<email>", "GSI1SK": "CUSTOMER"},
{"entity": "Order", "PK": "CUSTOMER#<customerId>", "SK": "ORDER#<createdAt>#<orderId>",
"GSI1PK": "ORDER#<orderId>", "GSI1SK": "ORDER"},
{"entity": "OrderItem", "PK": "ORDER#<orderId>", "SK": "ITEM#<sku>"}
],
"patterns": [
{"name": "Get customer profile", "index": "table",
"pk": "CUSTOMER#<customerId>", "sk": {"op": "eq", "value": "PROFILE"}},
{"name": "Customer orders, newest first", "index": "table",
"pk": "CUSTOMER#<customerId>", "sk": {"op": "begins_with", "value": "ORDER#"}},
{"name": "Find customer by email", "index": "GSI1",
"pk": "EMAIL#<email>", "sk": {"op": "eq", "value": "CUSTOMER"}},
{"name": "Get order by id", "index": "GSI1",
"pk": "ORDER#<orderId>", "sk": {"op": "eq", "value": "ORDER"}},
{"name": "Items of an order", "index": "table",
"pk": "ORDER#<orderId>", "sk": {"op": "begins_with", "value": "ITEM#"}}
]
}
def prefix(template: str) -> str:
"""Literal text before the first <placeholder>."""
return template.split("<", 1)[0]
def shapes_match(pattern_value: str, item_value: str, op: str) -> bool:
"""True if an item key template could satisfy the pattern's key condition."""
p, i = prefix(pattern_value), prefix(item_value)
if op == "eq":
if "<" not in pattern_value and "<" not in item_value:
return pattern_value == item_value
return i.startswith(p) or p.startswith(i)
# begins_with, between and range operators: compare literal prefixes
return i.startswith(p) or p.startswith(i)
def main(argv: list[str]) -> int:
if len(argv) == 2 and argv[1] == "--example":
print(json.dumps(EXAMPLE, indent=2))
return 0
if len(argv) != 2:
print(__doc__.strip(), file=sys.stderr)
return 2
try:
with open(argv[1], encoding="utf-8") as fh:
spec = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read spec: {exc}", file=sys.stderr)
return 2
errors: list[str] = []
warnings: list[str] = []
table = spec.get("table") or {}
if not table.get("pk"):
errors.append("table.pk is required")
indexes = {"table": {"name": "table", "pk": table.get("pk"), "sk": table.get("sk"), "base": True}}
for idx in spec.get("indexes", []):
if not idx.get("name") or not idx.get("pk"):
errors.append(f"index {idx!r} needs name and pk")
continue
indexes[idx["name"]] = {**idx, "base": False}
if len(indexes) - 1 > 20:
warnings.append("more than 20 GSIs: above the default per-table quota")
items = spec.get("items", [])
for it in items:
for attr in (table.get("pk"), table.get("sk")):
if attr and attr not in it:
errors.append(f"item '{it.get('entity', '?')}' is missing table key attribute {attr}")
for name, idx in indexes.items():
if idx["base"] or not items:
continue
if not any(idx["pk"] in it for it in items):
warnings.append(f"index {name}: no sample item writes {idx['pk']}, so the index stays empty")
rows = []
for pat in spec.get("patterns", []):
pname = pat.get("name", "?")
iname = pat.get("index", "table")
if pat.get("scan"):
warnings.append(f"'{pname}': uses Scan; acceptable only for rare admin or export jobs")
rows.append((pname, iname, "Scan", "WARN"))
continue
idx = indexes.get(iname)
if idx is None:
errors.append(f"'{pname}': index '{iname}' is not defined")
rows.append((pname, iname, "-", "ERROR"))
continue
pk_t = pat.get("pk", "")
sk = pat.get("sk") or {"op": "none"}
op = sk.get("op", "none")
status = "OK"
if not pk_t:
errors.append(f"'{pname}': pk template missing")
status = "ERROR"
if op not in SK_OPS:
errors.append(f"'{pname}': unknown sort key operator '{op}'")
status = "ERROR"
if op != "none" and not idx.get("sk"):
errors.append(f"'{pname}': index '{iname}' has no sort key, cannot apply '{op}'")
status = "ERROR"
if pat.get("consistent") and not idx["base"]:
errors.append(f"'{pname}': strongly consistent reads are not supported on GSIs")
status = "ERROR"
if pk_t and "<" not in pk_t:
warnings.append(f"'{pname}': constant partition key '{pk_t}' concentrates traffic on one partition")
status = "WARN" if status == "OK" else status
if pat.get("filter"):
warnings.append(f"'{pname}': FilterExpression runs after the read; you pay for filtered-out items")
status = "WARN" if status == "OK" else status
if items and pk_t and status != "ERROR":
candidates = [it for it in items
if idx["pk"] in it and shapes_match(pk_t, str(it[idx["pk"]]), "eq")]
if not candidates:
errors.append(f"'{pname}': no item writes a {idx['pk']} shaped like '{pk_t}'")
status = "ERROR"
elif op not in ("none",) and idx.get("sk"):
val = str(sk.get("value", ""))
if val and not any(idx["sk"] in it and shapes_match(val, str(it[idx["sk"]]), op)
for it in candidates):
errors.append(f"'{pname}': no item under '{pk_t}' has {idx['sk']} matching {op} '{val}'")
status = "ERROR"
kind = "GetItem" if (idx["base"] and op == "eq" and "<" not in str(sk.get("value", ""))) else "Query"
rows.append((pname, iname, kind, status))
if not rows:
errors.append("no access patterns defined")
width = max([len(r[0]) for r in rows] + [7])
print(f"{'Pattern'.ljust(width)} {'Index':<8} {'Op':<14} Status")
for r in rows:
print(f"{r[0].ljust(width)} {r[1]:<8} {r[2]:<14} {r[3]}")
for w in warnings:
print(f"warn: {w}")
for e in errors:
print(f"error: {e}")
print(f"\n{len(rows)} pattern(s), {len(errors)} error(s), {len(warnings)} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.