AWS – DynamoDB: Keys, Indexes and Access Patterns

September 17, 20245 min readUpdated 8/24/2026

DynamoDB is a key-value store that gives you single-digit millisecond reads at any scale, with no servers and no connection pool. It buys that by removing the thing you rely on in SQL: the ability to ask a question you did not plan for.

So the design process runs backwards. In Postgres you model the data and write queries later. In DynamoDB you list the queries first, then build a table that answers them.

Keys

Every item is found by its primary key, which is one of two shapes:

  • Partition key alone. A pure lookup — give the key, get one item
  • Partition key plus sort key. The partition key selects a group; the sort key orders within it. This is what makes range queries possible

The partition key decides which physical partition the item lives on, so it also decides how your load spreads. The sort key is what lets you say "the ten most recent bookings for this property" in one request.

Query is the operation. Scan is a bug.

Query takes a partition key and reads only that partition — fast, and priced by what it returns. Scan reads the entire table and filters afterwards, so it is priced by what it reads, and it gets slower and more expensive every day the table grows.

aws dynamodb query \
  --table-name Bookings \
  --key-condition-expression "PK = :pk AND begins_with(SK, :prefix)" \
  --expression-attribute-values '{":pk":{"S":"PROPERTY#4417"},":prefix":{"S":"BOOKING#"}}' \
  --limit 10 --no-scan-index-forward

--no-scan-index-forward reverses the sort order, which is how you get "most recent" without reading everything first.

The trap worth naming: --filter-expression looks like a WHERE clause and is not. It is applied after items are read, so you are billed for every item examined even though you receive few. A scan with a filter that returns three items from a million-item table costs the million.

Single-table design, briefly

DynamoDB has no joins. The usual answer is to put several entity types in one table and shape the keys so related items sit next to each other:

PKSKItem
PROPERTY#4417METAThe property
PROPERTY#4417BOOKING#2026-08-24#9912A booking
PROPERTY#4417REVIEW#2026-07-02#331A review

One Query on PROPERTY#4417 now returns the property and all its bookings and reviews together — the equivalent of a join, in one request, at partition speed. Sort keys are compared as strings, which is why dates are written YYYY-MM-DD: that is the format where lexical order and chronological order agree.

It is a genuinely harder model to read and refactor. Use it when access patterns are settled and the request count matters; two simple tables are often the better trade.

Indexes, and the one you cannot undo

GSILSI
Partition keyAny attributeSame as the table's
CreatedAny timeOnly at table creation
ConsistencyEventual onlyStrong available
CapacityIts ownShared with the table

An LSI cannot be added later. Not by an update, not by a migration — the table must be recreated and the data moved. Given that, and that a GSI can do almost everything an LSI can, default to GSIs and treat LSIs as a specialist choice made deliberately on day one.

A GSI also costs writes: every write to the table that touches an indexed attribute is a second write to the index, billed separately. Project only the attributes you need.

On-demand or provisioned

On-demand charges per request, scales instantly, and costs nothing when idle. Provisioned reserves capacity units per second and is roughly six times cheaper per request at steady load.

Start on-demand. It is the right answer for anything new, unpredictable, or small, and it removes an entire class of throttling problems. Move to provisioned with autoscaling once traffic is predictable enough that you can see the saving in a bill.

The hot partition

Throughput is spread across partitions, so a partition key with low cardinality concentrates traffic on one of them. The symptom is ProvisionedThroughputExceededException while the table's own metrics look comfortable — you have exhausted one partition, not the table.

A key of "2026-08-24" puts an entire day's writes in one place. A key of "ACTIVE" is worse. Choose something with many distinct values — a user id, a property id — and if the natural key really is low-cardinality, append a suffix to spread it and query across the suffixes.

Reads, consistency and cost

Reads are eventually consistent by default, which means a read immediately after a write may return the old value. Strongly consistent reads cost twice as much and are not available on a GSI at all.

Also worth knowing: writes are billed per KB of item size, and an update to one attribute is billed against the whole item. Large items with a frequently-updated counter are an expensive shape; split the counter into its own item.

Streams

aws dynamodb update-table --table-name Bookings \
  --stream-specification "StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES"

A stream emits every change in order, and a Lambda can consume it. This is how you keep a search index in sync, publish events, or maintain an aggregate — without your write path knowing about any of it. It is the closest thing DynamoDB has to a trigger, and it is the answer to most "but I also need to…" requirements.

Records are retained for 24 hours, and ordering is guaranteed per partition key rather than across the table. Both matter: a consumer broken for a day loses data, and a design that assumes global ordering is assuming something the stream does not offer.

Deleting data on a schedule

DynamoDB has a built-in TTL. Name an attribute holding a Unix timestamp and items are removed once it passes:

aws dynamodb update-time-to-live --table-name Sessions \
  --time-to-live-specification "Enabled=true,AttributeName=expiresAt"

Deletion is free, which makes it much cheaper than scanning for old items and deleting them. It is also not prompt — AWS deletes typically within 48 hours of expiry, not at it. So TTL is right for housekeeping and wrong for anything where the data must be gone by a deadline, and any query that must not see expired items needs to filter on the timestamp itself.