Skip to content
Back to skills

Dynamodb Patterns

ASecurity

DynamoDB single-table design, composite keys, GSI design, conditional writes, BatchWrite/BatchGet chunking, atomic counters, TTL, streams, and cross-tenant isolation patterns. Select explicitly when this guidance applies.

  • 12 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
ai-agentsgosqlexpressawsgitdatabasebackendsecurity

Works with

  • cli

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add Nmor/the-council --skill dynamodb-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dynamodb Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dynamodb Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/nmor-dynamodb-patterns-the-council/badge)](https://www.skillsdirectory.com/skills/nmor-dynamodb-patterns-the-council)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dynamodb-patterns
description: DynamoDB single-table design, composite keys, GSI design, conditional writes, BatchWrite/BatchGet chunking, atomic counters, TTL, streams, and cross-tenant isolation patterns. Select explicitly when this guidance applies.
disable-model-invocation: true
---

# DynamoDB Patterns

> **Size budget: 20 KB** — `token-budget.mjs --check`.

DynamoDB is a key-value store dressed up as a database. Get the access patterns right at design time
and it scales effortlessly; get them wrong and the only fix is a multi-month migration. These
patterns prevent the common production-grade mistakes.

## When to Activate

- Designing a new table or GSI
- Writing or reviewing any `ddb.send(...)` call
- Adding a new query pattern to an existing table
- Auditing for cross-tenant leak risk
- Migrating between key shapes
- Diagnosing throttling / hot-partition incidents

## The Core Rule: Access Patterns Define the Schema

In a relational DB, you model the data, then query it. In DynamoDB, you enumerate the QUERIES first,
then design keys to make each one a single Query (never a Scan). Skipping this step always ends in a
costly redesign.

For each entity, write down:

1. **List X by Y** — needs `PK = Y` or a GSI on Y
2. **Get specific X** — needs the full PK + SK
3. **List X sorted by date** — SK should be a date-prefixed string

If your access pattern is "list all rows that match attribute Z" — that's a Scan, and Scans are
forbidden in production code paths. Either add a GSI on Z or denormalize.

## Tenant Isolation: `organization_id` In Every Key

Multi-tenant systems MUST use `organization_id` (or equivalent) as the partition key on every base
table. Two reasons:

1. **Hot-partition prevention** — one tenant's traffic can't dominate another's RCU/WCU.
2. **Cross-tenant leak prevention** — a forgotten filter on a Query is impossible because the
   Query's PK requires the org.

Allowed deviations:

- Tables keyed by a server-minted unique id (e.g. `connection_id`, `webhook_event_id`) where the org
  is on a GSI.
- Lookup tables (Slack workspace → org, share token → diagram) where the key IS the lookup value.

Both deviations require a defense-in-depth check at read time: confirm the resolved row's
`organization_id` matches the caller's.

A custom ESLint rule (or equivalent static check) should enforce this on every new `QueryCommand` /
`GetCommand` / `UpdateCommand` / `DeleteCommand`.

## Composite Sort Keys For Hierarchies

Sort keys can encode hierarchy:

```text
PK: organization_id        SK: USER#<user_id>
PK: organization_id        SK: TEAM#<team_id>
PK: organization_id        SK: TEAM#<team_id>#MEMBER#<user_id>
```

A single Query with `begins_with(SK, "TEAM#<team_id>")` returns the team plus every member in one
round-trip.

## GSI Design

Each GSI is a separate table in cost. Don't add one casually. Rules:

- **PK = the dimension you query by**, not the original PK. If you query by `(workspace_id)`, the
  GSI PK is `workspace_id`.
- **Project only what callers need** (`KEYS_ONLY` or `INCLUDE`). Default `ALL` doubles your storage
  cost for the table.
- **Sparse indexes** — set the GSI key only on rows that participate in the index (e.g.
  `is_published` only present when `true`). Indexed rows = cost; sparse means cheap.
- **Eventually consistent reads only** — GSIs don't support strong consistency. If you need
  read-your-writes guarantees, query the base table.

## Conditional Writes For Atomicity

DynamoDB has no transactions across partitions, but `ConditionExpression` makes single-item updates
atomic. Use them everywhere a race could land an inconsistent state:

```ts
// "Claim this idempotency key" — fails if already claimed
await ddb.send(new PutCommand({
  TableName: RATE_LIMIT_TABLE,
  Item: { pk: `idemp:${eventId}`, ttl, claimed_at: now },
  ConditionExpression: "attribute_not_exists(pk)",
}));

// "Increment if balance >= cost" — fails atomically if not enough
await ddb.send(new UpdateCommand({
  TableName: BALANCE_TABLE,
  Key: { user_id },
  UpdateExpression: "SET balance = balance - :cost",
  ConditionExpression: "balance >= :cost",
  ExpressionAttributeValues: { ":cost": cost },
}));
```

A `ConditionalCheckFailedException` is the success-as-failure signal — catch it and treat it as the
"already happened" branch.

## Atomic Counters

`UpdateExpression: "ADD count :delta"` is atomic. No need to read-modify-write:

```ts
await ddb.send(new UpdateCommand({
  TableName: COUNTERS,
  Key: { metric: "page_views" },
  UpdateExpression: "ADD #c :one",
  ExpressionAttributeNames: { "#c": "count" },
  ExpressionAttributeValues: { ":one": 1 },
}));
```

Concurrent increments compose correctly without a lock.

## BatchWriteItem: Chunk + Retry Unprocessed

`BatchWriteItem` accepts at most 25 items per call. It can also return `UnprocessedItems` if the
batch hits write capacity. Always chunk + retry:

```ts
const CHUNK = 25;
for (let i = 0; i < rows.length; i += CHUNK) {
  let unprocessed = { [TABLE]: rows.slice(i, i + CHUNK).map(toDeleteRequest) };
  let attempts = 0;
  while (unprocessed && Object.keys(unprocessed).length && attempts < 5) {
    const res = await ddb.send(new BatchWriteCommand({ RequestItems: unprocessed }));
    unprocessed = res.UnprocessedItems ?? {};
    attempts += 1;
  }
}
```

Naive `BatchWrite` callers leak rows under burst load. Don't.

## TTL For Ephemeral Data

Mark transient rows with `ttl: <epoch_seconds>`. DynamoDB sweeps expired rows (within ~48 hours) for
free. Use it for:

- Idempotency keys (4-day window for Stripe; 6-hour for Slack)
- WebSocket connection rows
- Rate-limit buckets
- Verification codes / one-time tokens
- Subscription rows for transient peers

The TTL attribute name is set per-table in `TimeToLiveSpecification` — typically just `ttl`.

## Streams For Fan-Out

Enable `StreamSpecification: NEW_AND_OLD_IMAGES` on any table whose mutations another system needs
to know about. A Lambda subscribed to the stream replaces:

- Cron jobs that scan for changes
- Application-layer dual writes to a second store
- "After-write" hooks scattered across handlers

Stream Lambdas must be idempotent — DynamoDB delivers at-least-once. They get retried with
exponential backoff and finally land in a DLQ.

## Pagination Always

Every Query / Scan loops `LastEvaluatedKey`:

```ts
async function queryAllItems<T>(params: QueryCommandInput): Promise<T[]> {
  const out: T[] = [];
  let lastKey: Record<string, unknown> | undefined;
  do {
    const res = await ddb.send(
      new QueryCommand({ ...params, ExclusiveStartKey: lastKey }),
    );
    out.push(...((res.Items as T[] | undefined) ?? []));
    lastKey = res.LastEvaluatedKey as Record<string, unknown> | undefined;
  } while (lastKey);
  return out;
}
```

Single-page Query results lie about completeness. Always loop, or apply an explicit `Limit` and
surface "there are more" to the caller.

## Hot-Partition Avoidance

A single PK that takes >3000 RCU or >1000 WCU per second hot-partitions and throttles, regardless of
table-level capacity. Defenses:

- Multi-tenant: `organization_id` partitioning naturally spreads load
- Single-tenant high-write workloads: append a write-shard suffix (`organization_id#<0-19>`) and
  Query each shard at read time
- Global counters: don't use one row; use sharded counters and aggregate

Watch CloudWatch's `ConsumedWriteCapacityUnits` per-partition to spot a hot key before it pages you.

## Don't Use Scans In Production

A Scan reads every row in the table. At 10k rows it's an annoyance; at 10M it's an outage. The two
legitimate Scan use cases:

- One-shot data migrations / backfills (run from a script, not a request handler)
- Nightly integrity walkers (audit chain verification, GDPR sweep)

Both should respect a `MAX_ROWS` cap and emit "I hit the cap" telemetry so an unbounded scan can't
silently exhaust memory.

## Single-Table vs Multi-Table

The "official" DynamoDB design pattern is single-table — one table holds every entity, distinguished
by `PK` / `SK` shapes. Pros: every query is a single Query. Cons: schemas are encoded in code, not
the table; new entities are easy but new queries on existing entities require GSIs or rewrites.

Multi-table is the simpler default. Use single-table when:

- You query across entity types in the same Query call
- You're running thousands of orgs and want to consolidate read capacity
- You have a stable, well-understood entity model

If unsure, start multi-table. It's easier to migrate towards single-table when access patterns
settle than the reverse.

## Common Smells

| Smell | Fix |
| ----- | --- |
| Query without `organization_id` in a multi-tenant table | Add it; or document the deviation in an allowlist |
| `as` cast on `res.Items` to a domain type | Validate at the boundary; DDB returns `Record<string, unknown>` |
| `BatchWriteCommand` with > 25 items | Chunk |
| `UpdateExpression` reading then writing the same attribute in code | Use atomic `ADD` / `SET` with `ConditionExpression` |
| `Scan` in a request handler | Replace with a Query against a GSI |
| GSI with `ProjectionType: ALL` and few rows accessing it | Switch to `KEYS_ONLY` or `INCLUDE` |
| TTL attribute set in code but not declared in `TimeToLiveSpecification` | Declare it; DDB silently ignores otherwise |
| Stream consumer that isn't idempotent | Add an idempotency key check or a conditional write |

## Skill Chain

1. **dynamodb-patterns** — this skill
2. **backend-patterns** — handler / repository shape
3. **security-review** — multi-tenant isolation, defense in depth
4. **aws-serverless-patterns** — Lambda integration, stream handlers

## Purpose

Principal-level DynamoDB design: single-table modelling with composite PK + GSI overload,
conditional writes for idempotency, BatchWriteItem chunking, item-collection size limits, TTL for
retention, Streams for change capture, tenant isolation, on-demand vs provisioned capacity choice,
transactional writes.

**Negative scope** (NOT what this skill covers):

- Relational schema (Postgres / MySQL) — see `postgres-patterns`
- Analytical query patterns — see `clickhouse-io`
- AWS Lambda + DDB triggers — see `aws-serverless-patterns`
- Generic backend service-layer wiring — see `backend-patterns`

## When NOT to use

- Workloads needing complex JOIN / aggregation queries (use Postgres or Athena)
- Strong-consistency multi-row transactions exceeding 100 items per TX (DDB transaction limit)
- Workloads with unpredictable / spiky access patterns where provisioned capacity model wastes
  budget (use on-demand only)
- Data with strong relational integrity needs (FKs, RLS-style policies)

## Standards Cited

- **Amazon DynamoDB Developer Guide** (`docs.aws.amazon.com/amazondynamodb/`) — canonical
- **AWS Well-Architected Framework — Data pillar** — design principles
- **The DynamoDB Book (Alex DeBrie)** — community-canonical single-table reference
- **CAP Theorem** + **PACELC** — consistency / availability trade-offs
- **OWASP ASVS 4.0.3 §4 (Access Control)** — tenant isolation
- **OWASP ASVS 4.0.3 §13.1 (Generic Web Service Security)** — input validation on item attributes
- **NIST SP 800-53 Rev 5 AC-3 (Access Enforcement)** — IAM `LeadingKeys` for tenant isolation
- **NIST SP 800-53 Rev 5 AC-6 (Least Privilege)** — per-table / per-index IAM scoping
- **CWE-22 (Path Traversal)** — applies to composite-key construction with user input
- **CWE-639 (Authorization Bypass via User-Controlled Key)** — tenant-id in PK
- **RFC 7232 (HTTP Conditional Requests)** — ETag + DDB version attribute for optimistic concurrency
- **AWS IAM Best Practices** — least privilege on DDB resources

## Anti-Patterns

| Pattern | Why bad | Correct alternative |
| --- | --- | --- |
| One table per entity ("table-per-class") | Loses single-table benefits; fan-out queries | Single table with overloaded PK / SK; GSI per access pattern |
| `Scan` operations on hot path | O(n) cost + RCU exhaustion | Use Query with PK; design GSI for the access pattern |
| `BatchWriteItem` of > 25 items | Silent partial failure | Chunk into batches of 25; retry `UnprocessedItems` |
| Conditional writes without idempotency key | Duplicate writes on retry | `ConditionExpression: attribute_not_exists(pk)` for upsert OR explicit idempotency key per `idempotency.md` |
| Streams + Lambda without batchSize tuning | Throttling + cold-start overhead | `batchSize=10`, `maximumBatchingWindowInSeconds=5`, `parallelizationFactor=10` |
| Hot partition key (e.g., shared timestamp prefix) | Single physical partition saturates | Add random suffix OR use composite that distributes |
| TTL set but cleanup-dependent logic in app | TTL deletion is async (up to 48h delay) | Treat TTL as eventual; verify state via item attribute, not "exists" |
| Provisioned capacity without auto-scaling | Throttling under load OR waste | On-demand for variable; provisioned + auto-scaling for stable |
| Cross-tenant access via shared PK | Data leakage on bug | Include `tenant_id` in PK; verify in IAM policy via `dynamodb:LeadingKeys` condition |
| `UpdateExpression` without `ExpressionAttributeNames` for reserved words | Cryptic errors | Always use `#name` + `:value` placeholders |

## Verification Checklist

- [ ] Access patterns documented + GSI designed per pattern (not "just in case")
- [ ] No Scan in hot-path code; Query everywhere
- [ ] BatchWrite chunks to 25; retries `UnprocessedItems`
- [ ] Conditional writes for idempotency on POST-style endpoints
- [ ] Streams enabled with appropriate `StreamViewType` (NEW_AND_OLD_IMAGES for audit)
- [ ] TTL configured; tested with `aws dynamodb update-time-to-live`
- [ ] Tenant isolation via PK prefix + IAM `LeadingKeys` condition
- [ ] Item size monitored (DDB 400KB limit; design for < 16KB typical)
- [ ] Backup strategy: PITR enabled OR scheduled export to S3
- [ ] CloudWatch alarms on `ConsumedReadCapacityUnits` + `ThrottledRequests`

## Cross-References

- `~/.claude/skills/aws-serverless-patterns/SKILL.md` — Lambda + DDB triggers
- `~/.claude/skills/backend-patterns/SKILL.md` — repository shape
- `~/.claude/skills/postgres-patterns/SKILL.md` — relational alternative
- `~/.claude/skills/clickhouse-io/SKILL.md` — OLAP alternative
- `~/.claude/rules-library/common/idempotency.md` — conditional-write pattern
- `~/.claude/rules-library/common/observability.md` — DDB metrics + alarms
- `~/.claude/agents/database-reviewer.md` — Council Division 9
- `~/.claude/agents/security-reviewer.md` — IAM + tenant isolation

## Why this skill exists

DynamoDB rewards single-table design and punishes relational reflexes: developers coming from RDBMS
create table-per-entity, run Scan on the hot path, and discover at scale that the system charges per
RCU and throttles under load. The patterns above codify the principal-level posture: single-table +
overloaded GSI + Query-everywhere + idempotency-via-conditional-write + tenant-isolated-via-IAM.
Apps following these defaults scale to millions of requests-per-second at predictable cost.

## Learning hooks

Per `~/.claude/rules/common/continuous-learning-mandate.md`:

**Signals to watch**:

- Cross-tenant scan / query without `tenant_id` PK prefix (multi-tenant isolation weakening)
- BatchWrite > 25 items in one call without chunking (DDB hard limit)
- Conditional write missing on idempotency-sensitive write (double-execute risk)
- Hot partition pattern emerges (single PK absorbs > 1000 RCU/s or 1000 WCU/s) — composite-key
  redesign needed
- GSI projected attributes set to ALL when only specific attrs are read (cost inflation)
- Stream consumer not handling `OLD_IMAGE` for tombstone-style deletes
- TTL attribute set in code but `TimeToLiveSpecification` not in IaC (DDB silently ignores)
- ScanIndexForward used to reverse query order (cheap; flag good usage)
- `Scan` operation in production code path (cost + latency anti-pattern; use Query)
- BatchGet returning UnprocessedKeys without retry-with-backoff

**Refinement candidates**:

- New access-pattern row when a new query shape appears (e.g., reverse-chronological by org)
- New conditional-write template when idempotency is required on a new operation class
- New GSI design rule when a cost overrun is traced to overprojection
- New cross-reference when a sister skill (aws-serverless-patterns, postgres-patterns,
  dynamodb-patterns) adds a related pattern

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…