Skip to content

Key and field design

Eugene Lazutkin edited this page Jul 17, 2026 · 2 revisions

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:

  1. The pk choice determines how your data splits across partitions. Every other design decision flows from that.
  2. 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.
  3. 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.

Partition-key selection

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.

Rules that actually matter

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

Common good choices

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

Common bad choices

  • status, type, tier — handful of distinct values, writes concentrate on whichever status most items move through first. Use a GSI on status with the natural id as the sk instead.
  • Monotonic IDs (createdAt timestamp, 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 = 1 pattern" below.

The constant = 1 pattern — narrow-band honest discussion

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 ProvisionedThroughputExceededException on 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 = name gets you the same Query shape without boxing in the base table.
  • For actual reference data (US states, ISO currency codes, locale strings), the -t=1 pattern 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.

Sort-key selection

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.

When you need a sort key

  • Multiple items per partition. One user has many orders. Pk = userId; sk = orderId (or createdAt) 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.

When you don't need one

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

Common sort-key patterns

  • Natural ordering fieldcreatedAt for chronological; name for alphabetical.
  • Structural keystate|facility|vehicle joined with a separator, zero-padded on numeric components (the toolkit's structuralKey declaration). Supports begins_with(sk, 'TX|Dallas|') subtree queries.
  • Composite status sortstatus#createdAt. Query by begins_with(sk, 'active#') returns active items in chronological order; extends to begins_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: true trick, mostly obsolete now that the toolkit exposes descending Queries directly.

Avoid these

  • 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 / cloneWithOverwrite macros wrap this, but the WCU cost is still double + a transaction).

LSI to augment PK functionality

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.

Three practical uses

  1. Alternative sort order within a partition. Base sorted by createdAt; LSI sorted by name or updatedAt. Same partition, same capacity, different traversal for "show this user's orders alphabetically" vs "newest first."
  2. Sparse per-tier index within a partition. The per-tier LSI recipe — mark only rows of one tier with a _facilityMarker attribute; LSI sk = that attribute; Query the LSI = "facilities under this state, no scan." Costs ~nothing because the index is sparse.
  3. 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.

When LSI is the wrong choice

  • 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 vs GSI, in one line

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.

GSI patterns worth knowing

(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 → BatchGetItem against the base table with the caller's fields projection. 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:
    1. Skip the second hop for common-case reads. "List recent orders (name + timestamp only)" — INCLUDE those two attrs, the GSI is the whole answer.
    2. Filter at the GSI before deciding what to fetch. INCLUDE priority, query status = 'active', apply FilterExpression on priority = 'high', second-hop BatchGet only the winners.
  • Sparse GSI. The GSI only contains items that have all the index-key attributes. Pair with a prepare hook (or the toolkit's indices[*].sparse declarative predicate) that sets the index key conditionally. Right for "list all X" where X is a small subset of items (flagged, archived, pinned).

Technical fields

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.

Naming convention

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.

Checklist before you ship

  1. 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.
  2. 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.
  3. Do you need the sk? If every query is a GetItem, you don't. If you have Query patterns in mind, the sk expresses them.
  4. Do you need an LSI? Decide at CreateTable time — you can't add them later. Budget 1-3 LSIs worth of future flexibility.
  5. 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.
  6. 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.

Related

Clone this wiki locally