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:
| PK | SK | Item |
|---|---|---|
PROPERTY#4417 | META | The property |
PROPERTY#4417 | BOOKING#2026-08-24#9912 | A booking |
PROPERTY#4417 | REVIEW#2026-07-02#331 | A 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
| GSI | LSI | |
|---|---|---|
| Partition key | Any attribute | Same as the table's |
| Created | Any time | Only at table creation |
| Consistency | Eventual only | Strong available |
| Capacity | Its own | Shared 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.