05 · DynamoDB Deep Dive¶
The Level 1 capstone used DynamoDB with a single-attribute key just to store and fetch notes by ID. That barely scratches what DynamoDB is built for. This module covers composite keys, secondary indexes (the main tool for querying by something other than the primary key), capacity modes, and streams — the concepts you need to actually model an application's data in DynamoDB rather than just using it as a key-value store.
Core concepts¶
| Concept | What it is |
|---|---|
| Partition key | The attribute DynamoDB hashes to decide which physical partition stores an item. |
| Sort key | An optional second key attribute; items sharing a partition key are ordered by it, enabling range queries. |
| GSI (Global Secondary Index) | An alternate partition/sort key over the same table, queryable independently, with its own eventually-consistent copy of the data. |
| LSI (Local Secondary Index) | An alternate sort key using the same partition key, created only at table creation, supporting strongly consistent reads. |
| Capacity mode | PAY_PER_REQUEST (on-demand, pay per read/write) vs. PROVISIONED (reserve RCU/WCU capacity, cheaper at steady high volume). |
| Stream | An ordered, near-real-time log of item-level changes a table can emit, consumable by Lambda or Kinesis. |
Composite key table design¶
aws dynamodb create-table \
--table-name training-orders \
--attribute-definitions \
AttributeName=customerId,AttributeType=S \
AttributeName=orderId,AttributeType=S \
AttributeName=orderStatus,AttributeType=S \
AttributeName=createdAt,AttributeType=S \
--key-schema \
AttributeName=customerId,KeyType=HASH \
AttributeName=orderId,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--global-secondary-indexes '[{
"IndexName": "byStatusAndDate",
"KeySchema": [
{"AttributeName": "orderStatus", "KeyType": "HASH"},
{"AttributeName": "createdAt", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}]'
customerId (partition) + orderId (sort) means "all orders for one
customer" is a single efficient query — items sharing a partition key are
stored together and range-queryable by sort key. Only attributes actually
declared in --attribute-definitions can be used as a key or index key;
every other attribute (e.g. total, items) is schemaless and just goes
in the item.
Writing and querying items¶
aws dynamodb put-item \
--table-name training-orders \
--item '{
"customerId": {"S": "cust#42"},
"orderId": {"S": "order#1001"},
"orderStatus": {"S": "PLACED"},
"createdAt": {"S": "2026-08-01T10:00:00Z"},
"total": {"N": "42.50"}
}'
# All orders for one customer (uses the base table's key schema directly)
aws dynamodb query \
--table-name training-orders \
--key-condition-expression "customerId = :c" \
--expression-attribute-values '{":c": {"S": "cust#42"}}'
# All PLACED orders across all customers, newest first (uses the GSI)
aws dynamodb query \
--table-name training-orders \
--index-name byStatusAndDate \
--key-condition-expression "orderStatus = :s" \
--expression-attribute-values '{":s": {"S": "PLACED"}}' \
--scan-index-forward false
Without the GSI, "all orders in PLACED status" would require a full-table
scan (reading every item and filtering) — the GSI turns it into an
efficient, targeted query. Designing GSIs around your app's actual
access patterns, before you need them, is the core skill of DynamoDB data
modeling.
Batch operations¶
aws dynamodb batch-write-item --request-items file://batch-orders.json
# batch-orders.json: {"training-orders": [{"PutRequest": {"Item": {...}}}, ...]}
aws dynamodb update-item \
--table-name training-orders \
--key '{"customerId": {"S": "cust#42"}, "orderId": {"S": "order#1001"}}' \
--update-expression "SET orderStatus = :s" \
--expression-attribute-values '{":s": {"S": "SHIPPED"}}' \
--return-values ALL_NEW
batch-write-item handles up to 25 items per call and does not
support conditional expressions — use individual put-item/update-item
calls when you need "only if this doesn't already exist" logic.
Consistency: eventually consistent by default¶
# Default read — eventually consistent, cheaper
aws dynamodb get-item \
--table-name training-orders \
--key '{"customerId": {"S": "cust#42"}, "orderId": {"S": "order#1001"}}'
# Strongly consistent — guarantees you see the latest write, costs 2x the RCU
aws dynamodb get-item \
--table-name training-orders \
--key '{"customerId": {"S": "cust#42"}, "orderId": {"S": "order#1001"}}' \
--consistent-read
GSI queries are always eventually consistent — there is no
--consistent-read option for a GSI, because the index is itself an
asynchronously-updated copy of the base table. A write followed
immediately by a GSI query can, rarely, not yet reflect that write; the
base table (or an LSI) supports strongly consistent reads because it's the
authoritative copy.
Switching capacity modes¶
aws dynamodb update-table \
--table-name training-orders \
--billing-mode PROVISIONED \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
aws dynamodb update-table \
--table-name training-orders \
--billing-mode PAY_PER_REQUEST
PAY_PER_REQUEST is the right default for unpredictable or low/spiky
traffic (including everything in this course) — you pay per read/write
with no capacity to plan. PROVISIONED (optionally with auto scaling on
top) becomes cheaper once traffic is steady and high enough that reserved
capacity costs less than paying per-request for the same volume.
Enabling streams and TTL¶
aws dynamodb update-table \
--table-name training-orders \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
aws dynamodb update-time-to-live \
--table-name training-orders \
--time-to-live-specification "Enabled=true,AttributeName=expiresAt"
A stream (consumed by a Lambda trigger, similar to module 1's ECS trigger
wiring) can react to every insert/update/delete — e.g. sending an SNS
notification (previous module) whenever orderStatus changes. TTL
auto-deletes items once their expiresAt (a Unix epoch number attribute)
passes — deletion happens within the following 48 hours typically, not
instantly, and doesn't consume write capacity.
A poorly chosen partition key creates a hot partition
DynamoDB spreads throughput across partitions based on the partition
key's distribution. A key like orderStatus alone (few distinct
values, uneven access) concentrates traffic on very few partitions and
throttles even though the table's overall capacity looks sufficient.
Prefer high-cardinality keys (like customerId) for the base table,
and push low-cardinality access patterns into a GSI instead.
Cheat sheet¶
| Command | Purpose |
|---|---|
aws dynamodb create-table --key-schema HASH+RANGE --global-secondary-indexes [...] |
Create a table with a composite key and a GSI. |
aws dynamodb query --key-condition-expression ... |
Efficient, key-based read (base table or an index). |
aws dynamodb query --index-name NAME |
Query via a GSI/LSI instead of the base table. |
aws dynamodb batch-write-item --request-items file://F |
Write up to 25 items in one call (no conditions). |
aws dynamodb get-item --consistent-read |
Force a strongly consistent read (base table/LSI only). |
aws dynamodb update-table --billing-mode MODE |
Switch between on-demand and provisioned capacity. |
aws dynamodb update-table --stream-specification StreamEnabled=true,... |
Enable a change stream. |
aws dynamodb update-time-to-live |
Enable automatic item expiry. |
How It Actually Works¶
DynamoDB's performance guarantees come directly from how it partitions
data: every item's partition key is hashed, and that hash determines
which physical storage partition holds the item — there's no query planner
scanning an index, a GetItem by key is essentially "hash the key, jump
straight to the partition holding it." This is exactly why DynamoDB
punishes poor key design so harshly: if most of your traffic hits a small
number of partition key values (a "hot partition"), that traffic is
concentrated onto one physical partition's fixed throughput allocation no
matter how much total provisioned capacity your table has — the other
partitions sit idle while one gets throttled.
Provisioned/on-demand capacity is enforced per-partition, not table-wide: DynamoDB automatically splits partitions as a table grows (by size or sustained throughput), redistributing key-hash ranges across more physical partitions — but a sudden traffic spike to one key can outrun that adaptive splitting, which is the real mechanism behind DynamoDB throttling you even when your table-level "capacity" metric looks fine.
Global Secondary Indexes aren't computed at query time — they're maintained as a separate, asynchronously-updated copy of your data reorganized under a different key, updated via DynamoDB's internal replication stream after every write to the base table. That asynchrony is why a GSI can lag milliseconds behind the base table under load (eventual consistency by default), and why a GSI needs its own provisioned throughput — you're paying for a second, independently-partitioned dataset being kept in sync, not a free index on top of one.
Exercise¶
- Create a table modeling orders with
customerId(partition) +orderId(sort), plus a GSI onorderStatus+createdAt. - Insert at least 5 orders across 2 customers and a mix of statuses.
- Query "all orders for one customer" against the base table, and "all orders in PLACED status, newest first" against the GSI — confirm each returns only the expected items.
- Update one order's status with
update-itemand immediately re-query the GSI; note (in your own words) why the result is not guaranteed to reflect the update instantly. - Enable a stream with
NEW_AND_OLD_IMAGES, then useaws dynamodbstreams describe-streamandaws dynamodbstreams get-records(via a shard iterator) to observe a raw change record after anotherupdate-item. - Enable TTL on an
expiresAtattribute, insert one item with a past timestamp, and note it should disappear fromscanresults within the next day without you deleting it.