Skip to content

The cost model

Keyway estimates the capacity units each write consumes, computed from the item's size and the indexes it touches, so you can see why a write costs what it does. It follows DynamoDB's documented formulas, but it is a model-based estimate, not your actual bill. The math lives in src/engine/itemsize.ts (sizes and units) and src/engine/cost.ts (per-op, per-index effects).

Item size

An item's capacity-relevant size is the sum, over its attributes, of the UTF-8 byte length of the name plus the value:

text
itemSize(item) = Σ  utf8Len(name) + utf8Len(value)

Notes that keep this honest:

  • Bytes are counted as UTF-8, not JavaScript .length (which counts code units), so multi-byte characters cost their real size.
  • Every value is sized as a DynamoDB String (S): its UTF-8 byte length. The model is flat and all-strings today; Number/Binary/Set/Map sizing lands with typed attributes.
  • The 100-byte per-item overhead from the AWS docs is storage overhead, not capacity, so it's excluded here. The 400 KB hard item limit is tracked as MAX_ITEM_BYTES.

Write capacity (WCU)

text
WCU = ceil(size / 1KB) × (1 standard | 2 transactional)      # min 1
  • 1 WCU per 1 KB, rounded up, minimum 1.
  • A transactional write bills double: its base writes cost 2×.

Read capacity (RCU)

text
RCU = ceil(cumulativeBytes / 4KB) × (0.5 eventual | 1 strong | 2 transactional)   # units min 1
  • 1 RCU per 4 KB, rounded up, minimum 1 unit.
  • Eventually consistent reads are half; strong is full; transactional is double.
  • For a Query/Scan, pass the cumulative size of every item read: DynamoDB rounds the total once, not per item. This is why a filter that drops most rows still leaves the cost high: cost is computed from what was read, not what was returned (see Filters).

Index effects: what a write does to each index

Every write is priced per index by its transition, not a snapshot. The vocabulary (IndexEffect):

EffectMeaningWrites
nonethe write doesn't touch this index (no key before or after)0
insertthe item enters the index for the first time
deletethe item leaves the index (a key attr removed, or the row deleted)
updatethe item stays put, but a projected attribute changed
reindexthe index key changed → delete the old projection and put a new

A GSI write is sized by the projected item, so a KEYS_ONLY or INCLUDE index that carries less data costs less. An update only fires when an attribute the index actually projects changed: a KEYS_ONLY GSI ignores changes to attributes it doesn't carry.

Why a key change is a 2-write reindex

DynamoDB can't move a projected row to a new key in place. When an index key changes, it deletes the old projection and puts a new one: two writes on that index. In Keyway this is the reindex effect, and it's the classic cost that bites people: flipping GSI1PK=STATUS#pending to STATUS#shipped is a 1-write update on the base table but a 2-write reindex on GSI1.

Transactional writes bill base at 2×

A repeated label with a new base key becomes an atomic TransactWriteItems (a delete-old + put-new, see the DSL). For a transact:

  • Base writes are billed at 2× (the transactional rate). So an atomic key rename is 2 × (delete + put) = 4 base WCU: the price of doing it safely instead of as two racy writes.
  • GSI maintenance is billed at the standard rate even inside a transaction, because index propagation is asynchronous and outside the transaction's guarantee.

Multi-key GSI query rules

A native multi-key GSI has up to 4 partition and 4 sort attributes as separate columns. When you query one:

  • Every partition attribute must be supplied with equality.
  • Only the last sort attribute may take a range; every earlier sort attribute must be =.
  • No skipping sort attributes: conditions apply as a left-to-right prefix.
text
@gsi ByRegion pk=tenant,region sk=status,date

@ap Open orders for a tenant+region -> ByRegion tenant=acme region=us status=open
#   tenant= and region= are equality (both partition attrs); status= is the
#   first sort attr as equality; add `date between …` as the last, rangeable one.

Break a rule and the query returns a naming error (e.g. "only the last sort key (date) can use a range; status must be =") rather than misbehaving: the same error that flags a pattern invalid under coverage.

A personal project. Opinions are my own, not those of AWS or Amazon. Amazon DynamoDB, AWS, and NoSQL Workbench are trademarks of Amazon.com, Inc. or its affiliates.