-
Notifications
You must be signed in to change notification settings - Fork 0
Key and field design
A guide to picking the right partition key, sort key, secondary indices, and technical fields for a DynamoDB table. This page is advisory, not reference — read Concepts first for the vocabulary (pk, sk, GSI, LSI, projection, structural key, technicalPrefix). What follows is the "what should I actually choose?" layer on top. Two companions go deeper: Key expression patterns (a catalogue of composite-key shapes) and the Hierarchical data walkthrough (a worked end-to-end design).
Three rules you can trust before reading the rest:
- The pk choice determines how your data splits across partitions. Every other design decision flows from that.
- DynamoDB limits partition throughput (3000 RCU / 1000 WCU) and partition size (~10 GB). Plans that look fine on paper fail here. Assume both ceilings are real.
- Secondary indices are additional access patterns, not rescues. A bad base-table key can't be un-bad with a GSI — you just pay twice.
The partition key determines which physical partition an item lives on. DynamoDB hashes the pk; items with the same pk land on the same partition and share its throughput + size budget. Items with different pks distribute across the table.
- High cardinality. The set of distinct pk values should be large enough that no single partition holds a meaningful fraction of your data.
- Write-balanced. Writes against one pk should be roughly uniform over time, not concentrated in one value.
- Natural to the access pattern. The pk answers "how do I look this up?" for the common case. You Query a partition by pk; a wrong pk means every read is a Scan.
-
Stable. Pks can't change after write. If the field you'd pick changes (a user's email, a product's SKU), either use an immutable surrogate (
userId,productId) and expose the mutable field as a GSI, or accept that "renames" require delete-then-post.
| Domain | pk | Why |
|---|---|---|
| User records |
userId (UUID or ULID) |
High cardinality; mutations never change pk. |
| Orders (multi-tenant) |
tenantId (composite with sk on orderId) |
Tenants are the natural query boundary; each tenant gets its own partition budget. |
| Sessions | sessionId |
Unique per session; writes distribute. |
| Vehicles | vin |
Natural unique identifier; industry-wide. |
| Time-series (events) |
deviceId + date bucket (deviceId#2026-04) |
Shards writes across devices and months; avoids a single hot partition for "most recent." |
-
status,type,tier— handful of distinct values, writes concentrate on whichever status most items move through first. Use a GSI onstatuswith the natural id as the sk instead. -
Monotonic IDs (
createdAttimestamp, auto-increment) — every new write targets the same partition. If you must sort globally by creation time, pk the logical owner (tenantId,userId) and use createdAt as the sk (or as a GSI sk). - Boolean flags — two partitions total. Any real dataset fills one to bursting.
-
Constants. See "The
constant = 1pattern" below.
A frequent shortcut: declare a "technical" field (often -t) stamped to 1 on every item, use it as the pk, put the natural identifier (name, id) on the sk. This makes the whole table a single partition, so Query #t = :one returns every item in sort-key order with paging — the closest DynamoDB gets to "SELECT * FROM t ORDER BY name."
When it's fine:
- Total table size stays well under 10 GB. DynamoDB won't split a single-valued pk, so 10 GB is a hard per-table ceiling here.
- Aggregate write traffic stays under 1000 WPS and read traffic under 3000 RPS strongly-consistent (6000 RPS eventually-consistent).
- The "treat the whole table like a list" semantics are worth more than scalability — reference / config / lookup tables fit this.
When it breaks:
- The moment you outgrow any of those ceilings. DynamoDB won't throw "you hit the limit" — it throws
ProvisionedThroughputExceededExceptionon write spikes or serves slower than expected on reads. - When you later want to shard, you've backed yourself into a corner: the whole point of the pattern is that there's nothing to shard on.
How to get the same ergonomics without the ceilings:
- If you need a global sort order, put it on a GSI, not the base pk. GSI with pk =
constantShard(sharded for distribution) + sk =namegets you the same Query shape without boxing in the base table. - For actual reference data (US states, ISO currency codes, locale strings), the
-t=1pattern is honest. Just commit to the ceiling.
The toolkit doesn't forbid it — keyFields: ['-t', 'name'] with a prepare hook that stamps -t: 1 works. Just know what you're buying.
The sort key defines the order of items within a partition. It's optional; single-field keyFields get you a pure key-value store, which is fine when you only ever want to getByKey.
-
Multiple items per partition. One user has many orders. Pk =
userId; sk =orderId(orcreatedAt) gives you "all orders for this user" in one Query. -
Ranges / prefix queries. The sk supports
<,<=,>,>=,BETWEEN,begins_with. If your query is "events for device X between T1 and T2," the sk is timestamp-like. -
Hierarchical data. The structural key pattern packs multiple keyFields into the sk so subtree queries work via
begins_with.
- Every record is addressed by a unique id and you only ever fetch-one-at-a-time.
- The set of items you want to list together isn't natural to partition on. Use a GSI.
-
Natural ordering field —
createdAtfor chronological;namefor alphabetical. -
Structural key —
state|facility|vehiclejoined with a separator, zero-padded on numeric components (the toolkit'sstructuralKeydeclaration). Supportsbegins_with(sk, 'TX|Dallas|')subtree queries. -
Composite status sort —
status#createdAt. Query bybegins_with(sk, 'active#')returns active items in chronological order; extends tobegins_with(sk, 'active#2026-04-')for "active items from April." -
Reverse timestamp —
${Number.MAX_SAFE_INTEGER - createdAtMs}as sk; ascending lexicographic order = descending chronological. Pre-descending: truetrick, mostly obsolete now that the toolkit exposes descending Queries directly.
-
High-cardinality sk per single pk — if one partition would hold hundreds of thousands of sk values (one user with 1M orders), the partition gets hot on reads even if writes are balanced. Shard the pk (
userId#${hash(orderId) % 16}) if the user-centric list view isn't essential. -
Sort keys that change — DynamoDB can't mutate a primary key field in place. Changing the sk means delete-then-post (the toolkit's
move/cloneWithOverwritemacros wrap this, but the WCU cost is still double + a transaction).
A Local Secondary Index shares the base partition key and picks a different sort key. Must be declared at table creation — can't be added later. Max 5 per table. Counts toward the partition's 10 GB ceiling and shares its read/write throughput budget.
-
Alternative sort order within a partition. Base sorted by
createdAt; LSI sorted bynameorupdatedAt. Same partition, same capacity, different traversal for "show this user's orders alphabetically" vs "newest first." -
Sparse per-tier index within a partition. The per-tier LSI recipe — mark only rows of one tier with a
_facilityMarkerattribute; LSI sk = that attribute; Query the LSI = "facilities under this state, no scan." Costs ~nothing because the index is sparse. -
Sparse attribute as pinned / flagged subset. LSI sk =
_pinnedAt(present only on pinned items). Query the LSI partition → only pinned items, in pin-order, within this base partition. Same pattern works for_starredBy,_archivedAt, etc.
- You need the same alternative access pattern across every partition (global "newest orders regardless of user") — that's a GSI, not an LSI.
- You need to add the access pattern after table creation — can't be done with LSI; use a GSI.
- The partition already pushes the 10 GB ceiling — LSIs add to the partition's byte count.
LSI when you already know the partition and need a second sort order within it. GSI when the query spans partitions or needs a different partition key entirely.
(The Concepts page covers GSI mechanics. This section is about the design choices you make when declaring one.)
-
Direct read —
projection: 'all'. The GSI carries every attribute from the base table. Reads from the GSI return fully-hydrated items, no second hop. Cost: every base-table write replicates every attribute to the GSI. Right when base items are small (<1 kB) or the GSI serves most of your reads. -
Indirect read —
projection: 'keys-only'+indirect: true. The GSI carries only the index key + base-table keys. Reads Query the GSI →BatchGetItemagainst the base table with the caller'sfieldsprojection. Cost: tiny GSI writes regardless of base item size; 2-hop reads. Right when base items are large (>~1 kB) or the GSI is sparse (not every base row is indexed). See the Indirect indices page. -
Hybrid —
projection: ['col1', 'col2'](INCLUDE). The GSI carries a declared subset. Two payoffs:- Skip the second hop for common-case reads. "List recent orders (name + timestamp only)" — INCLUDE those two attrs, the GSI is the whole answer.
-
Filter at the GSI before deciding what to fetch. INCLUDE
priority, querystatus = 'active', apply FilterExpression onpriority = 'high', second-hop BatchGet only the winners.
-
Sparse GSI. The GSI only contains items that have all the index-key attributes. Pair with a
preparehook (or the toolkit'sindices[*].sparsedeclarative predicate) that sets the index key conditionally. Right for "list all X" where X is a small subset of items (flagged, archived, pinned).
The inventory of fields beyond your domain data that unlock toolkit features, answer common access patterns, or enable operational patterns. Pick what you need; ignore the rest.
| Field (convention) | Purpose | Toolkit declaration | Notes |
|---|---|---|---|
_v |
Optimistic concurrency counter | versionField: '_v' |
Auto-init on post, auto-bump on every write; patch/delete accept expectedVersion. See Concepts: Optimistic concurrency. |
_createdAt |
First-insert timestamp (immutable) |
createdAtField: '_createdAt' + stampCreatedAtISO / stampCreatedAtEpoch in hooks.prepare
|
Enables asOf scope-freeze on mass ops. The stamp factory writes once, on post; patches and round-tripped reads are untouched. |
_updatedAt |
Last-write timestamp | User prepare hook |
Not auto-managed — stamp in prepare: out._updatedAt = Date.now() unconditionally. |
_ttl |
DynamoDB native TTL (epoch seconds) | User prepare hook + table TimeToLiveSpecification
|
Units matter: DynamoDB TTL takes seconds, not ms. Typical stamp: _ttl: Math.floor((Date.now() + duration) / 1000). Deletes eventually-consistent; not authoritative for read-path gating. |
_kind |
Multi-type table discriminator |
typeField: '_kind' + typeDiscriminator: '_kind' + typeLabels: [...]
|
Auto-stamped to typeOf(item) on full writes when typeField is declared. See Concepts: Type tags. |
_sk |
Structural composite key | structuralKey: '_sk' |
Toolkit joins contiguous-from-start keyFields values, zero-pads numeric components. Required when keyFields.length > 1. |
_search-<field> |
Lowercased mirror for substring filter |
searchable: {field: 1} + searchablePrefix: '_search-'
|
Auto-written on every write whose body carries the source field. ?search= at the REST layer runs over these mirrors. |
_tenantId |
Multi-tenant scoping | User concern — typically in pk or as part of a composite sk | Best handled at the pk/sk level, not as a side attribute; otherwise queries have to FilterExpression on it, which pays for every scanned byte. |
_deletedAt |
Soft-delete marker (sparse-GSI anchor) | User prepare hook |
Pair with a sparse GSI {type: 'gsi', pk: '_deletedAt', sparse: true} to list all deleted rows. Read-path filters _deletedAt === undefined for "live" items. |
_shard |
Anti-hot-partition salt | User concern — composite pk | For workloads that can't naturally distribute: pk = ${naturalKey}#${hash % 16}. Scatter-gather reads query all 16 shards in parallel; toolkit doesn't ship this primitive yet. |
_version-etag |
HTTP ETag for conditional requests | User prepare hook |
Typically md5(item) or a UUID per write. If-Match: <etag> maps to expectedVersion in the Adapter patch call. |
_lastModifiedBy |
Audit who-did-it | User prepare hook, needs request-context plumbing |
Source the actor ID from your auth layer. Pair with _updatedAt for "who changed this and when." |
__adapter__ |
Provisioning descriptor snapshot | descriptorKey: '__adapter__' |
JSON snapshot of adapter declaration at a reserved row. Captures what DescribeTable can't see (marshalling, searchable, filterable). Auto-filtered from list ops. See Concepts: Descriptor record. |
The toolkit's declarative options assume your technical-field prefix is technicalPrefix: '_' (or '-', etc.). Every declared technical field (versionField, createdAtField, structuralKey.name, searchablePrefix, descriptorKey) must start with this prefix — the constructor validates. Benefits:
- Built-in prepare validates incoming user items can't accidentally write over technical fields (rejects at the hook boundary).
-
Built-in revive strips technical fields from read results (except
versionField/createdAtField, which round-trip). -
One-line contract: "anything starting with
_is adapter-managed; anything else is yours."
Pre-technicalPrefix code (and adapters that don't declare it) has to wire the same validation + stripping in hand-rolled prepare / revive hooks. The declaration is the hygienic replacement.
- Is the pk high-cardinality and write-balanced? Estimate items per pk value (median + p99). If p99 is >10k items per partition, reconsider the pk.
- Will the pk hit 10 GB or 1000 WCU? At 10 GB DynamoDB adaptive-splits on natural pk diversity — but only if the pk has enough distinct values to split along. At 1000 WCU you hit throttling. Plan accordingly.
-
Do you need the sk? If every query is a
GetItem, you don't. If you haveQuerypatterns in mind, the sk expresses them. - Do you need an LSI? Decide at CreateTable time — you can't add them later. Budget 1-3 LSIs worth of future flexibility.
-
Every GSI: worth the write cost? Every base write replicates to every GSI (proportional to the projection). If you have 4 GSIs with
projection: 'all', every base write is 5× storage + 5× write capacity. Use'keys-only'+ indirect reads wherever base items are large. - Every technical field you add: is the cost/benefit clear? Each adds bytes per item and complexity to hooks. The toolkit's declarative options amortise this (one line of declaration vs a chunk of hook code); stamps and read-path fields stay yours.
-
Concepts — vocabulary (
pk,sk, GSI, LSI, projection, structural key, technicalPrefix). - Concepts: Why reach for a secondary index — the four patterns secondary indices solve.
-
Adapter: Constructor options — full declaration reference (
keyFields,structuralKey,indices,versionField,createdAtField,typeField,technicalPrefix,searchable,searchablePrefix,descriptorKey). -
Adapter: Hooks — Canned prepare builders —
stampCreatedAtISO,stampCreatedAtEpoch. - Adapter: Indirect indices — keys-only GSI + second-hop read pattern.
-
Recipe: Reservation with auto-release —
versionField+createdAtField+ cleanup sweep in one flow. - Recipe: List records of a tier / Per-tier GSI markers / within a partition — sparse-index patterns for type-based listing.
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