-
Notifications
You must be signed in to change notification settings - Fork 0
Key expression patterns
A catalogue of creative key shapes that turn FilterExpression problems into KeyConditionExpression problems. Each pattern answers a SQL-shaped question with an index seek instead of a scan, states its cost, and names its failure mode. Vocabulary is in Concepts; the general selection rules are in Key and field design; the worked hierarchy is in Hierarchical data walkthrough.
The common thread: a key is a tiny sorted materialized view. Whatever you encode into it — hierarchy, time, status, tenancy — is what DynamoDB can seek by. Whatever you leave out is a filter, and filters only shrink responses, never costs.
All snippets assume:
import {Adapter} from 'dynamodb-toolkit';SQL:
SELECT * FROM items WHERE needs_review = true— a small hot subset of a large table.
A GSI only contains items that have the index key attributes. Omit the attribute on most items, write it on the interesting few, and the GSI is the subset — membership is presence:
const adapter = new Adapter({
client, table: 'documents', keyFields: ['docId'],
indices: {
'needs-review': {type: 'gsi', pk: 'reviewFlag', sk: 'submittedAt', projection: 'keys-only', sparse: true}
}
});
// Enter the queue: write the marker. Leave it: delete the marker.
await adapter.patch({docId}, {reviewFlag: 'pending'});
await adapter.patch({docId}, {}, {delete: ['reviewFlag']});Querying reviewFlag = 'pending' reads only queue members — the other 99% of the table costs nothing on this index. sparse: true (or {onlyWhen} for per-type predicates) makes the toolkit's built-in prepare maintain the marker; the declaration doc is Adapter: Constructor options.
Fails when the subset stops being small: a marker shared by half the table is a hot GSI partition, not a queue. See the sharded-marker variant in Per-tier sparse GSI markers.
SQL:
SELECT * FROM orders WHERE customer = ? AND status = 'open' ORDER BY created_at DESC LIMIT 20.
Concatenate the low-cardinality dimension with the ordering dimension in one sort key — status#createdAt — and both the equality and the range live in the key condition:
// sk values: 'open#2026-07-16T09:12:00Z', 'shipped#2026-07-01T…'
KeyConditionExpression: 'customerId = :c AND begins_with(#sk, :p)',
ExpressionAttributeValues: {':c': 'C-100', ':p': 'open#'}With the toolkit, declare keyFields: ['customerId', 'status', 'createdAt'] + a structuralKey and the composite is written for you; begins_with narrowing per tier comes from buildKey ({partial: 'open#'} narrows the next component). Newest-first is {descending: true} on any list read.
Fails when the status changes often: the sort key is identity, so every transition is a delete + put (adapter.move does the pair transactionally). High-churn workflow states are better as a GSI pk (update-in-place) than a base-table sk.
SQL:
ORDER BY created_at DESC— but inside abegins_withprefix or a merged listing where you can't flip the scan direction for just one segment.
Usually you don't need this: ScanIndexForward: false (the toolkit's {descending: true}) walks any index backwards for free. The reverse-timestamp trick — store 9999999999999 - epochMillis, zero-padded — is for the residual cases: newest-first within a composite where an outer component must still sort ascending, or interop with tooling that only pages forward.
const reverseEpoch = ts => String(9_999_999_999_999 - ts).padStart(13, '0');
// sk: 'C-100#7973...#...' — newest orders sort first lexicographicallyFails when anyone has to read the value: it's write-side obfuscation, keep the real timestamp as a plain attribute alongside. Reach for it last — descending: true covers the common ask.
SQL: separate databases per tenant, or
WHERE tenant_id = ?stapled onto every query.
Make the tenant the partition key (or the first composite component), and tenancy stops being a filter — it's the address:
const adapter = new Adapter({
client, table: 'workspaces',
keyFields: ['tenantId', 'entityId'],
structuralKey: '_sk',
technicalPrefix: '_'
});
const docs = await adapter.getListUnder({tenantId: 'acme'}, {limit: 50});Every tenant gets its own partition budget, cross-tenant reads are structurally impossible without naming the other tenant, and the REST layer scopes it once via exampleFromContext (derive {tenantId} from the auth context; every list/delete/clone route is then tenant-scoped automatically — including the unscoped-delete guard, which counts a non-empty example as scope).
Fails when one tenant dwarfs the rest: the whale tenant hits the per-partition ceiling while the table averages look healthy. Shard the whale (acme#0…acme#7) or promote it to its own table.
SQL: a many-to-many join table traversed in both directions.
One table holds nodes and edges. The base key is (src, dst); the reverse direction is one GSI that swaps the pair:
const adapter = new Adapter({
client, table: 'graph',
keyFields: ['src', 'dst'],
structuralKey: '_sk',
technicalPrefix: '_',
indices: {reverse: {type: 'gsi', pk: 'dst', sk: 'src', projection: 'keys-only'}}
});
// Node: {src: 'user#42', dst: 'user#42', name: '…'} (self row carries the entity)
// Edge: {src: 'user#42', dst: 'team#7', role: 'member'} (edge attributes ride the row)
const memberships = await adapter.getListByParams(
adapter.buildKey({src: 'user#42'}, {partial: 'team#'}, {TableName: adapter.table})
);"Teams user 42 belongs to" is a base Query narrowed by the team# prefix; "members of team 7" is the same shape on the reverse GSI (dst = 'team#7'). Edges are items, so membership metadata (role, joined-at) lives on the edge row.
Fails when you need real graph traversal (multi-hop, shortest path) — each hop is a round trip; three hops in, you want a graph database. And frequent re-parenting means delete + put per edge move, same as any key-encoded relationship.
SQL: an append-only events table, queried by device and time window.
Monotonic keys (bare timestamps, auto-increments) drive every write into one partition. Bucket the pk by owner + coarse time so writes spread and windows stay queryable:
keyFields: [{name: 'bucket'}, {name: 'ts', type: 'number', width: 13}]
// bucket: 'device-7#2026-07' → one partition per device-monthA month's window is one Query; a quarter is three (fan out and merge client-side — readByKeys/getListByParams per bucket). The width on numeric composite components is load-bearing: zero-padding keeps "9" < "10" true lexicographically (construction throws without it).
Fails when the bucket is too coarse (hot partition returns) or too fine (every read is a fan-out). Size buckets so a hot owner's write rate stays under ~1000 WCU/partition.
Not a pattern so much as a rule the patterns above depend on: any number embedded in a string composite must be fixed-width. The toolkit enforces it — {type: 'number'} in a composite keyFields requires width, and the built-in prepare pads on write. If you compose keys by hand, pad by hand; "item-2" sorting after "item-10" is the classic silent bug.
| Question shape | Pattern |
|---|---|
| "The N% of items that are currently X" | Sparse index by absence |
| "This owner's items in state X, newest first" | Status + timestamp compound |
| "Everything under this tenant / parent" | Multi-tenant prefix · Hierarchy |
| "Who's connected to whom, both directions" | Adjacency list |
| "Events by owner and time window, high write rate" | Write-sharded time series |
| "All records of one type, across partitions" | Sparse GSI on typeField |
Patterns compose: a multi-tenant adjacency list, a sparse review-queue over a hierarchy — the key grammar is the same. The discipline that keeps composites honest: sanitize the separator out of values, zero-pad numbers, and treat any key component as immutable identity (changing it is a move, not an update).
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History