AWS Databases

Amazon DynamoDB Hands-On: Tables, Keys, Capacity Modes & Indexes

The first time you meet Amazon DynamoDB it feels like a relational database with the good parts filed off: no joins, no SELECT *, no ad-hoc WHERE on any column you like, no schema to speak of. That reaction is the trap. DynamoDB is not a lesser SQL database — it is a different machine with a different contract, and once you accept the contract it gives you something no relational engine can: single-digit-millisecond reads and writes at any scale, with zero servers to patch and a bill that (done right) grows linearly with the traffic that pays for it. The price of that promise is that you must design around your access patterns, and the entire design lives in one place — the primary key.

This article is the on-ramp. You will build a real table with a composite key, put and get items, run an efficient Query and see why a Scan is the thing you avoid, add a global secondary index and query it, switch a copy of the table from on-demand to provisioned capacity with auto scaling, and turn on TTL and point-in-time recovery — all with copy-pasteable aws CLI and equivalent Terraform. Along the way you will learn the mental model that makes the rest of DynamoDB obvious: how the partition key is hashed to a physical partition, why an item is capped at 400 KB, what an RCU and a WCU actually buy you, and when a read is strongly versus eventually consistent.

Keep the tables open. Because DynamoDB is a reference-heavy service — data types, capacity math, operation limits, index rules, consistency modes — this piece is deliberately table-dense: read the prose once to build the model, then return to the tables mid-design and mid-incident. If you are still deciding whether DynamoDB is even the right store, read AWS Databases: RDS, DynamoDB and Aurora — Choose the Right Store first; this article assumes you have chosen DynamoDB and now need to wield it.

What problem this solves

Relational databases answer any question you can phrase in SQL, and they do it by scanning and joining at query time. That flexibility is a gift when queries are unpredictable and data is modest, and a liability when you need predictable low latency at scale: a join that was fast at 10 GB becomes a table-scan at 10 TB, a single writer becomes a vertical-scaling ceiling, and connection pools become a bottleneck long before storage does. DynamoDB inverts the deal. You give up query flexibility up front — you decide the access patterns before you create the table — and in return you get flat latency and horizontal scale that does not care whether the table holds a thousand items or a trillion.

What breaks without understanding this: teams treat DynamoDB like a key-value cache bolted onto relational habits. They pick a random id as the partition key, then discover they cannot list “all orders for a customer” without a full-table Scan. They put everything in one table with no sort key, then bolt on Scans with FilterExpressions and wonder why the bill and the latency both climb. They provision capacity by guessing, then either throttle during launch or pay for idle RCUs the rest of the month. Every one of these is the same root error: modelling the data instead of modelling the access patterns, and not understanding what the partition key physically does.

Who hits this: every engineer building a serverless backend (DynamoDB is the default state store behind API Gateway + Lambda serverless web apps), every team that outgrew a relational instance on a write-heavy workload, and every candidate sitting CLF-C02, DVA-C02 or SAA-C03, all of which test keys, capacity modes, indexes and consistency. The fix is to learn the machine: know what the key does, know what a read and a write cost, know when to add an index, and know the handful of failure modes cold. That is exactly what follows.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account with the aws CLI v2 configured (aws configure or SSO — see AWS CLI profiles, SSO & config if that is shaky), permissions to call dynamodb:* and application-autoscaling:* in a sandbox account, and optionally Terraform 1.5+ with the aws provider. No prior NoSQL experience is assumed — if you can picture a giant hash map whose keys route to different machines, you have the intuition. Basic JSON literacy helps, because the CLI speaks DynamoDB’s typed JSON ({"S": "..."}, {"N": "42"}).

This sits at the foundation of the Databases and Serverless tracks. It is downstream of the store-selection decision in RDS, DynamoDB and Aurora compared and upstream of two deeper articles: DynamoDB single-table design & data modeling (how to fold many entity types and access patterns into one table) and DynamoDB throttling & hot-partition troubleshooting (what to do when a partition gets too hot). It pairs naturally with Lambda event-driven patterns (Streams trigger Lambda) and with S3 storage classes & lifecycle (where you offload the blobs that will not fit in a 400 KB item).

A quick map of who owns what during a DynamoDB design or incident, so you pull in the right person:

Layer What lives here Who usually owns it What it decides / can break
Data model / access patterns Key design, GSIs, item shape App / dev team Whether a query is a Query or a Scan; hot partitions
Data-plane calls PutItem/Query/UpdateItem, expressions App / dev team Latency, consumed capacity, ValidationExceptions
Capacity & scaling On-demand vs provisioned, auto scaling Platform / dev team Throttling vs over-provisioning; the bill
Table features TTL, PITR, Streams, encryption Platform / DBA Recovery, data lifecycle, change feed
Identity & network IAM policies, VPC endpoint, KMS key Security team Who can read/write which items; private access
Cost / FinOps Request units, storage, backups, Streams FinOps + owners On-demand surprises; idle provisioned capacity

Core concepts

Start with the vocabulary, because DynamoDB reuses relational words for different things and the mismatch causes half the early confusion.

DynamoDB term Rough relational analogue The important difference
Table Table No fixed schema beyond the key attributes; every item can have different attributes
Item Row A single record, ≤ 400 KB, uniquely identified by its primary key
Attribute Column / field Typed per item, not per table; can be scalar, document or set
Primary key Primary key The only mandatory structure; either partition-only or partition + sort
Partition key Hash / shard key Hashed to choose a physical partition — governs distribution
Sort key Clustering key Orders items within a partition key; enables ranges and prefixes
Item collection (no direct analogue) All items that share one partition-key value
Secondary index Index A re-projection of the table under a different key (LSI or GSI)
RCU / WCU (no analogue) The unit of read / write throughput you pay for
Query Indexed SELECT … WHERE key = … Reads one partition efficiently by key condition
Scan SELECT * FROM t (no index) Reads the whole table; almost always the wrong tool

The primary key is the whole design

Every item is uniquely identified by its primary key, and DynamoDB supports exactly two shapes. Get this choice right and most queries become efficient Queries; get it wrong and you are stuck Scanning.

Key type Shape Uniqueness Access it enables Pick it when
Partition key only (simple) One attribute (HASH) The partition key must be unique across the table Direct lookup by that exact key only Pure key-value: get/put by a single id (a session, a user profile by userId)
Composite (partition + sort) Two attributes (HASH + RANGE) The combination must be unique; the partition key can repeat Get one item, or Query a range/prefix within one partition key You need “all X for a given Y”, latest-N, ranges, or multiple item types per key

A composite key is what you reach for the moment an access pattern says “for a customer, give me their orders” or “for a device, give me readings between two timestamps.” The partition key (CustomerId) groups related items into one item collection; the sort key (OrderId or a timestamp) orders them so a single Query returns exactly the slice you want, sorted, without reading anything else.

How the partition key maps to physical partitions

This is the single most important mechanical fact in DynamoDB. When you write an item, DynamoDB takes the partition key value, runs it through an internal hash function, and the hash output selects one physical partition — an actual storage node (backed by SSD, replicated across three Availability Zones). All items with the same partition key land on the same partition and are stored adjacent, sorted by sort key. That is why a Query by partition key is one fast, local read, and why a Scan must visit every partition.

Fact about partitions Value / behaviour Why it matters
Selected by hash(partition key) Even key distribution = even load; skewed keys = hot partition
Max size per partition ~10 GB An item collection (one PK value) that outgrows this splits — except when an LSI pins it
Max throughput per partition ~3,000 RCU and ~1,000 WCU One very hot key throttles even if the table has spare total capacity
Replication 3 copies across 3 AZs Durability and availability are automatic
Adaptive capacity Automatic, instant DynamoDB redistributes throughput toward hot partitions to a point
Burst capacity Up to ~300 s of unused capacity Absorbs short spikes before throttling

The consequence you must internalise: choose a partition key with high cardinality and even access. A Status attribute with three values (“PLACED”, “SHIPPED”, “DELIVERED”) is a terrible partition key — three hot partitions — but a fine GSI key or sort-key prefix. A CustomerId or deviceId#date spreads load. The deep treatment of what to do when a key still runs hot lives in DynamoDB throttling & hot-partition troubleshooting; here, just remember hashing is why the key choice is physical, not cosmetic.

Data types and item structure

DynamoDB attributes are typed, and the CLI (and low-level SDK) always shows the type as a one- or two-letter descriptor wrapping the value. You will read and write this JSON constantly.

Category Type code Name Example (CLI JSON) Notes / gotcha
Scalar S String {"S": "CUST#42"} UTF-8; can be a sort/partition key; empty string is allowed (since 2020)
Scalar N Number {"N": "2499.50"} Sent as a string; up to 38 digits of precision; can be a key
Scalar B Binary {"B": "dGV4dA=="} Base64 on the wire; can be a key
Scalar BOOL Boolean {"BOOL": true} Not a key type
Scalar NULL Null {"NULL": true} Marks an attribute explicitly null
Document L List {"L": [{"S":"a"},{"N":"1"}]} Ordered, mixed types, nestable
Document M Map {"M": {"k": {"S": "v"}}} JSON-object-like, nestable; the workhorse for nested data
Set SS String Set {"SS": ["a","b"]} Unique values, unordered, cannot be empty
Set NS Number Set {"NS": ["1","2"]} Unique numbers; no duplicates
Set BS Binary Set {"BS": ["dGV4dA=="]} Unique binaries

Two rules trip people up. First, sets cannot be empty — removing the last element must remove the attribute, or you get a ValidationException. Second, only S, N and B may be key attributes (partition or sort); you cannot key on a boolean, list, map or set. For nested, evolving data prefer a map (M) over parallel lists.

The 400 KB item limit

Every item — the sum of all attribute names plus all values, including nested documents — must be ≤ 400 KB. This is a hard limit, not a soft quota you can raise.

What counts toward 400 KB Included? Mitigation when you are close
Attribute values (all of them) Yes Compress large text (gzip → store as B)
Attribute names Yes Use short attribute names in hot items
Nested map/list contents Yes Flatten or split into multiple items in the same collection
Number of attributes No hard cap, but size is Vertical-split a wide item across sort-key “facets”
Large binary blobs (images, PDFs) Yes Store the blob in S3 and keep only the S3 key in DynamoDB

The idiomatic pattern for big payloads is the S3 pointer: put the object in S3, store s3://bucket/key (and metadata) in the item. DynamoDB stays small and fast; S3 holds the bytes. Trying to cram a 2 MB document into an item is the wrong shape and will fail on write.

Capacity modes: on-demand vs provisioned

Every table runs in one of two capacity modes, and this is the choice that most directly shapes both your operational burden and your bill. You can switch modes, but only once every 24 hours per table.

Dimension On-demand (PAY_PER_REQUEST) Provisioned (PROVISIONED)
You configure Nothing (optionally a max throughput) RCUs and WCUs (with optional auto scaling)
Billing unit Per request: RRU / WRU Per hour of provisioned RCU / WCU
Handles spikes Instantly, up to 2× previous peak; higher via pre-warming Only up to provisioned + burst; else throttles
Throttling risk Very low (unless you exceed table limits) High if under-provisioned
Cost profile Higher per request; zero when idle Cheaper per request at steady load; pay even when idle
Free-tier eligible No always-free allotment Yes: 25 RCU + 25 WCU always free
Best for Spiky, unpredictable, new, or dev/test workloads Steady, predictable, high-volume production traffic
Auto scaling N/A (it is inherently elastic) Optional target-tracking auto scaling

Rule of thumb: start on-demand. It removes an entire class of throttling incidents while you are still learning the traffic shape. Once traffic is steady and understood, provisioned with auto scaling is usually cheaper. A workload with a flat baseline and rare spikes can even sit provisioned-with-auto-scaling and let the policy chase the curve.

The RCU / WCU math (know this cold)

Capacity is metered in read capacity units (RCU) and write capacity units (WCU) for provisioned, and the equivalent per-request read/write request units (RRU/WRU) for on-demand. The conversion rules are the most-tested numbers in the whole service.

Unit Buys you Rounding Consistency multiplier
1 RCU 1 strongly-consistent read/sec of an item up to 4 KB Round item size up to next 4 KB Eventually consistent = ½ RCU; transactional = 2 RCU
1 WCU 1 write/sec of an item up to 1 KB Round item size up to next 1 KB Transactional write = 2 WCU
1 RRU (on-demand) 1 strongly-consistent read up to 4 KB Same 4 KB rounding Eventually consistent = ½ RRU; transactional = 2 RRU
1 WRU (on-demand) 1 write up to 1 KB Same 1 KB rounding Transactional = 2 WRU

Worked read examples — how many RCUs one read consumes:

Read Item size Consistency RCU cost Why
GetItem 3 KB Strong 1 Rounds up to 4 KB → 1 unit
GetItem 3 KB Eventual 0.5 Half of strong
GetItem 8 KB Strong 2 8 KB ÷ 4 KB = 2
GetItem 8 KB Eventual 1 Half of 2
GetItem 12 KB Strong 3 12 KB ÷ 4 KB = 3
GetItem 0.5 KB Strong 1 Minimum 4 KB rounding
TransactGetItems 4 KB Transactional 2 2× strong
Query returning 40 KB 40 KB total Eventual 5 40 ÷ 4 = 10, ½ = 5 (billed on total read, pre-filter)

Worked write examples — how many WCUs one write consumes:

Write Item size Type WCU cost Why
PutItem 0.5 KB Standard 1 Rounds up to 1 KB
PutItem 1 KB Standard 1 Exactly 1 unit
PutItem 2.5 KB Standard 3 Rounds up to 3 KB → 3 units
UpdateItem 3 KB item Standard 3 Charged on the full new item size, not the delta
DeleteItem 1 KB Standard 1 Delete costs a write of the item size
TransactWriteItems 1 KB Transactional 2 2× standard
PutItem 1 KB + 1 GSI 1 KB base Standard 1 (base) + 1 (GSI) Each projected GSI write is billed separately

To size provisioned throughput: to sustain N strongly-consistent 4 KB reads per second, provision N RCUs; for eventually consistent, provision N/2. To sustain M 1 KB writes per second, provision M WCUs. Two subtleties that cost people money: a Query or Scan is billed on the total size of items read before any FilterExpression (filtering does not save capacity), and an UpdateItem is billed on the size of the whole resulting item, not the changed bytes.

Auto scaling for provisioned tables

Provisioned tables can auto-scale via Application Auto Scaling target-tracking: you set a target utilisation and min/max bounds, and DynamoDB adjusts capacity to keep consumed/provisioned near the target.

Auto scaling setting Typical value Effect / gotcha
Target utilisation 70% Higher = cheaper but less headroom for spikes
Min capacity Your steady-state floor Too low = throttles at the start of a ramp
Max capacity Spike ceiling + margin The real cap; scaling never exceeds it
Scale-out speed Fast (seconds–minutes) Handles gradual ramps well
Scale-in speed Slow, conservative Avoids flapping; you keep paying briefly after a spike
Applies to Table and each GSI separately A GSI can throttle independently — scale it too

Auto scaling reacts to sustained change, not instantaneous bursts. For a known spike (a sale launch, a batch import) either pre-provision, switch to on-demand for the window, or raise the min ahead of time — do not rely on the policy to catch a step-function of traffic.

Consistency, reads and writes

DynamoDB replicates every write across three AZs. A read can either wait for a majority (strong) or accept whatever the nearest replica has (eventual, the default).

Read mode Flag Latency Cost Guarantee Where available
Eventually consistent default Lowest ½ RCU May not reflect a write from the last ~1 second Everywhere, including GSIs
Strongly consistent --consistent-read Slightly higher 1 RCU Reflects all successful prior writes Base table + LSI only — never on a GSI
Transactional Transact APIs Highest 2 RCU ACID across up to 100 items TransactGetItems/TransactWriteItems

The rule to memorise for exams and incidents: a global secondary index is always eventually consistent — you cannot ask a GSI for a strongly-consistent read. If your read-after-write test queries a GSI and “loses” the item, that is by design (see the troubleshooting playbook). Writes are always durable once acknowledged; the choice is only on the read side.

Operations: the data-plane API

DynamoDB’s data plane is a small, sharp set of calls. Learn what each one costs and which access pattern it serves.

Operation What it does Reads/writes Capacity model Consistency options
PutItem Create or fully replace one item Write 1 write of new item size Conditional via ConditionExpression
GetItem Fetch one item by full primary key Read 1 read of item size Eventual or strong
UpdateItem Modify attributes of one item (upsert) Write 1 write of resulting item size Conditional; atomic counters
DeleteItem Remove one item by primary key Write 1 write of item size Conditional
Query Read items sharing a partition key, by key condition Read Total size read (pre-filter) Eventual or strong (not on GSI)
Scan Read every item in a table/index Read Total size of everything scanned Eventual or strong
BatchGetItem Up to 100 items / 16 MB across tables Read Sum of item reads Per-item eventual or strong
BatchWriteItem Up to 25 puts/deletes / 16 MB Write Sum of item writes No conditions, no updates
TransactWriteItems Up to 100 writes, all-or-nothing Write 2× per item ACID; ConditionCheck supported
TransactGetItems Up to 100 reads, consistent snapshot Read 2× per item ACID snapshot

Query vs Scan — the cost that decides careers

This is the difference between a system that scales and one that falls over. A Query targets one partition key and reads only the matching item collection (optionally narrowed by a sort-key condition). A Scan reads the entire table, page by page, and applies any filter after reading — so you pay for every byte even for items you throw away.

Aspect Query Scan
Reads One partition key’s items Every item in the table/index
Requires Partition key = condition Nothing
Sort-key conditions =, <, <=, >, >=, BETWEEN, begins_with N/A
Cost Proportional to items returned Proportional to table size
Latency at scale Flat Grows with the table
Page size Up to 1 MB per call, then paginate Up to 1 MB per call, then paginate
FilterExpression Narrows results, not cost Narrows results, not cost
When acceptable Almost always Rare: small tables, admin exports, or parallel Scan for full re-index

If you find yourself Scanning in a hot path, the fix is not a bigger Scan — it is a better key or a GSI so the pattern becomes a Query. That redesign is the subject of DynamoDB single-table design.

Sort-key condition expressions

Within a Query, the sort-key condition is what turns “all of a customer’s orders” into “this customer’s July orders” or “their latest three.”

Sort-key condition Example Returns
= OrderId = :id One exact item
<, <=, >, >= CreatedAt >= :t Items on one side of a boundary
BETWEEN … AND … CreatedAt BETWEEN :a AND :b An inclusive range
begins_with(...) begins_with(OrderId, :prefix) A prefix slice (great for hierarchical keys)
(none) partition key only The whole item collection

Add --no-scan-index-forward to read a range in descending sort order (newest first), and --limit N to cap the page — together they express “latest N” cheaply.

Conditional writes, expressions and optimistic locking

Every write can carry a ConditionExpression that must be true or the write fails with ConditionalCheckFailedException (and consumes capacity but changes nothing). This is how you get correctness without read-modify-write races.

Pattern Expression Guards against
Create-if-absent attribute_not_exists(OrderId) Overwriting an existing item on PutItem
Update-if-present attribute_exists(OrderId) Creating a ghost item on UpdateItem
Optimistic locking Version = :expected Lost updates under concurrency
State-machine guard #s = :from (Status = “PLACED”) Illegal transitions (ship a cancelled order)
Idempotency attribute_not_exists(RequestId) Double-processing a retried request

Optimistic locking is worth internalising: store a Version number on the item, and on every UpdateItem both bump it (SET Version = Version + :one) and require the old value (ConditionExpression: Version = :cur). Two concurrent writers race; one wins, the other gets ConditionalCheckFailedException and re-reads. No pessimistic locks, no deadlocks.

Update expressions themselves are a small language:

Update clause Example Effect
SET SET #s = :new Assign / overwrite an attribute
SET + arithmetic SET Views = Views + :one Atomic counter increment
REMOVE REMOVE OldFlag Delete an attribute
ADD ADD Score :n Numeric add or add-to-set (legacy but useful)
DELETE DELETE Tags :t Remove elements from a set
list_append SET Items = list_append(Items, :more) Append to a list
if_not_exists SET Count = if_not_exists(Count, :zero) + :one Initialise-then-increment safely

Because many natural attribute names (Status, Name, Size, Count, Timestamp, Data) are reserved words, you alias them with ExpressionAttributeNames (#s) and always pass values via ExpressionAttributeValues (:new). Doing this by default avoids a whole category of ValidationException.

Batch vs transaction limits

Operation Max items Max size Atomic? Conditions? Updates?
BatchGetItem 100 16 MB No (per-item) No N/A
BatchWriteItem 25 16 MB No (per-item) No No (put/delete only)
TransactWriteItems 100 4 MB Yes (all-or-nothing) Yes Yes
TransactGetItems 100 4 MB Yes (snapshot) N/A N/A

Batches can return UnprocessedItems / UnprocessedKeys under throttling — you must retry those with backoff; the SDK does not always do it for you. Transactions are all-or-nothing and cost double, so use them only where you truly need cross-item atomicity (transfer money, reserve inventory), not as a default write path.

Secondary indexes: LSI vs GSI

An index lets you query the same data by a different key. DynamoDB has two kinds, and choosing wrong is expensive because an LSI can only be created with the table.

Attribute Local Secondary Index (LSI) Global Secondary Index (GSI)
Partition key Same as the table Any attribute (different from table)
Sort key A different attribute Any attribute
When created Only at table creation Any time, added/removed live
Max per table 5 20 (default soft limit)
Throughput Shares the table’s capacity Own capacity (or on-demand)
Consistency Strong or eventual Eventual only
Size limit impact Caps item collection at 10 GB per PK No item-collection cap
Keys must exist? Item indexed only if it has the sort key Item indexed only if it has both index keys

Decision table:

If you need… Use Because
Another sort order for the same partition key, with strong reads LSI Only LSI shares the partition and allows strong consistency
A query by a completely different attribute GSI GSI can re-partition on any attribute
To add the index to a live table GSI LSIs cannot be added after creation
Isolated throughput for the index workload GSI GSI has its own RCU/WCU
To avoid the 10 GB per-partition cap GSI LSI imposes the 10 GB item-collection limit

Projections — what the index carries

A GSI does not copy the whole item; you choose a projection, and querying a non-projected attribute forces an extra fetch from the base table (or returns nothing if you filtered on it).

Projection type Copies into the index Use when Trade-off
KEYS_ONLY Base + index keys only You only need to find keys, then GetItem the rest Smallest/cheapest index; extra read to hydrate
INCLUDE Keys + a named attribute list You need a few specific fields on the query Balance of size vs completeness
ALL Every attribute The query needs the full item Largest index; ~2× storage & write cost

A GSI is a materialised copy kept up to date asynchronously: every base write that touches projected attributes triggers a write to the index (billed separately, hence “GSI own capacity”). Under-provision the GSI and you throttle the base-table writes — a classic surprise covered in the playbook.

Table features: TTL, PITR, Streams, encryption, global tables

Beyond keys and capacity, a handful of table-level features turn a bare key-value store into a production data layer.

Feature What it does Cost Key limit / behaviour
TTL Auto-deletes items past an epoch timestamp attribute Free (deletes don’t cost WCU) Deletion within ~48 h of expiry, not instant
Point-in-time recovery (PITR) Continuous backups; restore to any second Per-GB-month of backup Rolling 35-day window; restores to a new table
On-demand backup Full, retained snapshot Per-GB-month Kept until you delete it; no performance impact
DynamoDB Streams Ordered change feed of item modifications Per read request 24-hour retention; per-shard ordering
Encryption at rest AES-256 via KMS, always on Owned key free; managed/CMK billed Cannot be disabled
Global tables Multi-Region, multi-active replication Cross-Region replicated writes (rWCU) Requires Streams; last-writer-wins

TTL specifics

TTL detail Behaviour
Attribute format A Number holding a Unix epoch time in seconds (not milliseconds)
Timing Background delete, typically within 48 hours of expiry — not real-time
Reads before delete Expired-but-not-yet-deleted items can still be returned — filter in the app if precision matters
Streams TTL deletes appear in Streams (with userIdentity = dynamodb, principal dynamodb.amazonaws.com)
Cost The deletes themselves consume no write capacity

TTL is how you keep tables lean — expire sessions, carts, verification codes, event logs — without a nightly cleanup job. Because it is eventual, never rely on it for correctness (a security token must be validated by its own expiry field, not merely “trusted to be gone”).

DynamoDB Streams view types

DynamoDB Streams emit an ordered, per-partition-key feed of every write; a Lambda function or Kinesis consumer reacts to it. This is the backbone of event-driven data flows — see Lambda event-driven patterns.

StreamViewType Record contains Use for
KEYS_ONLY Just the changed item’s keys “Something changed here” fan-out
NEW_IMAGE The item after the change Replicating current state, search indexing
OLD_IMAGE The item before the change Auditing what was removed/overwritten
NEW_AND_OLD_IMAGES Both before and after Change-data-capture, diffs, most flexible

For encryption, DynamoDB gives you three key options: the default AWS owned key (free, invisible), the AWS managed key aws/dynamodb, or a customer-managed key (CMK) for full control and its own key policy — the CMK path is what compliance-driven workloads pick. Global tables are the multi-Region story: they replicate a table to other Regions with active-active writes and last-writer-wins conflict resolution, giving low local latency and Regional failover; they build on Streams and are a topic in their own right.

Architecture at a glance

The diagram below is the mental model to burn in. Read it left to right: your app never chooses a server — it hands DynamoDB an item, and the partition key is hashed to route that item to exactly one physical partition (P1/P2/P3), where items sharing the key are stored sort-key ordered. A global secondary index re-projects the same data under a different key with its own capacity, updated asynchronously. The capacity mode you chose (on-demand or provisioned RCU/WCU) governs the cost and throttling of every hop, while table features (TTL, PITR, Streams, KMS) sit alongside. The numbered badges mark the six decisions that make or break the design — from “Query, never Scan” at the app, through the composite key and the per-partition ceilings, to the GSI’s own capacity and the capacity mode that prices it all.

Amazon DynamoDB data-model architecture: an app using the SDK sends PutItem and Query calls to an Orders table with a composite CustomerId partition key and OrderId sort key; the partition key is hashed to route items to physical partitions P1, P2 and P3, each capped at 10 GB, 3000 RCU and 1000 WCU, with items sort-key ordered inside; a global secondary index status-index re-projects the data under a Status partition key and CreatedAt sort key with its own capacity and a projection; a capacity-mode block shows on-demand versus provisioned RCU/WCU plus PITR, TTL, Streams and KMS encryption, with six numbered badges marking Query-not-Scan, the composite key, the 400 KB item limit, the per-partition ceilings, the GSI rules and the capacity mode.

Real-world scenario

ShopFleet, a fictional D2C retailer, launched an order-tracking service on DynamoDB and nearly torched it on their first flash sale. The original table used OrderId as a partition-key-only primary key — clean for GetItem(orderId), useless for the app’s real hottest query: “show me all of a customer’s orders, newest first.” Engineering had implemented that screen as a Scan with a FilterExpression on CustomerId. At 40,000 orders it was tolerable. At 6 million it read the whole table on every page load, consumed thousands of RRUs per request, and the “My Orders” page took 9 seconds.

The redesign was a keying change, not a rewrite. They created a new table with a composite key: partition key CustomerId, sort key OrderId encoded as ORDER#<date>#<seq> so it sorts chronologically. “All orders for a customer, newest first” became a single Query with --no-scan-index-forward and --limit 20 — a few RCUs, 8 ms, flat regardless of table size. For the ops team’s “all PLACED orders across everyone” screen they added a GSI status-index (partition key Status, sort key CreatedAt) with an INCLUDE projection of Amount and CustomerId, so the fulfilment dashboard queries the index directly without touching the base table’s capacity.

They left the table on-demand through the migration and the next sale — no capacity guessing while traffic was unknown. Post-sale analytics showed a stable daily curve (a predictable morning ramp, a flat plateau, a quiet night), so they moved the base table to provisioned with auto scaling: min 200 WCU / 400 RCU, max 4,000 each, target 70%. The GSI got its own auto-scaling policy because the fulfilment dashboard’s read pattern is spikier than the write path. They enabled PITR the day they went live (a one-line change that would have saved them during a bad bulk-update the previous quarter) and set TTL on a separate EventLog table so per-order audit events self-expire after 90 days instead of accumulating forever.

The measurable outcome:

Metric Before (Scan on wrong key) After (Query + GSI)
“My Orders” latency (p95) 9,000 ms 8 ms
Read units per page load ~3,100 RRU ~6 RRU
Behaviour at 10× data Linear slowdown Flat
Fulfilment dashboard Scan of base table GSI Query, isolated capacity
Monthly DynamoDB spend Spiking, unpredictable ~55% lower, predictable
Recovery from bad write None (no backups) PITR to any second in 35 days

The lesson ShopFleet took away is the lesson of this article: the partition key is the design. A one-attribute keying mistake turned every read into a table Scan; a one-attribute keying fix turned it into an indexed Query.

Advantages and disadvantages

Advantages Disadvantages
Single-digit-ms latency at any scale Access patterns must be designed up front
Fully serverless — no patching, no instances No ad-hoc queries, no joins, no SELECT *
Horizontal scale with even keys, effectively unbounded A skewed key throttles (hot partition)
On-demand absorbs spikes with zero planning On-demand can surprise you on cost at high volume
Fine-grained IAM down to the item/attribute 400 KB item cap; large blobs must live in S3
Built-in PITR, TTL, Streams, encryption, global tables GSIs cost extra storage + write capacity
Predictable, linear cost at steady provisioned load Provisioned under-sizing throttles; over-sizing wastes
ACID transactions when you need them Transactions cost 2× and cap at 100 items

When each matters: the advantages dominate for high-scale, well-understood, key-addressable workloads (carts, sessions, user profiles, IoT telemetry, event stores, order tracking). The disadvantages bite hardest for reporting, analytics and exploratory query workloads — for those, either add a GSI per known pattern, stream to a system built for ad-hoc query (Athena over S3, OpenSearch), or reconsider whether a relational store from the RDS/Aurora comparison fits better.

Hands-on lab

A complete, free-tier-friendly walk-through: create a composite-key table on-demand, write and read items, run a Query and see why Scan is worse, add and query a GSI, switch a copy to provisioned with auto scaling, enable TTL and PITR, then tear it all down. Everything runs in us-east-1; swap the Region if you prefer. ⚠️ On-demand requests and provisioned capacity beyond the 25 RCU/25 WCU always-free tier cost a few cents at this volume — the teardown at the end removes everything.

Step 1 — Create the table (composite key, on-demand).

aws dynamodb create-table \
  --table-name Orders \
  --attribute-definitions \
      AttributeName=CustomerId,AttributeType=S \
      AttributeName=OrderId,AttributeType=S \
  --key-schema \
      AttributeName=CustomerId,KeyType=HASH \
      AttributeName=OrderId,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --tags Key=env,Value=lab \
  --region us-east-1

aws dynamodb wait table-exists --table-name Orders

Expected: create-table returns the table description with "TableStatus": "CREATING"; the wait call blocks until it flips to ACTIVE, then returns silently.

Step 2 — Put a few items (with a create-if-absent guard).

aws dynamodb put-item --table-name Orders \
  --item '{
    "CustomerId": {"S": "CUST#42"},
    "OrderId":    {"S": "ORDER#2026-07-14#1001"},
    "Status":     {"S": "PLACED"},
    "Amount":     {"N": "2499"},
    "CreatedAt":  {"S": "2026-07-14T10:30:00Z"},
    "Version":    {"N": "1"}
  }' \
  --condition-expression "attribute_not_exists(OrderId)"

aws dynamodb put-item --table-name Orders \
  --item '{ "CustomerId": {"S": "CUST#42"}, "OrderId": {"S": "ORDER#2026-07-14#1002"},
            "Status": {"S": "PLACED"}, "Amount": {"N": "899"},
            "CreatedAt": {"S": "2026-07-14T11:05:00Z"}, "Version": {"N": "1"} }'

Expected: no output on success. Re-running the first command returns An error occurred (ConditionalCheckFailedException) — proof the guard works.

Step 3 — Get one item, strongly consistent, and see the cost.

aws dynamodb get-item --table-name Orders \
  --key '{"CustomerId": {"S": "CUST#42"}, "OrderId": {"S": "ORDER#2026-07-14#1001"}}' \
  --consistent-read \
  --return-consumed-capacity TOTAL

Expected: the item JSON plus "ConsumedCapacity": {"TableName": "Orders", "CapacityUnits": 1.0} — a small item, strong read, exactly 1 RCU.

Step 4 — Query the customer’s orders, newest first (the efficient path).

aws dynamodb query --table-name Orders \
  --key-condition-expression "CustomerId = :c AND begins_with(OrderId, :p)" \
  --expression-attribute-values '{":c": {"S": "CUST#42"}, ":p": {"S": "ORDER#2026-07"}}' \
  --no-scan-index-forward \
  --return-consumed-capacity TOTAL

Expected: both July orders, newest first, with CapacityUnits around 0.5 (small items, eventual read). This is the pattern to internalise: one partition key, a sort-key prefix, sorted, cheap.

Step 5 — Contrast with a Scan (the anti-pattern) and watch the cost.

aws dynamodb scan --table-name Orders \
  --filter-expression "#s = :st" \
  --expression-attribute-names '{"#s": "Status"}' \
  --expression-attribute-values '{":st": {"S": "PLACED"}}' \
  --return-consumed-capacity TOTAL

Expected: the same items, but the consumed capacity reflects every item read before the filter. On a two-item table the difference is trivial; imagine it at 6 million items and you understand ShopFleet’s 9-second page.

Step 6 — Add a GSI and query by Status.

aws dynamodb update-table --table-name Orders \
  --attribute-definitions \
      AttributeName=Status,AttributeType=S \
      AttributeName=CreatedAt,AttributeType=S \
  --global-secondary-index-updates '[{
    "Create": {
      "IndexName": "status-index",
      "KeySchema": [
        {"AttributeName": "Status",    "KeyType": "HASH"},
        {"AttributeName": "CreatedAt", "KeyType": "RANGE"}
      ],
      "Projection": {"ProjectionType": "INCLUDE", "NonKeyAttributes": ["Amount", "CustomerId"]}
    }
  }]'

aws dynamodb wait table-exists --table-name Orders   # index backfill runs async

aws dynamodb query --table-name Orders --index-name status-index \
  --key-condition-expression "#s = :st" \
  --expression-attribute-names '{"#s": "Status"}' \
  --expression-attribute-values '{":st": {"S": "PLACED"}}' \
  --return-consumed-capacity TOTAL

Expected: the GSI query returns both PLACED orders with only the projected attributes (Amount, CustomerId, plus the keys). Note the index takes a moment to backfill; a query immediately after creation may return fewer items until IndexStatus is ACTIVE (check with describe-table).

Step 7 — Update with an atomic counter and optimistic lock.

aws dynamodb update-item --table-name Orders \
  --key '{"CustomerId": {"S": "CUST#42"}, "OrderId": {"S": "ORDER#2026-07-14#1001"}}' \
  --update-expression "SET #s = :new ADD Version :one" \
  --condition-expression "Version = :cur" \
  --expression-attribute-names '{"#s": "Status"}' \
  --expression-attribute-values '{":new": {"S": "SHIPPED"}, ":one": {"N": "1"}, ":cur": {"N": "1"}}' \
  --return-values ALL_NEW

Expected: the updated item with Status: SHIPPED and Version: 2. Run it again unchanged and it fails with ConditionalCheckFailedException because Version is now 2, not 1 — optimistic locking in action.

Step 8 — Switch a COPY to provisioned + auto scaling. Do not flip the live table (mode changes are limited to once per 24 h and this keeps the lab reversible). Create a second table provisioned:

aws dynamodb create-table --table-name OrdersProvisioned \
  --attribute-definitions AttributeName=CustomerId,AttributeType=S AttributeName=OrderId,AttributeType=S \
  --key-schema AttributeName=CustomerId,KeyType=HASH AttributeName=OrderId,KeyType=RANGE \
  --billing-mode PROVISIONED \
  --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
aws dynamodb wait table-exists --table-name OrdersProvisioned

aws application-autoscaling register-scalable-target \
  --service-namespace dynamodb --resource-id "table/OrdersProvisioned" \
  --scalable-dimension "dynamodb:table:WriteCapacityUnits" \
  --min-capacity 5 --max-capacity 100

aws application-autoscaling put-scaling-policy \
  --service-namespace dynamodb --resource-id "table/OrdersProvisioned" \
  --scalable-dimension "dynamodb:table:WriteCapacityUnits" \
  --policy-name "orders-write-tt" --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 70.0,
    "PredefinedMetricSpecification": {"PredefinedMetricType": "DynamoDBWriteCapacityUtilization"}
  }'

Expected: the scalable target and policy register; from now on write capacity tracks 70% utilisation between 5 and 100 WCU. (Repeat the two Application Auto Scaling calls with ReadCapacityUnits / DynamoDBReadCapacityUtilization to scale reads too.)

Step 9 — Enable TTL and PITR on the main table.

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

aws dynamodb update-continuous-backups --table-name Orders \
  --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true

Expected: TTL returns "TimeToLiveStatus": "ENABLING"; PITR returns "PointInTimeRecoveryStatus": "ENABLED". Any item you now write with an ExpiresAt number (epoch seconds in the past) will be background-deleted within ~48 hours.

Step 10 — Teardown (⚠️ deletes everything, stops all charges).

aws application-autoscaling deregister-scalable-target \
  --service-namespace dynamodb --resource-id "table/OrdersProvisioned" \
  --scalable-dimension "dynamodb:table:WriteCapacityUnits"

aws dynamodb delete-table --table-name OrdersProvisioned
aws dynamodb delete-table --table-name Orders
aws dynamodb wait table-not-exists --table-name Orders
aws dynamodb wait table-not-exists --table-name OrdersProvisioned

Deleting a table drops its GSIs, Streams, TTL config and PITR settings with it. Confirm with aws dynamodb list-tables — neither table should appear.

The whole lab as Terraform

The same result declaratively. main_table is on-demand with a GSI, TTL, PITR, Streams and encryption; provisioned_table shows provisioned mode with a target-tracking auto-scaling policy.

resource "aws_dynamodb_table" "main_table" {
  name         = "Orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "CustomerId"
  range_key    = "OrderId"

  attribute { name = "CustomerId" type = "S" }
  attribute { name = "OrderId"    type = "S" }
  attribute { name = "Status"     type = "S" }
  attribute { name = "CreatedAt"  type = "S" }

  global_secondary_index {
    name               = "status-index"
    hash_key           = "Status"
    range_key          = "CreatedAt"
    projection_type    = "INCLUDE"
    non_key_attributes = ["Amount", "CustomerId"]
  }

  ttl {
    attribute_name = "ExpiresAt"
    enabled        = true
  }

  point_in_time_recovery { enabled = true }

  server_side_encryption { enabled = true } # AWS managed KMS key aws/dynamodb

  stream_enabled   = true
  stream_view_type = "NEW_AND_OLD_IMAGES"

  tags = { env = "lab" }
}

resource "aws_dynamodb_table" "provisioned_table" {
  name           = "OrdersProvisioned"
  billing_mode   = "PROVISIONED"
  read_capacity  = 5
  write_capacity = 5
  hash_key       = "CustomerId"
  range_key      = "OrderId"

  attribute { name = "CustomerId" type = "S" }
  attribute { name = "OrderId"    type = "S" }
}

resource "aws_appautoscaling_target" "write" {
  max_capacity       = 100
  min_capacity       = 5
  resource_id        = "table/${aws_dynamodb_table.provisioned_table.name}"
  scalable_dimension = "dynamodb:table:WriteCapacityUnits"
  service_namespace  = "dynamodb"
}

resource "aws_appautoscaling_policy" "write" {
  name               = "orders-write-tt"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.write.resource_id
  scalable_dimension = aws_appautoscaling_target.write.scalable_dimension
  service_namespace  = aws_appautoscaling_target.write.service_namespace

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "DynamoDBWriteCapacityUtilization"
    }
    target_value = 70
  }
}

terraform apply builds it; terraform destroy is your teardown. Note that in Terraform you only declare an attribute {} block for attributes used as a table or index key — never for ordinary data attributes, a point the troubleshooting section returns to.

Common mistakes & troubleshooting

DynamoDB fails in a small, learnable set of ways. Here is the playbook — symptom, root cause, the exact command or console path to confirm it, and the fix.

# Symptom Root cause Confirm (command / console) Fix
1 ValidationException: One or more parameter values were invalid: Missing the key CustomerId PutItem/GetItem omitted a key attribute Re-read the error; aws dynamodb describe-table --table-name Orders --query 'Table.KeySchema' Include every key attribute (both PK and SK for a composite key) in --item / --key
2 ValidationException: Type mismatch for key CustomerId expected: S actual: N Wrong attribute type sent for a key describe-tableAttributeDefinitions Send the declared type ({"S": …} not {"N": …}); types are fixed at creation
3 ValidationException: Attribute name is a reserved keyword; reserved keyword: Status Used a reserved word directly in an expression Check the AWS reserved-words list Alias with ExpressionAttributeNames (#s) and reference #s
4 Query returns zero items but the data exists Wrong key condition — e.g. filtering on a non-key, or using = where a prefix was needed Run the Query with --return-consumed-capacity TOTAL; verify the PK value exactly Query must include partition key =; use begins_with/BETWEEN on the sort key, not a FilterExpression on the partition key
5 Query on a GSI “loses” a just-written item GSIs are eventually consistent and update asynchronously Repeat the query after ~1 s; GetItem the base table (strong) confirms the write landed Don’t read-after-write on a GSI; query the base table for immediate consistency
6 GSI query returns items missing attributes The attribute isn’t in the index projection describe-table → the index Projection Recreate/extend the GSI with INCLUDE/ALL, or GetItem the base table to hydrate
7 Scan is slow and expensive Scan reads the whole table; FilterExpression doesn’t reduce cost --return-consumed-capacity TOTAL shows units ≫ items returned Replace the Scan with a Query via a better key or a GSI
8 ProvisionedThroughputExceededException Consumed capacity exceeded provisioned (often a hot partition, not the table total) CloudWatch ConsumedWriteCapacityUnits vs ThrottledRequests; enable per-partition insight Raise capacity / enable auto scaling; if one key is hot, re-shard it — see the throttling playbook
9 ThrottlingException on writes even though the table looks under capacity An under-provisioned GSI throttles the base write CloudWatch on the index dimension Provision (or auto-scale) the GSI independently; or move the table to on-demand
10 Item size has exceeded the maximum allowed size (400 KB) Item + all attributes over 400 KB Estimate size; find the fat attribute Move large blobs to S3 and store the key; split or compress
11 On-demand bill far higher than expected Every request billed; a Scan-heavy or chatty read path multiplies RRUs Cost Explorer by usage type; --return-consumed-capacity on hot calls Fix the access pattern (Query not Scan), cache, or move steady traffic to provisioned
12 ConditionalCheckFailedException on a normal write The ConditionExpression was false (item exists, or version moved) Inspect the item with GetItem Expected for optimistic locking/idempotency — re-read and retry; only a bug if the condition is wrong
13 Terraform: ValidationException: … number of attributes in KeySchema does not exactly match number of attributes defined in AttributeDefinitions Declared an attribute {} for a non-key attribute terraform plan diff Only declare attribute {} blocks for key attributes (table or index); data attributes are schemaless
14 ResourceInUseException on create / update Table already exists, or an index build is in progress aws dynamodb describe-tableTableStatus / IndexStatus Wait for ACTIVE; you can only have one index operation at a time
15 BatchWriteItem “succeeded” but some items are missing Throttling returned them as UnprocessedItems Inspect the response UnprocessedItems field Retry unprocessed items with exponential backoff

Error / exception reference

Exception HTTP-ish class Meaning Typical trigger First move
ValidationException 400 client Malformed request Missing key, wrong type, reserved word, bad expression Fix the request; check key schema + expression names
ConditionalCheckFailedException 400 client A ConditionExpression was false Optimistic lock lost, item exists/absent Re-read and retry; verify the condition is intended
ProvisionedThroughputExceededException 400 (throttle) Exceeded provisioned capacity Under-provisioning or hot partition Auto-scale / raise capacity; re-shard hot key
ThrottlingException 400 (throttle) Request rate too high (often GSI/control-plane) Under-provisioned GSI; too many DDL ops Back off + retry; scale the GSI
ResourceInUseException 400 Resource busy / already exists Create over existing table; concurrent index op Wait for ACTIVE; serialise DDL
ResourceNotFoundException 400 Table/index does not exist Typo, wrong Region, not yet created Check name + --region; wait table-exists
ItemCollectionSizeLimitExceededException 400 LSI item collection > 10 GB One PK value grew past 10 GB under an LSI Redesign the key; use a GSI instead of an LSI
TransactionCanceledException 400 A transaction was cancelled A condition failed or items conflicted Read CancellationReasons; fix the failing condition
RequestLimitExceeded 400 Account/table request-rate ceiling On-demand table above its throughput limit Request a limit increase; pre-warm; shard
InternalServerError 500 server Transient AWS-side error Rare, transient Retry with backoff (SDK does this by default)

The two nastiest real failures

“My read-after-write test is flaky.” A test writes an item then immediately queries a GSI to assert it exists, and fails ~5% of the time. This is not a bug — a GSI is eventually consistent and is updated after the base write is acknowledged. The write did land; the index just had not caught up. The fix is architectural: for read-after-write correctness, read the base table with --consistent-read; use the GSI only for queries that tolerate a small lag. Teams burn days “fixing” phantom data-loss that is simply GSI propagation.

“We’re throttling but the table is 5% utilised.” CloudWatch shows the table’s total ConsumedWriteCapacityUnits far below provisioned, yet writes throttle. Two usual culprits: a hot partition (all writes hammer one partition key, which is capped at ~1,000 WCU regardless of table total), or an under-provisioned GSI (the base write cannot complete because the index write is throttled). Confirm by checking throttling on the index dimension in CloudWatch and by looking at per-key access with CloudWatch Contributor Insights for DynamoDB. The fix is either re-sharding the hot key (add a suffix to spread it) or giving the GSI its own capacity — the full treatment is in DynamoDB throttling & hot-partition troubleshooting.

Best practices

Security notes

DynamoDB security is IAM-first and encryption-always. There is no network listener you expose; access is entirely through the AWS API gated by IAM.

Control Mechanism Practical guidance
Authorization IAM identity-based policies on dynamodb:* actions Grant only the actions used (e.g. GetItem, Query, PutItem), never dynamodb:* in prod
Fine-grained access dynamodb:LeadingKeys condition key Restrict a role to items whose partition key matches the caller’s identity (multi-tenant isolation)
Attribute-level dynamodb:Attributes + projection expression Let a role read/write only named attributes
Encryption at rest KMS, always on Use a CMK for compliance and its own key policy; AWS-owned key otherwise
Encryption in transit TLS to the DynamoDB endpoint Enforced; use the regional endpoint
Private connectivity VPC gateway endpoint for DynamoDB Keep traffic off the public internet from private subnets — see VPC subnets & security groups
Auditing CloudTrail data events for DynamoDB Enable to log item-level reads/writes; pairs with PITR for forensics
Deletion protection Table setting Enable on prod tables to block accidental delete-table

The most powerful and least-used control is the dynamodb:LeadingKeys condition: in a multi-tenant table keyed on TenantId, a policy condition ties each tenant’s role to their own partition-key prefix, so one query can never read another tenant’s items — enforced by IAM, not application code.

Cost & sizing

Four things drive a DynamoDB bill: request throughput (RCU/WCU or RRU/WRU), stored data (per GB-month), indexes (extra storage + write units), and features (PITR/backup storage, Streams reads, global-table replicated writes). Approximate us-east-1 list prices as of 2026 — always verify current pricing, which AWS adjusts:

Cost driver On-demand Provisioned
Writes ~$0.625 per million WRU ~$0.00065 per WCU-hour
Reads ~$0.125 per million RRU ~$0.00013 per RCU-hour
Storage ~$0.25 per GB-month (first 25 GB free-tier) Same
PITR ~$0.20 per GB-month of backup Same
On-demand backup ~$0.10 per GB-month Same
Streams ~$0.02 per 100k read requests (first 2.5M free) Same

Rough INR at ~₹84/USD: a million on-demand writes ≈ ₹52; a million reads ≈ ₹10; a GB stored ≈ ₹21/month. The always-free tier (not just 12 months) covers 25 GB storage + 25 provisioned RCU + 25 provisioned WCU — enough for ~200 million requests/month — plus 25 GB of backup and 2.5M Streams reads. Sizing guidance:

Workload shape Recommended mode Why
New / unknown traffic On-demand No capacity to guess; no throttling while you learn
Spiky / bursty On-demand Absorbs spikes to 2× peak instantly
Steady, predictable, high-volume Provisioned + auto scaling Cheaper per request; policy chases the curve
Flat baseline + rare known spikes Provisioned, raise min before the spike Cheapest steady state, planned headroom
Dev / test / demo On-demand or 25/25 free-tier provisioned Effectively free at low volume

The classic cost trap is a Scan-heavy read path on on-demand: each Scan bills for the whole table, and at millions of items a single page view can cost thousands of RRUs. Fix the access pattern before you fix the mode. The second trap is over-provisioning “to be safe” — idle RCUs/WCUs bill 24×7; auto scaling with a sane min exists precisely so you do not.

Interview & exam questions

Q: What are the two primary-key types, and when do you use each? Partition-key-only (simple) for pure key-value get/put by a unique id; composite (partition + sort) when you need multiple items per partition key and range/prefix queries like “all orders for a customer.” (CLF-C02, DVA-C02, SAA-C03)

Q: How does the partition key affect performance? It is hashed to choose a physical partition; all items with the same value share one partition (capped ~10 GB, ~3,000 RCU, ~1,000 WCU). A high-cardinality, evenly-accessed key spreads load; a skewed key creates a hot partition and throttles. (DVA-C02, SAA-C03)

Q: 1 RCU and 1 WCU — define them. 1 RCU = one strongly-consistent read/sec of an item up to 4 KB (or two eventually-consistent, or half a transactional); 1 WCU = one write/sec up to 1 KB (transactional = 2). Reads round up to 4 KB, writes to 1 KB. (DVA-C02)

Q: On-demand vs provisioned — how do you choose? On-demand bills per request and absorbs spikes with no planning (spiky/new workloads); provisioned reserves capacity, is cheaper at steady high volume, and can auto-scale. Start on-demand, move to provisioned when traffic is predictable. (CLF-C02, SAA-C03)

Q: Query vs Scan? Query reads one partition key’s items by key condition and is billed on what it returns; Scan reads the entire table and filters after, billed on everything read. Prefer Query; a FilterExpression never reduces cost. (DVA-C02)

Q: LSI vs GSI? LSI shares the table’s partition key with a different sort key, must be created with the table, shares capacity, and allows strong reads. GSI uses any attributes as its key, can be added live, has its own capacity, and is eventually consistent only. (DVA-C02, SAA-C03)

Q: Can you do a strongly-consistent read on a GSI? No — GSIs are always eventually consistent. Strong reads are available only on the base table and LSIs. (DVA-C02)

Q: What is the maximum item size and how do you handle bigger objects? 400 KB per item (names + values). Store large payloads in S3 and keep the object key (plus metadata) in DynamoDB. (DVA-C02, SAA-C03)

Q: How do you prevent lost updates under concurrency? Optimistic locking: keep a Version attribute, and on each UpdateItem bump it while requiring the prior value via a ConditionExpression; the loser gets ConditionalCheckFailedException and retries. (DVA-C02)

Q: What does TTL do and what are its limits? Auto-deletes items past an epoch-seconds attribute, within ~48 hours (not instant), at no write cost; deletes flow into Streams. Do not rely on it for precise or security-critical expiry. (DVA-C02, SOA-C02)

Q: What is PITR and its window? Point-in-time recovery keeps continuous backups and restores to any second within a rolling 35-day window, into a new table. (SAA-C03, SOA-C02)

Q: When would you use DynamoDB Streams? To react to item changes: trigger a Lambda for CDC, replication, search indexing, or event-driven workflows. Choose the view type (KEYS_ONLY / NEW_IMAGE / OLD_IMAGE / NEW_AND_OLD_IMAGES) for the data you need; 24-hour retention. (DVA-C02)

Quick check

  1. You need “the 20 most recent orders for a customer.” What key design and what call makes this cheap?
  2. An item is 9 KB and you read it with strong consistency. How many RCUs does that consume?
  3. Why can a table throttle with ProvisionedThroughputExceededException while CloudWatch shows the table only 5% utilised?
  4. You add a GSI on Email, write a user, and immediately query the GSI by email — sometimes it returns nothing. Bug or expected? Why?
  5. Which single feature, enabled with one command, lets you recover from a bad bulk write up to 35 days later?

Answers

  1. A composite key (partition CustomerId, sort key a chronological OrderId/timestamp) and a Query with --no-scan-index-forward --limit 20. One partition, sorted, only 20 items read.
  2. 3 RCUs. 9 KB rounds up to the next 4 KB boundary (12 KB) → 12 ÷ 4 = 3 for a strong read (an eventual read would be 1.5).
  3. A hot partition (or an under-provisioned GSI): one partition key is capped at ~1,000 WCU / ~3,000 RCU regardless of the table’s total, so a skewed key throttles while the aggregate looks idle.
  4. Expected. GSIs are eventually consistent and updated asynchronously after the base write; the item exists (a strong GetItem on the base table proves it), the index just hasn’t propagated yet.
  5. Point-in-time recovery (PITR)update-continuous-backups … PointInTimeRecoveryEnabled=true.

Glossary

Term Definition
Item A single record in a table, uniquely identified by its primary key, ≤ 400 KB.
Attribute A typed name-value pair on an item; schemaless except for key attributes.
Partition (hash) key The attribute hashed to select a physical partition; governs distribution.
Sort (range) key The attribute that orders items within one partition key, enabling ranges.
Composite key A primary key made of a partition key + a sort key.
Item collection All items sharing one partition-key value.
RCU / WCU Read / write capacity unit — the provisioned throughput currency.
RRU / WRU Read / write request unit — the per-request on-demand currency.
Strongly consistent read A read reflecting all prior writes; base table / LSI only; 1 RCU.
Eventually consistent read The default read; may lag ~1 s; ½ RCU; only option on a GSI.
LSI Local secondary index: same partition key, different sort key, created with the table.
GSI Global secondary index: any key, own capacity, live-addable, eventually consistent.
Projection The attribute set copied into an index (KEYS_ONLY / INCLUDE / ALL).
TTL Time to live: auto-expiry of items past an epoch-seconds attribute.
PITR Point-in-time recovery: continuous backups restorable within 35 days.
DynamoDB Streams An ordered 24-hour change feed of item modifications.
Hot partition A partition key taking disproportionate traffic and throttling at its per-partition cap.
Optimistic locking Concurrency control via a version attribute checked with a ConditionExpression.

Next steps

AWSDynamoDBNoSQLPartition KeyCapacity ModesGSIServerlessDVA-C02
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading