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.

  • Databases
  • $dynamodb-single-table
  • 178 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- dynamodb-single-table

Installs to ~/.agents/skills/dynamodb-single-table/ for every project on this machine.

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

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.

~/.agents/skills/dynamodb-single-table/SKILL.md

--- name: dynamodb-single-table description: Designs DynamoDB tables from an access-pattern list - single-table key design with PK and SK prefixes, item collections, overloaded and sparse GSIs,… license: MIT ---

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

#PatternIndexKey conditionNotes
1Get customer profiletablePK = CUSTOMER#<id>, SK = PROFILEGetItem
2Customer orders, newest firsttablePK = CUSTOMER#<id>, SK begins_with ORDER#ScanIndexForward=false
3Find customer by emailGSI1GSI1PK = EMAIL#<email>uniqueness item too
4Open orders by status, oldest firstGSI2 (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 entityType attribute on every item for filtering exports and debugging.
  • Item size limit is 400 KB. Large blobs go to S3 with a pointer.

GSI patterns

PatternHow
Inverted index (many-to-many)GSI with GSI1PK = SK, GSI1SK = PK to query the relationship from the other side
Overloaded GSIDifferent entities write different meanings into GSI1PK/GSI1SK
Sparse indexWrite GSI keys only on items that should appear (open orders, flagged users); remove the attributes to drop them
Write sharding for hot GSI keysSTATUS#OPEN#<0-9> with a random or hashed suffix; query all shards in parallel and merge
  • GSIs are eventually consistent only; ConsistentRead is rejected on them.
  • Project only needed attributes (KEYS_ONLY or INCLUDE) 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 ConditionalCheckFailedException and TransactionCanceledException (inspect CancellationReasons) 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 :one is atomic; avoid read-modify-write.
  • Use ExpressionAttributeNames for 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.
  • Limit applies before FilterExpression; a filtered page can come back empty with more data remaining.
  • FilterExpression still 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) and BatchWriteItem (up to 25 puts/deletes) can partially fail: retry UnprocessedKeys/UnprocessedItems with 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#...).
  • Scan with 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.yamlgenerated from this page

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.py170 lines, 7.1 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem