Engineering Notes
Home/DevOps & Cloud/AWS/DynamoDB
DevOps & Cloud · AWS

DynamoDB

DynamoDB is a serverless key-value and document database with single-digit-millisecond latency at any scale, if you design keys from your access patterns. Compare Query vs Scan costs, then build a single-table design with GSIs and streams.

☁️ DevOps & Cloud● Intermediate⏱ 8 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Tables, items, partition and sort keys
  • GetItem, Query vs Scan, and writes with conditions
  • On-demand vs provisioned capacity, hot keys
  • Access-pattern-first single-table design, GSIs and Streams
Part 1Data Model and Access

1What DynamoDB Is

Amazon DynamoDB is a fully managed, serverless key-value and document database with single-digit-millisecond latency at virtually any scale. No servers, no connection limits (HTTP API), automatic replication across 3 AZs, optional global tables across regions. The trade-off: you must design the data model around your access patterns up front — there are no joins and no ad-hoc queries over arbitrary attributes.

2Core Concepts

ConceptMeaning
tablecollection of items (no fixed schema except the key)
itema record, up to 400 KB, with attributes (string, number, binary, bool, list, map, set)
partition key (PK)required; hashed to choose the physical partition
sort key (SK)optional; items with the same PK are stored sorted by SK — enables range queries
primary keyPK alone, or PK + SK (must be unique)
GSI (global secondary index)alternative PK/SK over the same data, eventually consistent, own capacity
LSI (local secondary index)same PK, different SK; must be defined at table creation; 10 GB per PK limit

3Reading: GetItem, Query, Scan

ts
import { DynamoDBDocumentClient, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";

// GetItem: exact primary key → 1 item
await ddb.send(new GetCommand({ TableName: "app", Key: { PK: "USER#42", SK: "PROFILE" } }));

// Query: one partition key, optional sort-key condition → items in SK order (paginated, 1 MB per page)
await ddb.send(new QueryCommand({
  TableName: "app",
  KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
  ExpressionAttributeValues: { ":pk": "USER#42", ":prefix": "ORDER#2025-" },
  ScanIndexForward: false,      // newest first
  Limit: 20,
}));
  • Query is the workhorse: efficient because it reads one partition, ordered by SK.
  • Scan reads the whole table (every partition) — expensive and slow; fine for small tables, backfills or exports, never for request paths.
  • FilterExpression is applied after reading — you still pay for everything read. Design keys so filters are rarely needed.
  • Reads are eventually consistent by default (half the cost); ConsistentRead: true for strongly consistent reads (not available on GSIs).

4Writing

ts
await ddb.send(new PutCommand({
  TableName: "app",
  Item: { PK: "USER#42", SK: "ORDER#2025-06-12#o-9", total: 4599, status: "PLACED" },
  ConditionExpression: "attribute_not_exists(PK)",           // create only (idempotent creates, no overwrite)
}));

await ddb.send(new UpdateCommand({
  TableName: "app",
  Key: { PK: "PRODUCT#7", SK: "STOCK" },
  UpdateExpression: "SET qty = qty - :n",
  ConditionExpression: "qty >= :n",                          // atomic, no oversell (advanced-engineering/…/02-race_conditions)
  ExpressionAttributeValues: { ":n": 2 },
}));
  • Conditional writes give optimistic concurrency (version attributes) and invariants without locks.
  • TransactWriteItems: up to 100 items across tables, all-or-nothing (2× cost).
  • BatchWriteItem / BatchGetItem: up to 25 / 100 items per call, not atomic, retry UnprocessedItems.
  • TTL attribute: items expire automatically (deletion within ~48 h, free) — sessions, idempotency keys, temporary data.

5Capacity Modes

ModeHowUse for
on-demandpay per request (RRU/WRU); scales automaticallyunknown/spiky traffic, new apps
provisionedset RCU/WCU (+ auto scaling); cheaper at steady loadpredictable traffic

1 WCU = one write/s of ≤ 1 KB; 1 RCU = one strongly consistent read/s of ≤ 4 KB (or two eventually consistent). Item size drives cost — keep items small, project only needed attributes into GSIs.

Under the HoodPartitions and Hot Keys▾

DynamoDB hashes the partition key to place items on partitions — storage nodes each holding up to ~10 GB and serving up to about 3,000 RCU and 1,000 WCU per second. Each partition is replicated to three nodes in three AZs; writes are acknowledged after a quorum (two of three) persists them, with a leader per partition handling writes and strongly consistent reads. As data or throughput grows, DynamoDB splits partitions automatically, and adaptive capacity shifts throughput toward busy partitions. What it can't fix is a single hot key: all requests for PK = "GLOBAL_COUNTER" land on one partition, capping it at ~1,000 writes/s regardless of table capacity → ProvisionedThroughputExceededException / throttling. Spread load with high-cardinality partition keys or write sharding (COUNTER#<0..9> and sum on read) — the same idea as sharded counters in databases/caching/08-distributed_caching.

A request's partition key is hashed to one of many partitions replicated across three availability zones; items sharing a partition key are sorted by sort key for efficient queries, while a single hot key saturates one partition's limits
A request's partition key is hashed to one of many partitions replicated across three availability zones; items sharing a partition key are sorted by sort key for efficient queries, while a single hot key saturates one partition's limits

📋 Quick Reference (Part 1)

text
PK (+SK) · item ≤ 400 KB · GetItem (exact) · Query (one PK, SK range, sorted) · Scan (avoid in request paths)
filters cost what they read · eventually consistent default · ConditionExpression for invariants · transactions ≤ 100 items
on-demand vs provisioned · TTL for expiry · hot keys cap at ~1k WCU/3k RCU per partition → shard keys
Part 2Single-Table Design, Indexes and Streams

1Start From Access Patterns

Relational design starts from entities and normalizes; DynamoDB design starts from the list of queries the application will run, then shapes keys so each query is a GetItem or Query.

Example — an e-commerce app:

#Access patternKey design
1get customer profilePK=CUSTOMER#<id>, SK=PROFILE
2list a customer's orders, newest firstPK=CUSTOMER#<id>, SK begins_with ORDER# (SK=ORDER#<iso-date>#<orderId>)
3get an order with its itemsPK=ORDER#<orderId>, SK=META and SK=ITEM#<n> (one Query)
4find an order by id alone(3) covers it
5list orders by status for the warehouseGSI1: GSI1PK=STATUS#<status>, GSI1SK=<iso-date>
6look up a customer by emailGSI2: GSI2PK=EMAIL#<email>

2Single-Table Design

Store different entity types in one table with generic key names (PK, SK, GSI1PK, …) and a type attribute. Items that are read together share a partition key, so one Query returns a whole "aggregate" — the DynamoDB equivalent of a join, precomputed at write time:

text
PK               SK                         type       attributes
CUSTOMER#42      PROFILE                    customer   name, email, GSI2PK=EMAIL#ana@x.io
CUSTOMER#42      ORDER#2025-06-12#o-9       orderref   total, status
ORDER#o-9        META                       order      customerId, status, GSI1PK=STATUS#PLACED, GSI1SK=2025-06-12
ORDER#o-9        ITEM#1                     item       sku, qty, price
ORDER#o-9        ITEM#2                     item       ...

Pros: fewest requests per page, one table to provision/monitor/back up. Cons: harder to read and evolve, requires knowing access patterns, analytics need export (S3/Athena) or streams. Many teams use a pragmatic middle ground: one table per bounded context/service, single-table inside it.

💡 Example: one table, five access patterns
text
PK            SK                          GSI1PK          attributes
CUSTOMER#42   PROFILE                     EMAIL#a@x.io    name, plan
CUSTOMER#42   ORDER#2026-10-01#o9         STATUS#PLACED   total
ORDER#o9      ITEM#1                      -               sku, qty

1. profile          → GetItem PK=CUSTOMER#42 SK=PROFILE
2. recent orders    → Query PK=CUSTOMER#42 SK begins_with ORDER# (desc)
3. order + items    → Query PK=ORDER#o9
4. by email         → Query GSI1 PK=EMAIL#a@x.io
5. placed orders    → Query GSI1 PK=STATUS#PLACED

3Secondary Indexes

  • GSI: any attribute as partition/sort key; sparse — only items that have the GSI key attributes appear (great for "orders awaiting shipment": only those items carry GSI1PK).
  • Projections: KEYS_ONLY, INCLUDE (chosen attributes) or ALL — every projected byte is written again (cost).
  • GSIs are updated asynchronously (eventually consistent) and can throttle the base table if under-provisioned.
  • Up to 20 GSIs per table; adding one later triggers a backfill.

4Patterns

  • Adjacency list for many-to-many (users ↔ groups): items USER#1 / GROUP#7 and the inverse via a GSI.
  • Composite sort keys for hierarchical queries: SK = COUNTRY#DE#CITY#Berlin#STORE#12 → begins_with at any level.
  • Version history: SK = v0 (latest) + v1, v2… written in a transaction.
  • Idempotency / dedup items with TTL.
  • Write sharding for hot keys (Part 1 Under the Hood).

5DynamoDB Streams

A stream records every item-level change (new/old images) for 24 hours, ordered per partition key. Consumers (Lambda event source mapping, Kinesis adapter) build:

Global tables use streams internally for multi-region, multi-active replication with last-writer-wins conflict resolution (databases/scaling/04-regional_partitioning).

6When DynamoDB Is the Wrong Choice

  • Ad-hoc querying, reporting, complex joins → relational DB or analytics (export to S3 + Athena).
  • Unknown or rapidly changing access patterns early in a product.
  • Large items (> 400 KB) → store in S3, keep a pointer.
  • Strong multi-item consistency across many entities beyond transactions' limits.
Under the HoodHow Global Secondary Indexes Are Maintained▾

A GSI is physically another partitioned table. When you write an item, DynamoDB's request router first commits the write to the base table's partition (quorum across AZs), then asynchronously propagates the change to each GSI whose key attributes are present — inserting the new projection and deleting the old one if the GSI key changed. Propagation is usually milliseconds but not transactional, which is why GSI reads are always eventually consistent and why a read-after-write via a GSI can miss a just-written item. If a GSI's partition is throttled (hot GSI key, insufficient provisioned WCU), DynamoDB applies back-pressure to the base table writes so the index can't fall arbitrarily behind — a classic surprise where "the table" throttles because of one badly designed index.

Single-table design stores customer profiles, order references, orders and order items under shared partition keys so one query returns an aggregate; sparse GSIs serve lookups by status and email; streams feed Lambda for read models and events
Single-table design stores customer profiles, order references, orders and order items under shared partition keys so one query returns an aggregate; sparse GSIs serve lookups by status and email; streams feed Lambda for read models and events

⚠️ Common Pitfalls (Part 2)

  1. Designing tables like relational schemas — one table per entity and "joins" in code.
  2. Scans + filters in request paths.
  3. Low-cardinality partition keys (status, country) — hot partitions.
  4. Projecting ALL into every GSI — doubled write costs.
  5. Assuming GSI reads are consistent right after writes.
  6. No plan for analytics — use exports/streams, not table scans.

📋 Quick Reference (Part 2)

text
list access patterns → design PK/SK/GSIs so each is Get/Query
single-table: generic keys, type attr, co-locate data read together
GSI: sparse, projections, eventually consistent, can throttle base table
streams: 24 h change log → Lambda → read models / EventBridge / aggregates · global tables = multi-region

🧠 Key takeaways

  • Items are addressed by partition key (+ sort key); Query reads one partition in sort order, Scan reads everything.
  • Use conditional writes for idempotency and optimistic locking; transactions for multi-item atomicity.
  • Choose on-demand for spiky/unknown load, provisioned + autoscaling for steady; avoid hot partition keys.
  • Design from access patterns: single-table with generic keys, GSIs for alternate lookups, Streams for change events.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How does DynamoDB partition data?core▾

Items are distributed across partitions by the hash of the partition key; a sort key orders items within a partition. Each partition has throughput limits (~3,000 RCU / 1,000 WCU), so keys must spread load evenly.

Follow-ups interviewers ask
  • What's a hot partition? A key receiving disproportionate traffic, causing throttling.
Q2How do you model data in DynamoDB?core▾

Start from access patterns, design keys so each pattern is a GetItem or Query on one partition, use composite sort keys (ORDER#2024-01-05), GSIs for alternate access patterns, and often single-table design with several entity types.

Follow-ups interviewers ask
  • Why avoid Scan? It reads the whole table: slow and expensive.
Q3GSI vs LSI?core▾

GSI: different partition and sort key, eventually consistent, own capacity, can be added anytime. LSI: same partition key, different sort key, strongly consistent reads possible, must be created with the table, 10 GB per partition key limit.

Follow-ups interviewers ask
  • Which is more common? GSIs.
Q4On-demand vs provisioned capacity?senior▾

On-demand: pay per request, instant scaling, good for unpredictable traffic. Provisioned: set RCU/WCU with auto scaling, cheaper for steady predictable load, can throttle on sudden spikes.

Follow-ups interviewers ask
  • Switching limits? You can switch modes periodically (limited frequency).
Q5How do transactions and consistency work in DynamoDB?core▾

Reads are eventually consistent by default, strongly consistent on request (not for GSIs). TransactWriteItems gives ACID across up to 100 items. Conditional writes enable optimistic locking and uniqueness checks.

Follow-ups interviewers ask
  • How do you enforce unique emails? A separate item keyed by email written with a condition in a transaction.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Which operation reads only the items for one partition key?
Scan reads the whole table.
2. A filter expression on a Scan reduces…
You pay for what's read.
3. How do you prevent overwriting an item that changed since you read it?
Optimistic concurrency.
4. Why start from access patterns?
Data modeling is query-driven.