DynamoDB is the piece of the modern stack that most data engineers meet from the outside — as a source they have to drain into a warehouse, a CDC feed they have to consume, or an interview whiteboard they have to model on — long before they ever get to design one themselves. It is a fully managed key-value and document store that gives you single-digit-millisecond reads at any scale, but it earns that speed by refusing to be a relational database: there are no joins, no ad-hoc WHERE clauses that the engine will happily optimize, and no "just add an index later and the slow query gets fast." Every efficient access path has to be designed before the first item is written, because the shape of the partition key and sort key you choose is the shape of every query you will ever be allowed to run cheaply.
This guide is the walkthrough you wished existed the first time someone handed you a DynamoDB table and said "get this into Snowflake by Friday," or the first time an interviewer asked "model a social feed in one table and explain how you'd stream changes to analytics." It opens the box in layers: the data model (how items hash into partitions and what a read or write actually costs), single-table design driven by access patterns instead of entities, the secondary indexes that buy you new query paths, the change log that turns every item mutation into an ordered event, and finally the S3 export path that bridges an operational table into a columnar warehouse without ever scanning it. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the database practice library →, sharpen your modeling with the indexing practice library →, and rehearse the pipeline side on the data-processing practice library →.
On this page
- The DynamoDB data model — keys, items, and capacity
- Single-table design and access patterns
- GSIs and query patterns
- DynamoDB Streams — change data capture
- S3 export and warehouse integration
- Cheat sheet — DynamoDB for data engineers recipes
- Frequently asked questions
- Practice on PipeCode
1. The DynamoDB data model — keys, items, and capacity
The partition key, sort key, and capacity model are the three levers that decide every query you can ever run cheaply
The one-sentence invariant: a DynamoDB table is a distributed hash map whose primary key is either a single partition key or a composite (partition key, sort key) pair — the partition key hashes an item onto one of many physical partitions, the sort key orders items within that partition, and every read or write is billed in capacity units, so the model you get is "O(1) lookup by full key, cheap range scan within a partition, and expensive full-table scan for anything else." Nothing about that sentence is optional. If you internalize it, single-table design, GSIs, and streams all fall out as consequences; if you skip it, you will design a table that works in the demo and melts under a hot partition in production.
The primitives that matter.
- Item. The row equivalent — a collection of attributes, one of which is the partition key. An item is at most 400 KB including attribute names and values. There is no fixed schema beyond the primary key; two items in the same table can have entirely different attributes.
-
Partition key (a.k.a. hash key). The attribute DynamoDB hashes to pick a physical partition. To read or
Queryefficiently you must supply the exact partition key value — there is no "partition keyLIKEprefix." High cardinality and even access distribution are the whole game. -
Sort key (a.k.a. range key). The optional second half of a composite primary key. Items sharing a partition key are stored sorted by sort key, which is what makes range conditions (
begins_with,between,>) cheap. The partition key plus sort key together must be unique. - Capacity units. One RCU buys one strongly-consistent read of up to 4 KB/second (or two eventually-consistent reads); one WCU buys one write of up to 1 KB/second. Everything you do is priced in these units, whether you run on-demand (pay-per-request) or provisioned.
The physical partitioning story interviewers probe.
- DynamoDB spreads items across many storage partitions; each partition tops out around 10 GB of data, 3,000 RCU, and 1,000 WCU. When a table grows or its throughput climbs, DynamoDB splits partitions automatically.
- A hot partition is the classic failure: if 90% of traffic targets one partition key value (say
status = 'ACTIVE'as a key), that single partition's throughput ceiling becomes your table's ceiling, and you get throttling while the table is 99% idle. - Adaptive capacity and burst capacity soften short spikes, but they cannot rescue a fundamentally skewed key design. The fix is always at the key level — add entropy, shard the hot key, or choose a higher-cardinality attribute.
The 2026 reality — on-demand is the default.
- On-demand capacity is the modern default: you pay per read/write request and DynamoDB handles scaling instantly. It is the right call for spiky, unpredictable, or new workloads, and it removes the "did we provision enough?" operational load.
- Provisioned capacity (with auto-scaling) is cheaper at steady, predictable, high volume — you reserve RCU/WCU and optionally buy reserved capacity. Data engineers usually meet provisioned tables on mature, high-throughput services where the cost math has been done.
- Reads have two consistency modes. Eventually-consistent reads are the default and cost half an RCU; strongly-consistent reads cost a full RCU and are not available on GSIs. Knowing which you need per access pattern is a real cost lever.
What interviewers listen for.
- Do you say "I pick the partition key for even distribution and high cardinality" before talking about attributes? — senior signal.
- Do you name the 400 KB item limit and what to do about large blobs (offload to S3, store a pointer)? — required answer.
- Do you distinguish
Query(by partition key) fromScan(whole table) and treatScanas a last resort? — required answer. - Do you reason about RCU/WCU cost rather than assuming reads are free? — senior signal.
Worked example — choosing a partition key that spreads load
Detailed explanation. The single most consequential decision in a DynamoDB table is the partition key. A good one distributes both storage and throughput evenly across partitions; a bad one funnels traffic onto one partition and throttles. Walk through picking a partition key for an IoT telemetry table that ingests readings from millions of devices.
- The access pattern. "Get the last N readings for a given device," and "write a reading as it arrives."
-
The tempting-but-wrong key.
sensor_type(e.g.TEMPERATURE,HUMIDITY) — only a handful of distinct values, so a handful of partitions absorb all writes. Instant hot partition. -
The right key.
device_id— millions of distinct values, so writes and reads spread across the whole partition space. Use the reading timestamp as the sort key so "last N readings" is a cheap descending range scan.
Question. Design the primary key for the telemetry table so that writes distribute evenly and "last N readings for a device" is a single efficient query.
Input.
| Candidate partition key | Distinct values | Distribution | Verdict |
|---|---|---|---|
sensor_type |
~5 | catastrophically skewed | reject |
region |
~20 | skewed | reject |
device_id |
millions | even | accept |
device_id#yyyy-mm |
millions × months | even + time-bounded | accept (write-sharded) |
Code.
import boto3
ddb = boto3.client("dynamodb")
ddb.create_table(
TableName="telemetry",
KeySchema=[
{"AttributeName": "device_id", "KeyType": "HASH"}, # partition key
{"AttributeName": "reading_ts", "KeyType": "RANGE"}, # sort key
],
AttributeDefinitions=[
{"AttributeName": "device_id", "AttributeType": "S"},
{"AttributeName": "reading_ts", "AttributeType": "S"}, # ISO-8601 string sorts lexically = chronologically
],
BillingMode="PAY_PER_REQUEST", # on-demand
)
# Last 10 readings for one device — a single Query, newest first
resp = ddb.query(
TableName="telemetry",
KeyConditionExpression="device_id = :d",
ExpressionAttributeValues={":d": {"S": "device-8f21"}},
ScanIndexForward=False, # descending sort key = newest first
Limit=10,
)
Step-by-step explanation.
-
device_idas the partition key gives millions of distinct hash targets, so writes spread across the entire partition space — no single device can hot-spot the table. -
reading_tsas an ISO-8601 string sort key is stored in lexical order, which for zero-padded ISO timestamps is identical to chronological order. That makes "readings between two times" a native range condition. -
ScanIndexForward=Falsereads the sort key in descending order, soLimit=10returns the ten newest readings without scanning the whole item collection. -
BillingMode=PAY_PER_REQUESTis on-demand — appropriate for telemetry whose volume is spiky and hard to predict. - If a single device ever became hot (e.g. a firmware bug hammering one sensor), the write-sharded key
device_id#yyyy-mmsplits its collection across months; you fan out reads across the small known set of month-shards.
Output.
| Metric |
sensor_type key |
device_id key |
|---|---|---|
| Distinct partitions used | ~5 | millions |
| Write hot-spot risk | severe | negligible |
| "Last N readings" query | Scan + filter | single Query |
| Throughput ceiling | one partition's | whole table's |
Rule of thumb. Pick the partition key for cardinality and even access first; pick the sort key so your most common "give me a range" access pattern is a native Query. If any single key value can attract a large fraction of traffic, shard it with a suffix before you ship.
Worked example — sizing reads and writes in capacity units
Detailed explanation. Data engineers routinely misjudge DynamoDB cost because they assume reads are free. They are not — every read and write is metered. Walk through computing the capacity cost of a realistic access pattern so you can defend a cost estimate in a design review.
- RCU rule. 1 RCU = one strongly-consistent read of ≤ 4 KB per second; eventually-consistent reads are half price (0.5 RCU per 4 KB).
- WCU rule. 1 WCU = one write of ≤ 1 KB per second. A 3.5 KB item costs 4 WCU to write (round up per KB).
- Rounding. Both RCU and WCU round the item size up to the next unit boundary (4 KB for reads, 1 KB for writes).
Question. A service reads a 6 KB item 500 times/second (eventual consistency is fine) and writes a 2.5 KB item 100 times/second. Compute the RCU and WCU demand.
Input.
| Operation | Item size | Rate/sec | Consistency |
|---|---|---|---|
| Read | 6 KB | 500 | eventual |
| Write | 2.5 KB | 100 | standard |
Code.
import math
def rcu(item_kb, reads_per_sec, strongly_consistent):
units_per_read = math.ceil(item_kb / 4) # 4 KB per RCU
if not strongly_consistent:
units_per_read = units_per_read / 2 # eventual = half price
return units_per_read * reads_per_sec
def wcu(item_kb, writes_per_sec):
units_per_write = math.ceil(item_kb / 1) # 1 KB per WCU
return units_per_write * writes_per_sec
read_demand = rcu(6, 500, strongly_consistent=False) # 6KB -> 2 units -> /2 -> 1 * 500
write_demand = wcu(2.5, 100) # 2.5KB -> 3 units * 100
print(read_demand, "RCU") # 500.0 RCU
print(write_demand, "WCU") # 300 WCU
Step-by-step explanation.
- A 6 KB item rounds up to two 4 KB read blocks → 2 units for a strongly-consistent read. Eventual consistency halves that to 1 unit per read.
- At 500 reads/second that is 500 RCU of demand — a real, billable number, not "free."
- A 2.5 KB item rounds up to three 1 KB write blocks → 3 WCU per write.
- At 100 writes/second that is 300 WCU.
- On-demand you simply pay for those request-units; provisioned you must reserve at least this many (plus headroom) or auto-scale, and under-provisioning shows up as
ProvisionedThroughputExceededExceptionthrottling.
Output.
| Access pattern | Per-op units | Rate | Total demand |
|---|---|---|---|
| 6 KB eventual read | 1 RCU | 500/s | 500 RCU |
| 2.5 KB write | 3 WCU | 100/s | 300 WCU |
Rule of thumb. Estimate capacity as ceil(size / block) × rate, halve reads when eventual consistency is acceptable, and remember that fat items multiply cost linearly — a 40 KB item costs 10× the read units of a 4 KB one, which is a strong argument for keeping items lean.
Worked example — the 400 KB item limit and large-attribute offload
Detailed explanation. DynamoDB caps an item at 400 KB. When a natural entity carries a large blob — a document body, an image, a big JSON payload — you must not stuff it into the item. The pattern is to store the blob in S3 and keep only a pointer plus metadata in DynamoDB. Walk through the offload.
-
The symptom.
ValidationException: Item size has exceeded the maximum allowed sizeon write. - The fix. Put the large attribute in S3; store the S3 key, size, and content-type in DynamoDB.
- The bonus. Items stay small → reads are cheaper (fewer RCU) and item collections stay under the 10 GB partition ceiling longer.
Question. Redesign a documents table whose body attribute can exceed 400 KB so writes never fail and reads stay cheap.
Input.
| Attribute | Before | After |
|---|---|---|
doc_id (PK) |
string | string |
title |
string | string |
body |
up to 5 MB text | removed |
s3_key |
— | s3://docs/<doc_id>.txt |
byte_size |
— | number |
Code.
import boto3, json
s3 = boto3.client("s3")
ddb = boto3.client("dynamodb")
def put_document(doc_id, title, body: str):
body_bytes = body.encode("utf-8")
key = f"{doc_id}.txt"
# 1. Large payload -> S3
s3.put_object(Bucket="docs", Key=key, Body=body_bytes,
ContentType="text/plain")
# 2. Small metadata item -> DynamoDB (well under 400 KB)
ddb.put_item(
TableName="documents",
Item={
"doc_id": {"S": doc_id},
"title": {"S": title},
"s3_key": {"S": f"s3://docs/{key}"},
"byte_size": {"N": str(len(body_bytes))},
},
)
Step-by-step explanation.
- The large
bodymoves to S3 under a deterministic key derived fromdoc_id, so the item and its blob stay linked without a separate mapping. - The DynamoDB item now holds only small metadata — a few hundred bytes — so it never approaches the 400 KB ceiling and costs a single RCU to read.
- Read paths that only need
titlenever pay to transfer the megabyte body; read paths that need the body do one extra S3GetObject. - Keeping items small also keeps item collections well under the 10 GB per-partition limit, delaying forced splits.
- The same pattern applies to anything bulky — embeddings, base64 images, denormalized arrays — offload to S3 (or a separate table) and store a reference.
Output.
| Concern | Before (inline body) | After (S3 offload) |
|---|---|---|
| Write success | fails > 400 KB | always succeeds |
| Read cost (metadata) | many RCU | 1 RCU |
| Partition pressure | high | low |
| Blob storage cost | DynamoDB rate | S3 rate (cheaper) |
Rule of thumb. Keep DynamoDB items lean and boring. Anything that can grow past a few KB — especially unbounded text or binary — belongs in S3 with a pointer in the item. Small items are cheaper to read, faster to stream, and friendlier to every downstream pipeline.
Data engineering interview question on the DynamoDB data model
A senior interviewer might ask: "You are given a new DynamoDB table that will store user sessions — hundreds of millions of rows, written constantly, read by user_id, and you must also fetch a single session by its session_id. Design the primary key, choose a capacity mode, and explain how you avoid a hot partition and how you keep read costs bounded."
Solution Using a composite primary key with on-demand capacity
import boto3
ddb = boto3.client("dynamodb")
# Primary key: partition by user_id (high cardinality), sort by session start time.
# Uniqueness of (user_id, started_at) is guaranteed by including session_id in the SK.
ddb.create_table(
TableName="sessions",
KeySchema=[
{"AttributeName": "user_id", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"}, # "2026-09-05T10:04:11Z#sess-abc123"
],
AttributeDefinitions=[
{"AttributeName": "user_id", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "session_id", "AttributeType": "S"},
],
BillingMode="PAY_PER_REQUEST",
GlobalSecondaryIndexes=[{
"IndexName": "gsi_session_id",
"KeySchema": [{"AttributeName": "session_id", "KeyType": "HASH"}],
"Projection": {"ProjectionType": "ALL"},
}],
)
# Access pattern A — recent sessions for a user (cheap range scan, newest first)
ddb.query(
TableName="sessions",
KeyConditionExpression="user_id = :u",
ExpressionAttributeValues={":u": {"S": "user-42"}},
ScanIndexForward=False,
Limit=20,
)
# Access pattern B — one session by its global id (via the GSI)
ddb.query(
TableName="sessions",
IndexName="gsi_session_id",
KeyConditionExpression="session_id = :s",
ExpressionAttributeValues={":s": {"S": "sess-abc123"}},
)
Step-by-step trace.
| Input | Value | Reasoning |
|---|---|---|
| Partition key | user_id |
hundreds of millions of users = even spread, no hot partition |
| Sort key | started_at#session_id |
orders sessions by time; suffix guarantees uniqueness |
| Capacity mode | on-demand | constant, spiky writes; no provisioning guesswork |
| Access A | Query user_id = :u |
one round trip for a user's recent sessions |
| Access B | GSI on session_id
|
direct lookup by the global id without a Scan |
| Read consistency | eventual on the GSI | GSIs are eventual-only; halves RCU cost |
After deployment, writes fan out across the full user_id space so no partition throttles; "recent sessions for a user" is one descending Query bounded by Limit; and "one session by id" is a single-item GSI lookup instead of a full-table Scan. Read cost stays O(items returned), not O(table).
Output:
| Access pattern | Mechanism | Cost |
|---|---|---|
| Recent sessions for a user | Query on base table PK | O(returned) RCU |
| Session by global id | Query on gsi_session_id
|
1 GSI read |
| Write a session | PutItem | 1+ WCU per KB |
| Hot-partition risk |
user_id spread |
negligible |
Why this works — concept by concept:
-
Partition key for spread —
user_idhas enormous cardinality, so DynamoDB's hash distributes writes and reads evenly across partitions; no single value can monopolize a partition's throughput. -
Sort key for ordering — encoding
started_atfirst in the sort key makes "newest sessions" a native descending range scan, and appendingsession_idguarantees the composite key is unique even when two sessions start in the same millisecond. -
GSI for the second access pattern — a lookup by
session_idis impossible on the base table (wrong partition key), so a GSI keyed onsession_idprovides that path without ever scanning the table. - On-demand capacity — constant spiky session traffic is exactly the workload on-demand is built for; you avoid both throttling and over-provisioning, and cost tracks actual usage.
-
Cost — every access is O(items touched), never O(table): a bounded
Queryfor the common path, a single-item GSI read for the by-id path, and one write per session. The eliminated cost is the full-tableScana naive "find session by id" design would have forced.
Database
Topic — database
Database modeling and key-design problems
2. Single-table design and access patterns
Model your access patterns first, then overload one table's partition key and sort key to serve them all
The mental model in one line: single-table design is the deliberate practice of storing many entity types — users, orders, line items — in a single DynamoDB table, using generic key attributes whose values carry entity-type prefixes (USER#123, ORDER#456), so that related items share a partition key and can be fetched together in one Query; it starts not from an entity-relationship diagram but from an exhaustive list of the exact queries the application will run. Data engineers trained on normalized relational schemas find this alien at first — you are effectively pre-joining data at write time — but it is what lets DynamoDB answer complex access patterns with single-digit-millisecond latency and no joins.
The axes that matter.
- Access-pattern-first, not entity-first. In DynamoDB you cannot bolt on an efficient query later. You enumerate every read the app needs — "get user profile," "list a user's orders," "get an order with its line items" — before the schema exists, because the keys must be shaped to serve them.
-
Generic key names. The partition key is literally named
PKand the sort keySK(orpk/sk). Their values are typed strings likeUSER#123andORDER#456, which lets one table hold heterogeneous items. -
Item collections. All items sharing a
PKform an item collection, stored physically together and sorted bySK. A singleQueryon thatPKreturns the whole collection — the user profile and their orders and their addresses — in one round trip. - Denormalization is the point. You duplicate data across items to avoid joins. Consistency across duplicates is maintained at write time (often via transactions or streams), trading write complexity for read speed.
The access-pattern-first workflow.
-
Step 1 — list access patterns. Write them as a table: pattern name, key condition, index. "List orders for a user →
PK = USER#<id> AND begins_with(SK, 'ORDER#')." -
Step 2 — design keys to satisfy each. Every access pattern must map to either a
Queryon the base table or aQueryon a GSI. If a pattern needs aScan, the design is wrong. -
Step 3 — assign entity prefixes. Decide the
PK/SKvalue templates per entity so related items collide into the same partition on purpose. -
Step 4 — validate. Re-walk every access pattern against the key design; confirm none requires a
Scanor aFilterExpressiondoing heavy lifting.
PK/SK overloading — the core technique.
- The same
PKvalue groups related entities:USER#123holds the profile (SK = PROFILE), the user's orders (SK = ORDER#456), and their addresses (SK = ADDRESS#home). -
begins_with(SK, 'ORDER#')filters an item collection to just the orders — a cheap sort-key condition, not a filter over unrelated data. - The adjacency-list pattern models many-to-many relationships (an order belongs to a user; a line item belongs to an order) by making the child's
PKthe parent's identifier.
What interviewers listen for.
- Do you start by listing access patterns, not by drawing entities? — required answer.
- Do you use overloaded generic keys (
PK/SKwith typed prefixes) rather than one table per entity? — senior signal. - Do you explain item collections and "one Query returns the whole aggregate"? — senior signal.
- Do you acknowledge the write-side cost of denormalization and how you keep duplicates consistent? — senior signal.
Worked example — listing access patterns before the schema
Detailed explanation. The discipline that separates a working DynamoDB model from a broken one is writing the access-pattern list first. Walk through building it for a small e-commerce domain with users, orders, and line items.
- The domain. A user places orders; each order has line items. The app never runs ad-hoc analytics against this table — that is the warehouse's job (section 5).
- The patterns. Get a user; list a user's orders; get an order plus its line items; get a single line item.
-
The rule. Each pattern must resolve to a
Query, never aScan.
Question. Enumerate the access patterns and map each to a concrete key condition on a single table.
Input.
| # | Access pattern | Frequency |
|---|---|---|
| 1 | Get user profile | high |
| 2 | List a user's orders | high |
| 3 | Get an order + its line items | high |
| 4 | Get one line item | medium |
Code.
Access-pattern map (single table "app", keys PK / SK)
=====================================================
1. Get user profile
PK = USER#<user_id> SK = PROFILE
2. List a user's orders
PK = USER#<user_id> SK begins_with "ORDER#"
3. Get an order + its line items
PK = ORDER#<order_id> SK begins_with "" (returns ORDER meta + ITEM#* rows)
4. Get one line item
PK = ORDER#<order_id> SK = ITEM#<item_id>
Item shapes
-----------
USER#123 | PROFILE | name, email, tier
USER#123 | ORDER#456 | order_id, total_cents, status (duplicated for pattern 2)
ORDER#456 | ORDER#456 | order_id, user_id, total_cents, status
ORDER#456 | ITEM#1 | sku, qty, price_cents
ORDER#456 | ITEM#2 | sku, qty, price_cents
Step-by-step explanation.
- Pattern 1 stores the profile under
PK = USER#123, SK = PROFILE— a directGetItem. - Pattern 2 stores a lightweight copy of each order under the user's partition (
SK = ORDER#456), sobegins_with(SK, 'ORDER#')lists them in one query. This copy is a deliberate denormalization for read speed. - Pattern 3 stores the authoritative order and its line items under
PK = ORDER#456; a singleQueryon that partition returns the order metadata row plus everyITEM#*row — the whole aggregate in one round trip. - Pattern 4 is a direct
GetItemon(ORDER#456, ITEM#1). - Nothing here requires a
Scanor aFilterExpressionover unrelated items — every pattern is a partition-scopedQueryor a pointGetItem, which is the mark of a correct design.
Output.
| Pattern | Key condition | Operation |
|---|---|---|
| Get user | PK=USER#id, SK=PROFILE |
GetItem |
| List orders | PK=USER#id, begins_with(SK,'ORDER#') |
Query |
| Order + items | PK=ORDER#id |
Query |
| One line item | PK=ORDER#id, SK=ITEM#id |
GetItem |
Rule of thumb. If you cannot write every access pattern as a Query or GetItem against your key design before you create the table, you are not done modeling. A pattern that forces a Scan is a design bug, not a runtime tuning problem.
Worked example — overloading PK/SK with entity prefixes
Detailed explanation. Overloading is the mechanism that lets one table hold many entity types. The PK and SK are generic; their values encode both the entity type and its id. Walk through writing the items and querying an item collection.
-
The convention.
<ENTITY>#<id>for both key parts, plus atypeattribute for clarity and stream filtering. -
The payoff. Related items live in one partition and come back in one query, sorted by
SK.
Question. Write the items for one user with two orders, then fetch the user plus their order list in a single query.
Input.
| PK | SK | type | payload |
|---|---|---|---|
| USER#123 | PROFILE | User | name, email |
| USER#123 | ORDER#456 | OrderRef | total_cents, status |
| USER#123 | ORDER#789 | OrderRef | total_cents, status |
Code.
import boto3
ddb = boto3.resource("dynamodb")
tbl = ddb.Table("app")
# Write the user profile and two order references into one item collection
with tbl.batch_writer() as bw:
bw.put_item(Item={"PK": "USER#123", "SK": "PROFILE",
"type": "User", "name": "Ada", "email": "ada@x.io"})
bw.put_item(Item={"PK": "USER#123", "SK": "ORDER#456",
"type": "OrderRef", "total_cents": 4200, "status": "shipped"})
bw.put_item(Item={"PK": "USER#123", "SK": "ORDER#789",
"type": "OrderRef", "total_cents": 1599, "status": "pending"})
# One Query returns the profile AND both orders (whole item collection)
from boto3.dynamodb.conditions import Key
resp = tbl.query(KeyConditionExpression=Key("PK").eq("USER#123"))
for item in resp["Items"]:
print(item["SK"], item.get("type"))
# PROFILE User
# ORDER#456 OrderRef
# ORDER#789 OrderRef
Step-by-step explanation.
- All three items share
PK = USER#123, so they land in the same item collection, physically co-located and sorted bySK. -
PROFILEsorts beforeORDER#456andORDER#789becauseP<O? No —O<Plexically, so orders sort first; if you want the profile first, prefix it (#PROFILE) or query withScanIndexForwardand handle ordering client-side. The lesson: sort-key value design controls result order. - A single
QueryonPK = USER#123returns the entire collection — one network round trip for the user and all their orders. - To fetch only orders, add
SK begins_with 'ORDER#'; the sort-key condition is evaluated on the index, so you pay only for matching items. - The
typeattribute is not required for querying but is invaluable downstream — stream consumers and the warehouse use it to route and unpack heterogeneous items.
Output.
| Query | Returns | Round trips |
|---|---|---|
PK=USER#123 |
profile + 2 orders | 1 |
PK=USER#123, begins_with(SK,'ORDER#') |
2 orders only | 1 |
GetItem(USER#123, PROFILE) |
profile only | 1 |
Rule of thumb. Design sort-key values as deliberately as partition keys — prefixes give you cheap begins_with filtering and control result ordering. A type attribute on every item pays for itself the moment you build a stream consumer or export.
Worked example — the adjacency-list pattern for relationships
Detailed explanation. Many-to-many and hierarchical relationships (a user has many orders; an order has many items; an item can appear in many orders) are modeled with the adjacency-list pattern: edges are items whose PK is one node and whose SK is the other. Walk through modeling "which users bought a given SKU" alongside "which items are in an order."
-
The relationship.
Order —contains→ Item, and we want both directions eventually (order→items on the base table, sku→orders via a GSI in section 3). -
The pattern. Store an edge item
PK = ORDER#456, SK = ITEM#sku-9for the forward direction.
Question. Model order-to-item edges so that "items in an order" is a base-table query, and set up the key so a later GSI can answer "orders containing a SKU."
Input.
| PK | SK | GSI1PK | GSI1SK |
|---|---|---|---|
| ORDER#456 | ITEM#sku-9 | SKU#sku-9 | ORDER#456 |
| ORDER#456 | ITEM#sku-3 | SKU#sku-3 | ORDER#456 |
| ORDER#789 | ITEM#sku-9 | SKU#sku-9 | ORDER#789 |
Code.
# Edge items carry BOTH the base key (order -> items) and GSI keys (sku -> orders)
edges = [
{"PK": "ORDER#456", "SK": "ITEM#sku-9", "GSI1PK": "SKU#sku-9", "GSI1SK": "ORDER#456", "qty": 2},
{"PK": "ORDER#456", "SK": "ITEM#sku-3", "GSI1PK": "SKU#sku-3", "GSI1SK": "ORDER#456", "qty": 1},
{"PK": "ORDER#789", "SK": "ITEM#sku-9", "GSI1PK": "SKU#sku-9", "GSI1SK": "ORDER#789", "qty": 5},
]
with tbl.batch_writer() as bw:
for e in edges:
bw.put_item(Item=e)
# Base table: items in an order
tbl.query(KeyConditionExpression=Key("PK").eq("ORDER#456") & Key("SK").begins_with("ITEM#"))
# -> sku-9 (qty 2), sku-3 (qty 1)
# (Section 3 will query GSI1: GSI1PK = SKU#sku-9 -> ORDER#456, ORDER#789)
Step-by-step explanation.
- Each edge item encodes the forward relationship in the base key (
PK = ORDER#456, SK = ITEM#sku-9), so "items in order 456" is abegins_with(SK, 'ITEM#')query. - The same edge item also carries
GSI1PK = SKU#sku-9andGSI1SK = ORDER#456. When projected into a GSI, this inverts the relationship, letting you query "orders containing sku-9" — covered in section 3. - Storing both directions on one physical item means a single write maintains both access paths; there is no separate join table to keep in sync.
-
qtyand other edge attributes live on the edge item, exactly where a relational schema would put them on a junction row. - This is how DynamoDB models graphs and many-to-many links without joins: edges are first-class items, and GSIs provide the reverse traversal.
Output.
| Traversal | Where | Query |
|---|---|---|
| Order → items | base table | PK=ORDER#456, begins_with(SK,'ITEM#') |
| SKU → orders | GSI1 | GSI1PK=SKU#sku-9 |
| Edge attributes | on the edge item |
qty, added at write time |
Rule of thumb. Model relationships as edge items that carry both the base key and the GSI key. One write maintains both directions of traversal, and you never build or maintain a separate join table — the adjacency list is the join.
Data engineering interview question on single-table design
A senior interviewer might ask: "Model a single DynamoDB table for a SaaS app with tenants, users within a tenant, and projects owned by users. The app must: get a tenant, list users in a tenant, get a user, and list a user's projects — all as single queries. Show the key design, the item shapes, and the query for 'list users in a tenant.'"
Solution Using PK/SK overloading with item-collection queries
import boto3
from boto3.dynamodb.conditions import Key
tbl = boto3.resource("dynamodb").Table("saas")
# Item shapes (overloaded generic keys PK / SK)
items = [
{"PK": "TENANT#acme", "SK": "TENANT#acme", "type": "Tenant", "plan": "enterprise"},
{"PK": "TENANT#acme", "SK": "USER#u1", "type": "User", "email": "a@acme.io"},
{"PK": "TENANT#acme", "SK": "USER#u2", "type": "User", "email": "b@acme.io"},
{"PK": "USER#u1", "SK": "PROJECT#p10", "type": "Project","name": "Atlas"},
{"PK": "USER#u1", "SK": "PROJECT#p11", "type": "Project","name": "Nova"},
]
with tbl.batch_writer() as bw:
for it in items:
bw.put_item(Item=it)
# Access pattern: list users in a tenant (one item collection, one query)
resp = tbl.query(
KeyConditionExpression=Key("PK").eq("TENANT#acme") & Key("SK").begins_with("USER#")
)
users = [i["email"] for i in resp["Items"]]
print(users) # ['a@acme.io', 'b@acme.io']
Step-by-step trace.
| PK | SK | Entity | Serves access pattern |
|---|---|---|---|
| TENANT#acme | TENANT#acme | Tenant | get a tenant |
| TENANT#acme | USER#u1 | User | list users in tenant |
| TENANT#acme | USER#u2 | User | list users in tenant |
| USER#u1 | PROJECT#p10 | Project | list a user's projects |
| USER#u1 | PROJECT#p11 | Project | list a user's projects |
Walking the query: PK = TENANT#acme selects the tenant's item collection; begins_with(SK, 'USER#') narrows it to just the user items, skipping the tenant metadata row; the result is both users in one round trip. "List a user's projects" is the same shape against PK = USER#u1. Every required access pattern is a single partition-scoped query — no scans, no joins.
Output:
| Access pattern | Key condition | Result |
|---|---|---|
| Get tenant | PK=TENANT#acme, SK=TENANT#acme |
1 item |
| List users in tenant | PK=TENANT#acme, begins_with(SK,'USER#') |
2 users |
| Get user | PK=TENANT#acme, SK=USER#u1 |
1 item |
| List user's projects | PK=USER#u1, begins_with(SK,'PROJECT#') |
2 projects |
Why this works — concept by concept:
-
Overloaded keys —
PKandSKare generic attribute names whose values (TENANT#acme,USER#u1) carry entity type and id, letting tenants, users, and projects coexist in one table without a schema per entity. -
Item collections — co-locating a tenant with its users under
PK = TENANT#acmemeans "list users in a tenant" is a single sort-key-filtered query over a physically contiguous collection. -
begins_with on the sort key — the
USER#andPROJECT#prefixes turn a heterogeneous collection into cheaply filterable sub-lists, evaluated on the index rather than as a post-read filter. -
Access-pattern-first design — every one of the four required reads was mapped to a key condition before the table existed, which is why none of them degrades to a
Scan. -
Cost — each access pattern is O(items returned) on a single partition: one
Queryper read, no cross-partition fan-out, no join. The write-side cost is maintaining the entity-prefixed keys, paid once per item at write time.
Database
Topic — database
Single-table and data-modeling problems
3. GSIs and query patterns
A GSI is a second partition key on the same data — it buys you a new access pattern at the cost of a second, eventually-consistent write
The mental model in one line: a Global Secondary Index (GSI) is an automatically-maintained copy of your table's items, re-keyed on different attributes, so you can Query the same data along a partition key the base table doesn't support — it has its own partition/sort key, its own throughput, its own projected subset of attributes, and it is always eventually consistent, which means every access pattern the base table can't serve directly becomes "add a GSI," bounded by the extra write cost and the propagation lag. GSIs are the single most important tool for turning a rigid key-value store into something that answers many questions, and misunderstanding them (LSI vs GSI, projection cost, sparse indexes) is the most common DynamoDB interview stumble.
GSI vs LSI — the distinction interviewers probe.
-
GSI (Global Secondary Index). Any attributes as its
(partition, sort)key; can be added any time; has its own provisioned/on-demand throughput; is eventually consistent only. Up to 20 per table (soft limit). This is the workhorse. - LSI (Local Secondary Index). Shares the base table's partition key but a different sort key; must be created at table creation and cannot be added later; supports strongly-consistent reads; shares the base table's throughput; subject to a 10 GB item-collection limit. Rarely worth the constraints in 2026.
- The default choice. Reach for a GSI unless you specifically need strong consistency on an alternate sort key within the same partition — then, and only then, consider an LSI, and only if you can create it up front.
Projections — what the GSI copies.
-
KEYS_ONLY. The GSI stores only the index and base keys. Smallest and cheapest; every read that needs more attributes must fetch back from the base table. -
INCLUDE. Keys plus a named list of extra attributes. The right middle ground — project exactly what the access pattern reads. -
ALL. Every attribute is copied into the GSI. Most convenient, most storage, highest write cost; use when the GSI must satisfy reads without touching the base table.
GSI overloading and sparse GSIs.
-
GSI overloading. Just like the base table, name the GSI keys generically (
GSI1PK,GSI1SK) and put typed values in them, so a single GSI serves multiple access patterns across entity types. -
Sparse GSI. An item only appears in a GSI if it has the GSI's key attributes. Omit the GSI key on items you don't want indexed and the index stays small — perfect for "find items in state X" where X is rare (e.g.
open_ticket = true). -
Query vs Scan on a GSI. A GSI is queried exactly like a table —
KeyConditionExpressionon its keys — and you should neverScanit for a targeted read.
What interviewers listen for.
- Do you correctly state "GSIs are eventually consistent, LSIs can be strong"? — required answer.
- Do you know a GSI has its own throughput and an under-provisioned GSI throttles writes to the base table (provisioned mode)? — senior signal.
- Do you use a sparse GSI to make "find the few items in state X" cheap instead of scanning? — senior signal.
- Do you choose a projection deliberately rather than defaulting to
ALL? — senior signal.
Worked example — an inverted-index GSI (sort key becomes partition key)
Detailed explanation. The most common GSI is the "inverted index": take an attribute that is a sort key or a child id on the base table and make it the GSI's partition key, so you can look items up by the other end of the relationship. Walk through inverting the order/item edges from section 2 to answer "which orders contain SKU X."
-
Base table. Edge items
PK = ORDER#456, SK = ITEM#sku-9. -
The question the base table can't answer. "Which orders contain
sku-9?" — that needsskuas a partition key. -
The GSI. Partition by
GSI1PK = SKU#sku-9, sort byGSI1SK = ORDER#456.
Question. Create the GSI and query all orders that contain a given SKU.
Input.
| Base PK | Base SK | GSI1PK | GSI1SK |
|---|---|---|---|
| ORDER#456 | ITEM#sku-9 | SKU#sku-9 | ORDER#456 |
| ORDER#789 | ITEM#sku-9 | SKU#sku-9 | ORDER#789 |
| ORDER#456 | ITEM#sku-3 | SKU#sku-3 | ORDER#456 |
Code.
import boto3
from boto3.dynamodb.conditions import Key
ddb = boto3.client("dynamodb")
# Add an inverted GSI keyed on the SKU
ddb.update_table(
TableName="app",
AttributeDefinitions=[
{"AttributeName": "GSI1PK", "AttributeType": "S"},
{"AttributeName": "GSI1SK", "AttributeType": "S"},
],
GlobalSecondaryIndexUpdates=[{
"Create": {
"IndexName": "GSI1",
"KeySchema": [
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
{"AttributeName": "GSI1SK", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "INCLUDE",
"NonKeyAttributes": ["qty"]},
}
}],
)
# Query: which orders contain sku-9?
tbl = boto3.resource("dynamodb").Table("app")
resp = tbl.query(IndexName="GSI1",
KeyConditionExpression=Key("GSI1PK").eq("SKU#sku-9"))
orders = [i["GSI1SK"] for i in resp["Items"]]
print(orders) # ['ORDER#456', 'ORDER#789']
Step-by-step explanation.
- The base table can query "items in an order" but not "orders containing a SKU," because
skuis only a sort-key fragment, never a partition key. - The GSI re-keys the same edge items on
GSI1PK = SKU#<sku>, so all edges for one SKU collide into a single GSI partition, queryable in one round trip. -
Projection = INCLUDE [qty]copies only the keys plusqtyinto the GSI — enough to answer "which orders, and how many units" without fetching back to the base table. - The GSI is maintained automatically: every base-table write that touches
GSI1PK/GSI1SKpropagates to the GSI asynchronously (eventual consistency, typically well under a second). - Querying by
GSI1PK = SKU#sku-9returns both orders — the inverse traversal the base table could not provide.
Output.
| GSI query | Returns | Consistency |
|---|---|---|
GSI1PK=SKU#sku-9 |
ORDER#456, ORDER#789 | eventual |
GSI1PK=SKU#sku-3 |
ORDER#456 | eventual |
| projection | keys + qty
|
INCLUDE |
Rule of thumb. When you need to query by the "other end" of a relationship, invert it with a GSI: put the child/attribute on GSI1PK and the parent on GSI1SK. Project only the attributes the access pattern actually reads to keep the index small and write-cheap.
Worked example — a sparse GSI for a rare-state filter
Detailed explanation. A sparse GSI indexes only the items that carry the GSI's key attributes, which makes "find the few items in state X" a tiny, cheap query instead of a full-table scan. Walk through indexing only open support tickets out of millions of mostly-closed ones.
-
The problem. 50 million tickets, 99.9% closed. "List open tickets" via
Scan+ filter reads all 50M items. -
The sparse trick. Only open tickets get a
GSI1PK = OPENattribute; closed tickets omit it, so they never enter the GSI. - The payoff. The GSI holds only ~50k open tickets; querying it is proportional to open tickets, not total tickets.
Question. Design a sparse GSI so "list all open tickets" costs O(open), and show how closing a ticket removes it from the index.
Input.
| ticket_id | status | GSI1PK (sparse) |
|---|---|---|
| T-1 | open | OPEN |
| T-2 | closed | (absent) |
| T-3 | open | OPEN |
Code.
import boto3
from boto3.dynamodb.conditions import Key
tbl = boto3.resource("dynamodb").Table("tickets")
# Opening a ticket: set the sparse GSI key
tbl.put_item(Item={"PK": "TICKET#T-1", "status": "open",
"GSI1PK": "OPEN", "GSI1SK": "2026-09-05T09:00Z"})
# Closing a ticket: REMOVE the sparse key so it drops out of the GSI
tbl.update_item(
Key={"PK": "TICKET#T-1"},
UpdateExpression="SET #s = :closed REMOVE GSI1PK, GSI1SK",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":closed": "closed"},
)
# List all open tickets — reads only items in the sparse GSI
open_tickets = tbl.query(IndexName="GSI1",
KeyConditionExpression=Key("GSI1PK").eq("OPEN"))
Step-by-step explanation.
- Only items that possess
GSI1PKappear in the GSI. Open tickets set it to the constantOPEN; closed tickets have no such attribute. - "List open tickets" becomes
Query GSI1 WHERE GSI1PK = 'OPEN', reading only the open tickets — not the 50 million closed ones. - Closing a ticket uses
REMOVE GSI1PK, GSI1SKin the update expression, which deletes the item from the GSI while keeping it in the base table. The item silently drops out of the index. - Because all open tickets share the constant partition key
OPEN, watch for a hot GSI partition if "open" volume is huge; shard the constant (OPEN#<n>) if needed. - The base table still holds every ticket for point lookups by
ticket_id; the sparse GSI is purely the efficient "current open set."
Output.
| Operation | Effect on GSI | Cost of "list open" |
|---|---|---|
| Open ticket | item enters GSI | — |
Close ticket (REMOVE keys) |
item leaves GSI | — |
Query GSI1PK=OPEN
|
reads only open items | O(open), not O(total) |
Rule of thumb. For "find the small set of items currently in state X," use a sparse GSI: write the GSI key only while the item is in that state and REMOVE it when the state ends. You turn an O(table) scan into an O(matching-items) query.
Worked example — GSI overloading to serve multiple patterns from one index
Detailed explanation. Just as the base table's keys are overloaded, a single GSI's generic keys (GSI1PK/GSI1SK) can serve several unrelated access patterns by putting different typed values in them per entity. Walk through one GSI answering both "orders by status" and "users by email domain."
-
The idea. Different entity types write different value templates into the same
GSI1PK. - The payoff. Fewer indexes (each GSI costs storage + write throughput), more access patterns per index.
Question. Use one GSI to answer "orders in status pending" and "users at email domain acme.io."
Input.
| Entity | GSI1PK | GSI1SK |
|---|---|---|
| Order | STATUS#pending | ORDER#456 |
| Order | STATUS#shipped | ORDER#457 |
| User | DOMAIN#acme.io | USER#u1 |
Code.
from boto3.dynamodb.conditions import Key
# Orders currently pending
tbl.query(IndexName="GSI1",
KeyConditionExpression=Key("GSI1PK").eq("STATUS#pending"))
# -> ORDER#456
# Users at a given email domain — SAME index, different value template
tbl.query(IndexName="GSI1",
KeyConditionExpression=Key("GSI1PK").eq("DOMAIN#acme.io"))
# -> USER#u1
Step-by-step explanation.
- Both orders and users write into the same
GSI1PKattribute, but with distinct value namespaces:STATUS#...for orders,DOMAIN#...for users. - A query for
GSI1PK = STATUS#pendingreturns only order items in that status; a query forGSI1PK = DOMAIN#acme.ioreturns only users at that domain. The namespaces never collide. - One physical GSI thus serves two logically-unrelated access patterns, saving the storage and write-throughput cost of a second index.
- When an order ships, updating
GSI1PKfromSTATUS#pendingtoSTATUS#shippedmoves it between GSI partitions automatically — the "status index" stays correct with no extra bookkeeping. - The discipline: keep value namespaces globally unique across entity types so a single overloaded GSI never conflates two patterns.
Output.
| Query | GSI1PK value | Returns |
|---|---|---|
| Orders pending | STATUS#pending |
ORDER#456 |
| Orders shipped | STATUS#shipped |
ORDER#457 |
| Users at domain | DOMAIN#acme.io |
USER#u1 |
Rule of thumb. Overload one GSI across entity types with namespaced key values (STATUS#…, DOMAIN#…) before creating a second GSI. Every extra GSI is extra storage and write amplification; a well-namespaced overloaded index does the work of several.
Data engineering interview question on GSIs
A senior interviewer might ask: "You have a 200-million-row orders table keyed by order_id. Product needs two new reads: 'all orders for a customer, newest first' and 'all orders currently in the refund_pending state.' Design the indexes, choose projections, and explain the consistency and cost implications, including why one of them should be sparse."
Solution Using a customer GSI plus a sparse status GSI
import boto3
from boto3.dynamodb.conditions import Key
ddb = boto3.client("dynamodb")
ddb.update_table(
TableName="orders",
AttributeDefinitions=[
{"AttributeName": "customer_id", "AttributeType": "S"},
{"AttributeName": "created_at", "AttributeType": "S"},
{"AttributeName": "refund_pk", "AttributeType": "S"}, # sparse: present only when refund_pending
],
GlobalSecondaryIndexUpdates=[
{"Create": {
"IndexName": "gsi_customer",
"KeySchema": [
{"AttributeName": "customer_id", "KeyType": "HASH"},
{"AttributeName": "created_at", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "INCLUDE",
"NonKeyAttributes": ["total_cents", "status"]},
}},
{"Create": {
"IndexName": "gsi_refund",
"KeySchema": [{"AttributeName": "refund_pk", "KeyType": "HASH"},
{"AttributeName": "created_at", "KeyType": "RANGE"}],
"Projection": {"ProjectionType": "KEYS_ONLY"},
}},
],
)
tbl = boto3.resource("dynamodb").Table("orders")
# All orders for a customer, newest first
tbl.query(IndexName="gsi_customer",
KeyConditionExpression=Key("customer_id").eq("CUST#42"),
ScanIndexForward=False)
# All orders currently refund_pending (sparse index -> only those items exist here)
tbl.query(IndexName="gsi_refund",
KeyConditionExpression=Key("refund_pk").eq("REFUND_PENDING"))
Step-by-step trace.
| Requirement | Index | Key design | Projection |
|---|---|---|---|
| Orders for a customer, newest first | gsi_customer |
PK customer_id, SK created_at
|
INCLUDE total, status |
Orders in refund_pending
|
gsi_refund (sparse) |
PK refund_pk (const), SK created_at
|
KEYS_ONLY |
| Set refund state | write refund_pk='REFUND_PENDING'
|
item enters sparse GSI | — |
| Clear refund state | REMOVE refund_pk |
item leaves sparse GSI | — |
Walking it: the customer GSI re-keys every order by customer_id, so "orders for CUST#42, newest first" is a descending query returning total_cents and status from the projection without touching the base table. The refund GSI is sparse — only orders with refund_pk set are present — so "list refund_pending" reads a few thousand items, not 200 million; because the projection is KEYS_ONLY, those reads are tiny and the code fetches full order details from the base table only for the handful that need action.
Output:
| Access pattern | Reads | Consistency | Cost |
|---|---|---|---|
| Customer's orders |
gsi_customer query |
eventual | O(customer's orders) |
| Refund-pending list |
gsi_refund query |
eventual | O(pending), not O(table) |
| Full order detail | base-table GetItem | strong (if needed) | 1 read per item |
Why this works — concept by concept:
-
GSI as an alternate partition key — re-keying orders on
customer_idprovides an access path the base table (keyed onorder_id) fundamentally cannot, without duplicating the table by hand. -
Sparse GSI — writing
refund_pkonly while an order is refund-pending keeps that index tiny, converting "find orders in a rare state" from an O(200M) scan into an O(few-thousand) query. -
Projection choice —
INCLUDEon the customer GSI carries just the attributes the list view shows;KEYS_ONLYon the sparse GSI keeps its per-item write cost minimal since it is only a routing index. - Eventual consistency — both GSIs are eventual-only, which is fine for list views; anything needing the authoritative latest value reads the base table with strong consistency.
- Cost — each GSI adds write amplification (one extra write per indexed item) and storage for its projection, but turns two impossible-or-scan access patterns into O(result-size) queries. The sparse index is the key lever: its cost scales with the rare state, not the whole table.
Indexing
Topic — indexing
Secondary-index and query-path problems
4. DynamoDB Streams — change data capture
DynamoDB Streams is a 24-hour ordered change log — the native CDC tap for every item mutation
The mental model in one line: DynamoDB Streams is an ordered, 24-hour-retained log of every item-level change (INSERT, MODIFY, REMOVE) in a table, sharded so that all changes to a given partition key are strictly ordered, and consumable by Lambda triggers or the Kinesis Client Library — it is the mechanism that turns DynamoDB from a data island into a source of truth that fans out to search indexes, aggregate tables, and the analytics warehouse without ever polling or scanning the table. For a data engineer, Streams is the DynamoDB equivalent of a write-ahead log tail: it is how you build materialized views, replicate to other stores, and feed real-time CDC.
The primitives that matter.
-
Stream record. One record per item-level change, carrying the
eventName(INSERT/MODIFY/REMOVE), the item keys, and — depending on the view type — the new and/or old image of the item. - 24-hour retention. Records live for 24 hours, then expire. Consumers must keep up or use the Kinesis Data Streams integration for longer retention (up to 365 days).
- Shards and ordering. The stream is partitioned into shards; all changes for a single partition key go to the same shard and are delivered in order. There is no global ordering across partition keys — only per-key.
- At-least-once delivery. A record can be delivered more than once (retries, resharding), so consumers must be idempotent.
Stream view types — choose what each record carries.
-
KEYS_ONLY. Just the key attributes of the changed item. Smallest; the consumer must read the current item if it needs more. -
NEW_IMAGE. The entire item as it looks after the change. Ideal for replicating current state. -
OLD_IMAGE. The entire item as it looked before the change. Needed to compute deltas or capture what aREMOVEdeleted. -
NEW_AND_OLD_IMAGES. Both — the richest and the usual choice for CDC and aggregation, since you can diff old vs new.
Ordering, delivery, and consumers.
- Lambda trigger. The simplest consumer: DynamoDB invokes your function with batches of records, tracks checkpoints, and retries on failure. Batch size, parallelization factor, and bisect-on-error are the tuning knobs.
- Kinesis adapter / KCL. For fan-out to multiple independent consumers or longer retention, route the stream through Kinesis Data Streams.
- Failure handling. Configure a bisect-on-function-error and an on-failure destination (SQS/SNS DLQ) so one poison record doesn't block a shard forever.
What interviewers listen for.
- Do you name the four view types and pick
NEW_AND_OLD_IMAGESfor CDC/aggregation? — required answer. - Do you state "ordering is per partition key, not global"? — senior signal.
- Do you make consumers idempotent because delivery is at-least-once? — required answer.
- Do you know the 24-hour limit and reach for Kinesis when you need longer retention or multiple consumers? — senior signal.
Worked example — a Lambda stream processor that maintains a materialized aggregate
Detailed explanation. The canonical Streams use case is a materialized view: as orders are written, keep a running per-customer order count and total in a separate aggregate item. Walk through the Lambda that consumes NEW_AND_OLD_IMAGES and updates the aggregate idempotently.
-
The trigger. Table
orderswith Streams on, viewNEW_AND_OLD_IMAGES. -
The target. An aggregate item
PK = CUST#42, SK = AGGholdingorder_countandtotal_cents. -
Idempotency. Because records can redeliver, track processed
SequenceNumbers (or use conditional math) so a replay doesn't double-count.
Question. Write the Lambda handler that increments the customer aggregate on INSERT and decrements on REMOVE, safely under at-least-once delivery.
Input.
| eventName | new image | old image | aggregate effect |
|---|---|---|---|
| INSERT | order, total 4200 | — | count +1, total +4200 |
| MODIFY | total 4500 | total 4200 | total +300 |
| REMOVE | — | order, total 4500 | count -1, total -4500 |
Code.
import boto3
tbl = boto3.resource("dynamodb").Table("orders")
def handler(event, _ctx):
for rec in event["Records"]:
ev = rec["eventName"] # INSERT | MODIFY | REMOVE
seq = rec["dynamodb"]["SequenceNumber"]
new = rec["dynamodb"].get("NewImage", {})
old = rec["dynamodb"].get("OldImage", {})
cust = (new or old)["customer_id"]["S"]
new_total = int(new["total_cents"]["N"]) if new else 0
old_total = int(old["total_cents"]["N"]) if old else 0
d_count = (1 if ev == "INSERT" else -1 if ev == "REMOVE" else 0)
d_total = new_total - old_total # works for all three events
try:
tbl.update_item(
Key={"PK": f"CUST#{cust}", "SK": "AGG"},
UpdateExpression=("ADD order_count :c, total_cents :t "
"SET last_seq = :s"),
ConditionExpression="attribute_not_exists(last_seq) OR last_seq < :s",
ExpressionAttributeValues={":c": d_count, ":t": d_total, ":s": seq},
)
except tbl.meta.client.exceptions.ConditionalCheckFailedException:
pass # already applied this or a newer record -> idempotent skip
Step-by-step explanation.
- Each record carries
eventName, theSequenceNumber, and (withNEW_AND_OLD_IMAGES) both images, so the handler can compute the exact delta for count and total. -
d_total = new_total - old_totalis uniform across all three events: onINSERTold is 0, onREMOVEnew is 0, onMODIFYit is the real difference. - The
ADDupdate atomically increments the aggregate counters — DynamoDB's atomic counters avoid read-modify-write races between concurrent invocations. - The
ConditionExpressiononlast_seqmakes the update idempotent: a redelivered (older-or-equalSequenceNumber) record fails the condition and is skipped, so at-least-once delivery never double-counts. - On
ConditionalCheckFailedExceptionthe handler swallows the error — that is the expected path for a duplicate, not a failure.
Output.
| Event stream | order_count | total_cents |
|---|---|---|
| INSERT 4200 | 1 | 4200 |
| MODIFY 4200→4500 | 1 | 4500 |
| REMOVE 4500 | 0 | 0 |
| (INSERT 4200 redelivered) | 0 | 0 (skipped) |
Rule of thumb. Build stream consumers as idempotent delta-appliers: use atomic ADD for counters, compute deltas from NEW_AND_OLD_IMAGES, and guard with a monotonic SequenceNumber condition so redelivery is a no-op. Never assume exactly-once — DynamoDB Streams is at-least-once.
Worked example — choosing the right stream view type
Detailed explanation. The view type you pick determines both what your consumer can do and how much data crosses the stream. Walk through matching view type to three consumer goals.
-
Replicate current state to OpenSearch. You need the full post-change item →
NEW_IMAGE. -
Maintain deltas / audit before-and-after. You need both images →
NEW_AND_OLD_IMAGES. -
Just invalidate a cache by key. You need only the key →
KEYS_ONLY.
Question. For each consumer, choose the minimal view type that satisfies it and justify the cost trade-off.
Input.
| Consumer goal | Needs new image? | Needs old image? | View type |
|---|---|---|---|
| Replicate to search index | yes | no | NEW_IMAGE |
| Delta aggregate / audit | yes | yes | NEW_AND_OLD_IMAGES |
| Cache invalidation | no | no | KEYS_ONLY |
Code.
import boto3
ddb = boto3.client("dynamodb")
# Enable streams with the richest view for a CDC/aggregation table
ddb.update_table(
TableName="orders",
StreamSpecification={"StreamEnabled": True,
"StreamViewType": "NEW_AND_OLD_IMAGES"},
)
# For a table whose only consumer replicates state, NEW_IMAGE is enough:
# StreamViewType="NEW_IMAGE"
# For a table whose only consumer busts a cache, KEYS_ONLY is cheapest:
# StreamViewType="KEYS_ONLY"
Step-by-step explanation.
-
NEW_IMAGEships the full item after the change — exactly what a search-index replicator needs to upsert the current document, and nothing more. -
NEW_AND_OLD_IMAGESships both, which is mandatory for anything computing a delta (aggregates) or auditing what changed, and it is the only view that tells you what aREMOVEdeleted (via the old image). -
KEYS_ONLYships only the keys — smallest payload — and is perfect when the consumer just needs "this key changed, go invalidate it." - Richer views cost more stream throughput and Lambda payload size, so pick the minimal view that satisfies every consumer of that table.
- If different consumers need different views, either standardize on the richest they collectively require, or route through Kinesis so each consumer reads independently.
Output.
| View type | Payload | Enables |
|---|---|---|
| KEYS_ONLY | keys only | cache invalidation |
| NEW_IMAGE | post-change item | state replication |
| OLD_IMAGE | pre-change item | delete capture, deltas |
| NEW_AND_OLD_IMAGES | both | CDC, aggregation, audit |
Rule of thumb. Default to NEW_AND_OLD_IMAGES for anything CDC-like — it is the only view that lets you diff and that captures deletes — and drop to NEW_IMAGE or KEYS_ONLY only when you have proven a single consumer needs less.
Worked example — Kinesis Data Streams for longer retention and fan-out
Detailed explanation. Native DynamoDB Streams retain 24 hours and are designed around a single Lambda consumer per shard. When you need multiple independent consumers, longer retention, or integration with the broader Kinesis/Flink ecosystem, route change data through Kinesis Data Streams instead. Walk through the trade-off.
- The limit. 24-hour retention; if a downstream backfill needs a week of history, native streams can't provide it.
- The fix. Enable Kinesis Data Streams for DynamoDB — same change records, but into a Kinesis stream with configurable retention (up to 365 days) and multiple consumers via enhanced fan-out.
- The caveat. Kinesis for DynamoDB is at-least-once and not guaranteed strictly ordered across records the way native streams are per shard, so idempotency matters even more.
Question. Decide between native Streams and Kinesis for a table that must feed both a real-time aggregator and a nightly warehouse backfill needing 7 days of replay.
Input.
| Requirement | Native Streams | Kinesis for DynamoDB |
|---|---|---|
| Retention | 24 h | up to 365 days |
| Multiple consumers | 1 primary (Lambda) | many (enhanced fan-out) |
| Ordering | strict per partition key | best-effort |
| Replay window | 24 h | configurable |
Code.
import boto3
ddb = boto3.client("dynamodb")
# Route DynamoDB change records into a Kinesis Data Stream (long retention + fan-out)
ddb.enable_kinesis_streaming_destination(
TableName="orders",
StreamArn="arn:aws:kinesis:us-east-1:123456789012:stream/orders-cdc",
)
# Consumers now read from Kinesis:
# - real-time aggregator (Lambda / KCL)
# - nightly warehouse loader replaying up to 7 days
Step-by-step explanation.
- Native Streams are ideal for a single, keep-up consumer with strict per-key ordering and a 24-hour safety window.
- When a second, independent consumer (the warehouse backfill) needs its own cursor and a 7-day replay window, native Streams' 24-hour retention and single-primary-consumer model no longer fit.
-
enable_kinesis_streaming_destinationmirrors the same change records into a Kinesis Data Stream, where retention is configurable (up to 365 days) and enhanced fan-out gives each consumer its own throughput. - The trade-off: Kinesis for DynamoDB relaxes strict ordering guarantees, so consumers must be idempotent and tolerate occasional reordering — reconcile by
SequenceNumber/timestamp. - A common production shape is both: native Streams → Lambda for the low-latency aggregate, and Kinesis → Firehose → S3 for durable, replayable history feeding the warehouse.
Output.
| Consumer | Source | Why |
|---|---|---|
| Real-time aggregate | native Streams → Lambda | strict order, low latency |
| 7-day warehouse backfill | Kinesis → Firehose → S3 | long retention, replay |
| Multiple analytics readers | Kinesis enhanced fan-out | independent cursors |
Rule of thumb. Use native Streams for a single low-latency consumer needing strict per-key order and a 24-hour window; switch to (or add) Kinesis Data Streams when you need long retention, replay beyond a day, or several independent consumers. Either way, make every consumer idempotent.
Data engineering interview question on DynamoDB Streams
A senior interviewer might ask: "Your orders table must, in near-real time, keep a per-customer 'lifetime value' aggregate and replicate every order into OpenSearch for full-text search. Design the Streams setup — view type, consumers, ordering guarantees, idempotency, and failure handling — and explain how you'd replay the last 12 hours after a bad deploy."
Solution Using Streams → Lambda with idempotent aggregation and a DLQ
import boto3
tbl = boto3.resource("dynamodb").Table("orders")
search = boto3.client("opensearch") # illustrative client
# Table configured with StreamViewType = NEW_AND_OLD_IMAGES
def handler(event, _ctx):
for rec in event["Records"]:
ev = rec["eventName"]
seq = rec["dynamodb"]["SequenceNumber"]
new = rec["dynamodb"].get("NewImage", {})
old = rec["dynamodb"].get("OldImage", {})
cust = (new or old)["customer_id"]["S"]
# 1. Maintain lifetime-value aggregate (idempotent atomic add)
delta = (int(new.get("total_cents", {}).get("N", 0)) -
int(old.get("total_cents", {}).get("N", 0)))
try:
tbl.update_item(
Key={"PK": f"CUST#{cust}", "SK": "LTV"},
UpdateExpression="ADD ltv_cents :d SET last_seq = :s",
ConditionExpression="attribute_not_exists(last_seq) OR last_seq < :s",
ExpressionAttributeValues={":d": delta, ":s": seq},
)
except tbl.meta.client.exceptions.ConditionalCheckFailedException:
pass # duplicate/older record -> skip
# 2. Replicate to search (upsert on INSERT/MODIFY, delete on REMOVE)
doc_id = new.get("order_id", old.get("order_id"))["S"]
if ev == "REMOVE":
delete_from_search(doc_id) # idempotent delete
else:
upsert_to_search(doc_id, new) # idempotent upsert by id
Step-by-step trace.
| Step | Mechanism | Guarantee |
|---|---|---|
| View type | NEW_AND_OLD_IMAGES |
delta + delete capture |
| Ordering | per customer_id shard |
LTV updates ordered per customer |
| Aggregate | atomic ADD + last_seq guard |
idempotent under redelivery |
| Search replicate | upsert/delete by order_id
|
idempotent by document id |
| Poison record | bisect-on-error + SQS DLQ | one bad record doesn't wedge shard |
| Replay 12 h | re-point iterator within 24 h retention | reprocess without data loss |
Walking it: every order mutation flows through one Lambda; the LTV aggregate uses an atomic ADD guarded by a monotonic last_seq, so a replay of the last 12 hours (well within the 24-hour retention) re-applies nothing it already saw; the search replication is keyed by order_id, so upserts and deletes are naturally idempotent; and a poison record is bisected out to a dead-letter queue instead of blocking its shard forever.
Output:
| Concern | Solution | Result |
|---|---|---|
| Double counting |
last_seq condition |
exactly-once effect on LTV |
| Delete propagation |
REMOVE → search delete |
search stays consistent |
| Bad deploy | replay within 24 h | no data loss |
| Poison message | DLQ + bisect | shard keeps flowing |
| Latency | Lambda trigger | sub-second in steady state |
Why this works — concept by concept:
-
NEW_AND_OLD_IMAGES — carrying both images lets the aggregator compute an exact delta and lets the replicator know what a
REMOVEdeleted, which is impossible withNEW_IMAGEalone. - Per-partition-key ordering — because all of a customer's order changes land on one shard in order, the LTV aggregate never applies a stale value out of sequence.
-
Idempotent consumers — the atomic
ADDguarded bylast_seq, plus upsert/delete-by-id for search, make at-least-once delivery safe: replays and retries are no-ops. - Failure isolation — bisect-on-error plus an SQS dead-letter destination stops a single un-processable record from stalling an entire shard, the classic Streams outage.
- Cost — one Lambda invocation per batch, atomic counter updates, and idempotent search writes; replay is free within the 24-hour window. The eliminated cost is polling or scanning the table to detect changes — Streams pushes them at O(changes), not O(table).
Data processing
Topic — data-processing
Streaming and change-data-capture problems
5. S3 export and warehouse integration
Export to S3 is the zero-RCU, point-in-time bridge from DynamoDB to your analytics warehouse
The mental model in one line: DynamoDB's "export to S3" feature writes a consistent point-in-time snapshot of a table into S3 — as DynamoDB JSON or Amazon Ion, partitioned into files — without consuming any read capacity and without a Scan, by reading from the continuous backup (PITR) rather than the live table, so it is the correct way to move DynamoDB data into a columnar warehouse for analytics, while Streams handles the real-time incremental path. For a data engineer, the rule is blunt: never Scan a production DynamoDB table for analytics — export it to S3 and query it there.
The primitives that matter.
- Point-in-time export. Export reads from PITR (point-in-time recovery) continuous backups, so it never touches live table throughput — zero RCU consumed — and produces a transactionally-consistent snapshot as of a chosen timestamp.
- PITR is a prerequisite. You must enable PITR on the table before you can export; without it, export is unavailable.
-
Output format. Files land in S3 as DynamoDB JSON (typed, e.g.
{"total_cents":{"N":"4200"}}) or Amazon Ion, gzip-compressed, partitioned across many objects with a manifest. -
No
Scan, no impact. Because export bypasses the live table, exporting a 10 TB table doesn't throttle production the way aScanwould.
Full export vs incremental export.
- Full export. A complete snapshot of the table as of a point in time. Use it to bootstrap the warehouse or take periodic full refreshes.
- Incremental export. Exports only the items that changed within a specified time window (backed by PITR), so after the initial full export you sync deltas cheaply instead of re-exporting everything.
- The pattern. One full export to seed, then scheduled incremental exports (e.g. hourly/daily windows) to keep the warehouse current — a batch CDC that complements or replaces the Streams path for analytics.
Query engines downstream.
- Glue + Athena. Point a Glue crawler at the export prefix to infer a schema, then query the DynamoDB JSON with Athena SQL — serverless, pay-per-scan.
- Redshift Spectrum / Spark. External tables over the S3 export let Redshift or Spark join DynamoDB data with the rest of the warehouse.
-
Unpacking DynamoDB JSON. The typed format (
{"N":"..."},{"S":"..."}) usually needs a flattening step — Athena'sjson_extract, a Glue transform, or a Spark UDF — to become clean columnar rows.
What interviewers listen for.
- Do you say "never
Scanfor analytics — export to S3"? — required answer. - Do you know export consumes zero RCU and requires PITR? — senior signal.
- Do you distinguish full vs incremental export and design a seed-then-delta pipeline? — senior signal.
- Do you contrast export (batch snapshots) with Streams (real-time CDC) and pick per requirement? — senior signal.
Worked example — full export to S3 then query with Athena
Detailed explanation. The bootstrap step for any DynamoDB→warehouse pipeline is a full point-in-time export, followed by a Glue crawler and Athena query. Walk through it end to end.
- Prerequisite. PITR enabled on the table.
-
The export.
export-table-to-point-in-timeto an S3 prefix, DynamoDB JSON, gzip. - The query. Glue crawler → Athena external table → SQL that flattens the typed JSON.
Question. Export the orders table to S3 and write the Athena query that returns total revenue by status.
Input.
| Step | Tool | Output |
|---|---|---|
| Enable PITR | DynamoDB | continuous backup |
| Export | export-table-to-point-in-time |
S3 DynamoDB JSON |
| Catalog | Glue crawler | Athena table |
| Query | Athena SQL | revenue by status |
Code.
# 1. Ensure PITR is on (one-time)
aws dynamodb update-continuous-backups \
--table-name orders \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
# 2. Full point-in-time export to S3 (zero RCU on the live table)
aws dynamodb export-table-to-point-in-time \
--table-arn arn:aws:dynamodb:us-east-1:123456789012:table/orders \
--s3-bucket my-lake \
--s3-prefix exports/orders/ \
--export-format DYNAMODB_JSON
-- 3. After a Glue crawler catalogs exports/orders/, query in Athena.
-- DynamoDB JSON stores typed scalars, so unwrap Item.<attr>.<type>.
SELECT
Item.status.S AS status,
SUM(CAST(Item.total_cents.N AS BIGINT)) AS revenue_cents,
COUNT(*) AS order_count
FROM orders_export
GROUP BY Item.status.S
ORDER BY revenue_cents DESC;
Step-by-step explanation.
- PITR must be enabled first; it is what the export reads from, which is why the export costs zero live read capacity.
-
export-table-to-point-in-timewrites a consistent snapshot to the S3 prefix as gzipped DynamoDB JSON, partitioned across many objects with a manifest describing them. - A Glue crawler infers the schema of the export (a nested
Itemstruct whose fields are typed maps likestatus.Sandtotal_cents.N) and registers an Athena table. - The Athena query unwraps the typed JSON —
Item.total_cents.Nis a string inside the DynamoDB JSON, soCAST(... AS BIGINT)turns it into a number for aggregation. - The whole analytical query runs against S3, never against DynamoDB, so it cannot throttle production no matter how heavy the aggregation.
Output.
| status | revenue_cents | order_count |
|---|---|---|
| shipped | 128,400,000 | 30,200 |
| pending | 9,150,000 | 2,410 |
| refunded | 1,020,000 | 260 |
Rule of thumb. Bootstrap the warehouse with a full point-in-time export (PITR required, zero RCU), catalog it with Glue, and always CAST the typed DynamoDB-JSON scalars when querying in Athena. The analytics never touch the live table — that is the whole point.
Worked example — incremental export for ongoing sync
Detailed explanation. Re-exporting a large table in full every hour is wasteful. Incremental export ships only the items changed in a time window, so after the initial seed you sync cheap deltas. Walk through a daily incremental pipeline.
- The seed. One full export (previous example).
- The delta. Daily incremental export for the previous 24 hours.
-
The merge. Downstream
MERGE/upsert applies inserts, updates, and deletes into the warehouse table by key.
Question. Configure a daily incremental export and describe how the warehouse applies the delta idempotently.
Input.
| Parameter | Value |
|---|---|
| Export type | INCREMENTAL_EXPORT |
| Window | previous 24 h |
| Format | DynamoDB JSON |
| Merge key | order_id |
Code.
# Daily incremental export: only items changed in the window [from, to)
aws dynamodb export-table-to-point-in-time \
--table-arn arn:aws:dynamodb:us-east-1:123456789012:table/orders \
--s3-bucket my-lake --s3-prefix exports/orders/incr/ \
--export-type INCREMENTAL_EXPORT \
--incremental-export-specification \
ExportFromTime=2026-09-04T00:00:00Z,ExportToTime=2026-09-05T00:00:00Z,ExportViewType=NEW_AND_OLD_IMAGES \
--export-format DYNAMODB_JSON
-- Warehouse merge: apply the incremental delta by key (Redshift/Snowflake-style)
MERGE INTO analytics.orders AS tgt
USING staging.orders_incr AS src
ON tgt.order_id = src.order_id
WHEN MATCHED AND src.op = 'REMOVE' THEN DELETE
WHEN MATCHED THEN UPDATE SET
status = src.status, total_cents = src.total_cents, updated_at = src.updated_at
WHEN NOT MATCHED THEN INSERT (order_id, status, total_cents, updated_at)
VALUES (src.order_id, src.status, src.total_cents, src.updated_at);
Step-by-step explanation.
-
INCREMENTAL_EXPORTwith an explicit[ExportFromTime, ExportToTime)window exports only items mutated in that period, using PITR — again zero live RCU. -
ExportViewType=NEW_AND_OLD_IMAGESincludes both images so the delta carries enough to distinguish updates from deletes downstream. - The warehouse
MERGEkeys onorder_id: matched-and-removed rows are deleted, matched rows are updated, and new rows are inserted — one statement handles all three change types. - Because the merge is keyed and deterministic, re-running the same incremental window is idempotent — a retried load produces the same warehouse state.
- This seed-then-daily-delta pattern is batch CDC: cheaper than full re-exports, and it keeps the warehouse within a day of the source without ever scanning the table.
Output.
| Change in window | Delta row op
|
Warehouse effect |
|---|---|---|
| New order | INSERT | row inserted |
| Status change | MODIFY | row updated |
| Cancelled order | REMOVE | row deleted |
| Unchanged order | (absent) | untouched |
Rule of thumb. Seed once with a full export, then run scheduled incremental exports and a keyed MERGE. Include old-and-new images so deletes propagate, and key the merge so retries are idempotent — that is batch CDC without a single Scan.
Worked example — Streams → Firehose vs export: pick the right path
Detailed explanation. Data engineers must choose between two DynamoDB→analytics paths: real-time via Streams/Kinesis Firehose, or batch via S3 export. They are complementary, and picking the wrong one wastes money or misses SLAs. Walk through the decision.
- Real-time path. Streams → Kinesis Firehose → S3 (or Redshift), delivering changes continuously with seconds-to-minutes latency.
- Batch path. Full + incremental S3 export on a schedule, delivering consistent snapshots with hours latency but zero RCU and trivial ops.
- The choice. Freshness requirement vs cost and operational simplicity.
Question. For a dashboard needing sub-minute freshness and a monthly finance report needing exact consistency, pick a path for each.
Input.
| Consumer | Freshness need | Consistency need | Path |
|---|---|---|---|
| Ops dashboard | sub-minute | eventual OK | Streams → Firehose → S3 |
| Finance report | monthly | strong point-in-time | full S3 export |
Code.
Decision guide — DynamoDB to analytics
=======================================
Need sub-minute freshness?
yes -> Streams (NEW_AND_OLD_IMAGES) -> Kinesis Firehose -> S3/Redshift
+ pay per change, real-time, must handle idempotency
Need periodic consistent snapshots / heavy historical scans?
yes -> S3 export (full to seed, incremental to sync)
+ zero RCU, point-in-time consistent, batch latency
Need both?
run both: Streams for the live dashboard, export for the
consistent warehouse tables and backfills.
Step-by-step explanation.
- A dashboard that must reflect changes within a minute needs the real-time path: Streams push each mutation to Firehose, which buffers and writes to S3 or Redshift continuously.
- Firehose delivery is at-least-once and micro-batched, so the dashboard's loader must dedupe by key — same idempotency discipline as any stream consumer.
- A monthly finance report needs exact, consistent numbers as of a timestamp, not the freshest possible — a full point-in-time export gives a transactionally-consistent snapshot with zero impact on the live table.
- Export's batch latency (hours) is irrelevant for a monthly report but disqualifying for a live dashboard; conversely, running Streams to satisfy a monthly report would pay for real-time you don't need.
- Mature stacks run both: Streams/Firehose for low-latency views, and scheduled exports for the authoritative, replayable warehouse tables.
Output.
| Path | Latency | RCU cost | Best for |
|---|---|---|---|
| Streams → Firehose | seconds–minutes | per change | live dashboards |
| Full export | hours | zero | consistent snapshots, backfills |
| Incremental export | hours | zero | scheduled warehouse sync |
Rule of thumb. Match the path to the freshness SLA: Streams/Firehose when minutes matter, S3 export when consistency and cost matter and hours are fine. They are complementary — run both when you need real-time views and an authoritative warehouse.
Data engineering interview question on S3 export
A senior interviewer might ask: "You must load a 5 TB DynamoDB orders table into Redshift for analytics without impacting the production service, then keep it within a day fresh. Walk me through the initial load, the ongoing sync, why you would not Scan, and how you handle the typed DynamoDB JSON and deletes."
Solution Using PITR full export, Glue catalog, and daily incremental exports
# 1. Enable PITR (prerequisite for any export)
aws dynamodb update-continuous-backups --table-name orders \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
# 2. Initial FULL export — 5 TB, zero RCU, consistent point-in-time
aws dynamodb export-table-to-point-in-time \
--table-arn arn:aws:dynamodb:us-east-1:123456789012:table/orders \
--s3-bucket lake --s3-prefix orders/full/ --export-format DYNAMODB_JSON
# 3. Daily INCREMENTAL export — only yesterday's changes, with old+new images
aws dynamodb export-table-to-point-in-time \
--table-arn arn:aws:dynamodb:us-east-1:123456789012:table/orders \
--s3-bucket lake --s3-prefix orders/incr/ --export-type INCREMENTAL_EXPORT \
--incremental-export-specification \
ExportFromTime=2026-09-04T00:00:00Z,ExportToTime=2026-09-05T00:00:00Z,ExportViewType=NEW_AND_OLD_IMAGES \
--export-format DYNAMODB_JSON
-- 4. Redshift Spectrum external tables over the S3 exports, then MERGE the delta in.
-- Flatten typed DynamoDB JSON on the way in.
INSERT INTO staging.orders_incr
SELECT
json_extract_path_text(item, 'order_id', 'S') AS order_id,
json_extract_path_text(item, 'status', 'S') AS status,
CAST(json_extract_path_text(item, 'total_cents', 'N') AS BIGINT) AS total_cents,
metadata_op AS op -- INSERT/MODIFY/REMOVE
FROM spectrum.orders_incr_raw;
MERGE INTO analytics.orders AS tgt
USING staging.orders_incr AS src ON tgt.order_id = src.order_id
WHEN MATCHED AND src.op = 'REMOVE' THEN DELETE
WHEN MATCHED THEN UPDATE SET status = src.status, total_cents = src.total_cents
WHEN NOT MATCHED THEN INSERT (order_id, status, total_cents)
VALUES (src.order_id, src.status, src.total_cents);
Step-by-step trace.
| Step | Mechanism | Property |
|---|---|---|
| PITR | continuous backup | enables export; zero RCU |
| Full export | point-in-time snapshot | 5 TB, no production impact |
| Glue / Spectrum | external tables over S3 | query without loading first |
| Flatten |
json_extract + CAST
|
typed DynamoDB JSON → columns |
| Daily incremental | changed-items export | cheap ongoing sync |
MERGE by order_id
|
keyed upsert/delete | idempotent, handles deletes |
Walking it: the 5 TB initial load is a point-in-time export read from PITR, so production sees zero read load and no Scan; Spectrum queries the S3 files directly; a nightly incremental export ships only the day's changes with old-and-new images; and a keyed MERGE applies inserts, updates, and deletes idempotently. Freshness lands within a day, and the production table is never scanned.
Output:
| Metric | Scan approach (rejected) | Export approach |
|---|---|---|
| Production impact | heavy RCU, throttling risk | zero RCU |
| Initial 5 TB load | days, at capacity cost | one export, no impact |
| Ongoing sync | re-scan | daily incremental delta |
| Delete handling | invisible without extra work |
REMOVE rows in delta |
| Consistency | non-atomic | point-in-time consistent |
Why this works — concept by concept:
-
Point-in-time export — reading from PITR rather than the live table gives a consistent snapshot at zero read capacity, which is why a 5 TB export never throttles production, unlike a
Scan. - Full then incremental — one full export seeds the warehouse; scheduled incremental exports ship only changed items, making ongoing sync cheap and keeping freshness within the export cadence.
-
NEW_AND_OLD_IMAGES on incrementals — including both images lets the downstream
MERGEdistinguish updates from deletes, so cancellations propagate instead of silently lingering. -
Keyed MERGE — upserting/deleting by
order_idmakes each load idempotent, so a retried export window converges to the same warehouse state. -
Cost — export cost scales with data exported (full once, deltas thereafter), all at zero live RCU, versus a
Scanthat costs O(table) RCU every run and risks throttling. Analytics run on S3/Redshift, never on the operational table.
Data processing
Topic — data-processing
Warehouse-load and batch-CDC problems
Indexing
Topic — indexing
Partitioning and file-layout problems
Cheat sheet — DynamoDB for data engineers recipes
-
Partition-key spreading rule. Choose the partition key for high cardinality and even access distribution first, correctness of naming second. Reject low-cardinality attributes (
status,region,type) as partition keys — they hot-spot. If a single key value can attract a large fraction of traffic, shard it with a suffix (device_id#yyyy-mm,OPEN#<n>). Sort key exists to make your most common range read a nativeQuerywithbegins_with/between. -
Capacity math. RCU =
ceil(item_kb / 4) × reads/sec, halved for eventual consistency; WCU =ceil(item_kb / 1) × writes/sec. On-demand (PAY_PER_REQUEST) is the 2026 default for spiky/new workloads; provisioned + auto-scaling wins only at steady, predictable, high volume. Keep items lean — a 40 KB item costs 10× the read units of a 4 KB one. -
400 KB item limit. Never inline unbounded text/binary. Offload the blob to S3, keep
s3_key+byte_sizein the item. Small items are cheaper to read, faster to stream, and delay the 10 GB item-collection split. -
Single-table workflow. List every access pattern before the schema; map each to a
Query/GetItem(a pattern that needs aScanis a design bug). Use generic keysPK/SKwith typed value prefixes (USER#123,ORDER#456), atypeattribute on every item, and item collections so oneQueryreturns a whole aggregate. -
Adjacency-list relationships. Model edges as items carrying both the base key (
PK=ORDER#456, SK=ITEM#sku-9) and GSI keys (GSI1PK=SKU#sku-9, GSI1SK=ORDER#456). One write maintains both traversal directions; the adjacency list is the join. - GSI vs LSI. GSI: any keys, add anytime, own throughput, eventual-only, up to 20/table — the default. LSI: same partition key + different sort key, must be created at table creation, strongly-consistent, shares throughput, 10 GB collection cap — rare. Prefer a GSI unless you specifically need strong consistency on an alternate sort key.
-
GSI projections.
KEYS_ONLYfor routing indexes,INCLUDE [attrs]for list views (project exactly what you read),ALLonly when the GSI must serve reads without touching the base table. Every projected attribute adds write amplification. -
Sparse GSI. Write the GSI key only while an item is in the target state and
REMOVEit when the state ends, so "find the few items in state X" is O(matching) not O(table). Watch for a hot GSI partition when the key is a constant — shard it. -
Streams view types.
KEYS_ONLY= cache-bust,NEW_IMAGE= state replication,OLD_IMAGE= delete/delta capture,NEW_AND_OLD_IMAGES= CDC/aggregation default. Ordering is strict per partition key (per shard), not global. 24-hour retention. -
Stream consumer contract. Delivery is at-least-once → consumers must be idempotent. Use atomic
ADDcounters guarded by a monotoniclast_seq/SequenceNumber, upsert/delete by natural id for replication, bisect-on-error + a DLQ for poison records. Reach for Kinesis Data Streams when you need >24 h retention, replay, or multiple independent consumers. -
S3 export basics. Enable PITR (prerequisite), then
export-table-to-point-in-timereads from continuous backups at zero RCU and neverScans. Output is gzipped DynamoDB JSON / Ion, partitioned with a manifest. Seed with a full export; keep fresh withINCREMENTAL_EXPORTwindows usingNEW_AND_OLD_IMAGESso deletes propagate. -
Export → warehouse. Catalog the S3 export with Glue; query via Athena / Redshift Spectrum / Spark; flatten typed DynamoDB JSON with
json_extract+CAST. Apply incremental deltas with a keyedMERGE(REMOVE→ DELETE, matched → UPDATE, unmatched → INSERT) so loads are idempotent. NeverScana production table for analytics — export it.
Frequently asked questions
What is DynamoDB in one sentence for a data engineer?
DynamoDB is a fully managed, serverless key-value and document database that delivers single-digit-millisecond performance at any scale by hashing items onto physical partitions via a partition key and refusing the relational conveniences — no joins, no ad-hoc WHERE, no add-an-index-later — that would break that performance guarantee. For a data engineer it is usually a source: a table you model around fixed access patterns, tap for change data via DynamoDB Streams, and drain into a warehouse via S3 export. The whole discipline is designing keys and indexes up front so every important query is an O(result-size) Query, never an O(table) Scan.
Partition key vs sort key — what is the difference?
The partition key (hash key) determines which physical partition an item lives on; DynamoDB hashes its value to spread items across the storage fleet, and you must supply its exact value to read or Query efficiently. The sort key (range key) is optional and determines the order of items within a partition — items sharing a partition key are stored sorted by sort key, which is what makes range conditions like begins_with, between, and > cheap. Together (partition key, sort key) form a composite primary key that must be unique. Rule of thumb: pick the partition key for even distribution and high cardinality; design the sort key so your most common "give me a range of related items" access pattern is a single native Query.
What is single-table design and why is it recommended?
Single-table design stores multiple entity types — users, orders, line items — in one DynamoDB table using generic key attributes (PK, SK) whose values carry entity-type prefixes (USER#123, ORDER#456), so related items share a partition and can be fetched together in a single Query. It is recommended because DynamoDB has no joins: co-locating related items in one item collection lets you retrieve an entire aggregate (a user plus their orders plus their addresses) in one round trip instead of N queries across N tables. The catch is that you must enumerate every access pattern before creating the table and denormalize deliberately, maintaining consistency across duplicated data at write time. Done right, it delivers complex reads at single-digit-millisecond latency; done wrong (entity-first, like a relational schema) it forces expensive Scans.
GSI vs LSI — when do I use each?
Use a GSI (Global Secondary Index) for almost everything: it can be keyed on any attributes, added at any time, has its own throughput, and lets you query the same data along a partition key the base table doesn't support — at the cost of being eventually consistent only. Use an LSI (Local Secondary Index) only when you need strongly-consistent reads on an alternate sort key within the same partition key, and only if you can create it at table-creation time (LSIs cannot be added later), accepting that it shares the base table's throughput and imposes a 10 GB item-collection limit. In 2026, the honest default is "reach for a GSI"; LSIs are a niche tool for the specific strong-consistency-on-a-second-sort-key case.
How do DynamoDB Streams enable CDC?
DynamoDB Streams publishes an ordered, 24-hour-retained log of every item-level change (INSERT, MODIFY, REMOVE), sharded so that all changes for a given partition key are delivered in order, with each record optionally carrying the new and/or old image of the item. That is textbook change data capture: a consumer (a Lambda trigger or a Kinesis Client Library app) reads the change records and fans them out to search indexes, materialized aggregate tables, caches, or the analytics warehouse — all without polling or scanning the source table. Because delivery is at-least-once, consumers must be idempotent (atomic counters guarded by SequenceNumber, upsert/delete by natural id). When you need more than 24 hours of retention, replay, or multiple independent consumers, route the stream through Kinesis Data Streams for DynamoDB, which offers up to 365 days retention and enhanced fan-out.
How do I get DynamoDB data into a warehouse?
Two complementary paths. For batch analytics, use S3 export: enable PITR, then export-table-to-point-in-time writes a consistent point-in-time snapshot to S3 as DynamoDB JSON at zero read capacity and without a Scan; catalog it with Glue and query via Athena, Redshift Spectrum, or Spark, flattening the typed JSON with json_extract + CAST. Seed with one full export, then keep the warehouse fresh with scheduled incremental exports (using NEW_AND_OLD_IMAGES so deletes propagate) applied via a keyed MERGE. For real-time needs, use DynamoDB Streams → Kinesis Firehose → S3/Redshift, delivering changes with seconds-to-minutes latency. The cardinal rule: never Scan a production table for analytics — export it to S3 and query it there, and add the Streams path only when sub-minute freshness is actually required.
Practice on PipeCode
- Drill the database practice library → for the key-design, single-table, and query-modeling problems senior interviewers love to hand DynamoDB candidates.
- Sharpen your index intuition on the indexing practice library → for GSI vs LSI, sparse indexes, inverted indexes, and projection trade-offs.
- Rehearse the pipeline side on the data-processing practice library → for streams, CDC, incremental loads, and DynamoDB-to-warehouse export patterns.
- Stack these against PipeCode's broader 450+ data-engineering catalogue to anchor the "model access patterns first, never
Scanfor analytics" instinct against real graded inputs.
Lock in DynamoDB modeling muscle memory
Docs explain the API. PipeCode drills explain the decision — when to shard a hot partition key, when single-table design beats one-table-per-entity, when a sparse GSI turns an O(table) scan into an O(few) query, when Streams beat polling, and when to export to S3 instead of scanning. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the production trade-offs senior data engineers actually face.
Practice database problems →
Practice data-processing problems →





Top comments (0)