Skip to content

Key expression patterns

Eugene Lazutkin edited this page Jul 17, 2026 · 1 revision

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';

Sparse index by absence

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.

Status + timestamp compound sort key

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.

Reverse timestamp

SQL: ORDER BY created_at DESC — but inside a begins_with prefix 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 lexicographically

Fails 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.

Multi-tenant prefix

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#0acme#7) or promote it to its own table.

Adjacency list (graph edges)

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.

Write-sharded time series

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-month

A 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.

Zero-padded numbers in composites

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.

Choosing between them

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).

Clone this wiki locally