NoSQL · 03

Amazon DynamoDB

DynamoDB answers a small set of questions very fast at any scale, and punishes the rest. This page teaches the model from the ground up: how keys map to partitions, why Scan is a smell, how indexes really work, a balanced view of single-table design, a complete e-commerce model, the capacity arithmetic, conditions, locking and transactions, and the operational features around them, with tested Java code and a local lab.

13
Chapters
400 KB
Item limit
4 KB / 1 KB
Read / write unit
SDK 2.57
Java SDK v2
01
Chapter One - The Model

What DynamoDB Is and How Data Is Shaped

Amazon DynamoDB is a fully managed key-value and document database. You do not pick servers, patch anything or manage partitions: you create a table, say how it is keyed, and DynamoDB spreads the data across storage partitions and replicates it across Availability Zones. The trade is that it answers a small, fixed set of questions very efficiently (give me the item with this key, give me the items under this key in this order) and answers almost nothing else cheaply. Everything on this page follows from that sentence.

ConceptWhat it isRelational analogy
TableA collection of items. No fixed schema except the key attributesTable (without the column definitions)
ItemOne record: a set of attributes, uniquely identified by its primary keyRow
AttributeA named value. Items in one table may have different attributesColumn (but per item, not per table)
Primary keyPartition key, or partition key plus sort key. Unique per item, set at table creationPrimary key, but it also decides where the data lives
Secondary indexA second key layout over the same data (chapter 04)Index, but with its own storage and its own capacity
Attribute types
KindTypesNotes
ScalarS string, N number, B binary, BOOL, NULLOnly S, N and B can be key attributes. Numbers travel as strings on the wire
DocumentL list, M mapNest up to 32 levels deep (verified in the Constraints page)
SetSS, NS, BSUnordered, unique values of one type

Limits that shape design (checked against the DynamoDB Developer Guide, Constraints and Quotas pages, 2026-10-31; sizes use 1 KB = 1024 bytes):

LimitValue
Maximum item size, including attribute names and values400 KB
Partition key value length1 to 2048 bytes
Sort key value length1 to 1024 bytes
One Query or Scan call returns at most1 MB of evaluated data (then you page, chapter 03)
BatchGetItem / BatchWriteItem100 items, 16 MB / 25 put or delete requests, 16 MB
Table sizeNo practical limit
Why the item limit matters early

400 KB is generous for a record and tiny for a blob. Store large payloads (documents, images) in S3 and keep the object key and metadata in the item. Because attribute names count toward the 400 KB, very long attribute names repeated across millions of items also cost you storage and read units.

Key insight: A relational database lets you ask any question and pays at query time. DynamoDB makes you decide the questions at design time and then pays almost nothing at query time. Designing a DynamoDB table is writing down the queries first.
02
Chapter Two - Keys and Partitions

Partition Key, Sort Key and Where Data Lives

Every item must have a partition key. DynamoDB feeds its value into an internal hash function, and the result decides which storage partition holds the item. That is why a read by key is fast: hash the key, go straight to one partition. It is also why items are not stored in any useful order across partitions.

Partition routing: the partition key is hashed to pick a partition
requests Put pk = CUSTOMER#17 Get pk = CUSTOMER#17 Put pk = ORDER#o-1002 hash(partition key) same key, same hash, same partition Partition A items spread evenly Partition B HOT: one key takes most traffic Partition C items spread evenly Partition D items spread evenly Each partition has its own ceiling: 3000 read units and 1000 write units per second (documented partition maximum). Adaptive capacity moves capacity toward a busy partition and can isolate a frequently used item, but it cannot make one single key exceed what one partition can serve. Spread load across many distinct partition key values.

A composite primary key adds a sort key. All items with the same partition key form an item collection, stored together and ordered by the sort key. That gives you Query: fetch the items of one collection, optionally narrowed by a condition on the sort key (equals, less than, between, begins_with), in ascending or descending order.

Key shapeLets youTypical use
Partition key onlyGetItem / PutItem by exact keySessions, user profiles, anything fetched by id
Partition key + sort keyEverything above plus Query over a range inside one collectionOrders per customer, events per device, messages per chat
How partitions grow and stay balanced
  • DynamoDB allocates more partitions automatically when you raise throughput beyond what the current ones can serve, or when a partition fills up. You never manage this.
  • Burst capacity: DynamoDB retains up to about five minutes of unused capacity to absorb short spikes. The docs note these details may change, so treat it as a cushion, not a plan.
  • Adaptive capacity is on for every table at no extra cost. It shifts capacity toward partitions that receive more traffic, within the table total, and can isolate hot items onto their own partitions.
  • Hard edges still exist: the documented per-partition maximum is 3000 read units and 1000 write units per second. A single item hammered harder than that throttles no matter how much you provision.
  • Without local secondary indexes, an item collection can be split across as many partitions as needed. With an LSI, one collection is capped at 10 GB (chapter 04).
What a good partition key looks like
PropertyGoodBad
CardinalityMillions of distinct values (customer id, device id, order id)status, country, true/false, a date
Access spreadTraffic spread roughly evenly over keysOne celebrity customer or "TODAY" key takes most traffic
Time seriesKey = device id, sort key = timestampKey = date, so every write today hits one partition
⚠ Low-cardinality or time-based partition key

A table keyed by status has a handful of partitions doing all the work while the rest idle, and a key like 2026-10-31 sends the entire day's writes to one place. What you see: ProvisionedThroughputExceededException (or throttled requests in on-demand) while CloudWatch shows the table well below its total capacity. Fix: a high-cardinality key, or write sharding, appending a suffix such as #0..#9 to a hot key and reading all suffixes back, trading one extra query fan-out for write spread.

03
Chapter Three - Reading Data

GetItem, Query, Scan and Pagination

OperationWhat it needsCost shapeUse for
GetItemFull primary keyOne item, one lookupFetch by id
BatchGetItemUp to 100 full keysEach item priced as its own GetItemFetch many known keys
QueryPartition key value, optional sort key conditionOnly the one item collection is readRange, list, "latest N" inside one key
ScanNothingEvery item in the table or index is read, then filteredExports, one-off maintenance, small tables
Why Scan is a smell in the request path

Scan reads the whole table (or index) in 1 MB pages. Cost and latency grow with table size, not with the number of matches, and the reads compete with your real traffic for capacity. A FilterExpression does not help the cost: it is applied after the items are read, before results are returned, so the read units are the same with or without it, and the 1 MB limit also applies before the filter runs. The same is true of a filter on a Query.

QuestionScan with filterQuery on a key designed for it
"Orders of customer c-17"Reads every order of every customerReads only c-17's collection
Cost grows withWhole table sizeResult size
At 10x data10x the reads for the same answerSame cost for the same answer
Legitimate useBackfills, analytics export, tiny reference tablesEverything user-facing
Parallel Scan and exports

If you really need to read everything (migration, backfill), Scan supports parallel scan (Segment / TotalSegments), and DynamoDB can export to S3 for analytics without consuming table read capacity. Reach for those instead of a loop of scans against a live table.

Pagination with LastEvaluatedKey

A Query or Scan stops at 1 MB of evaluated data or at your Limit, whichever comes first. If there is more, the response contains LastEvaluatedKey. To get the next page, send the same request with ExclusiveStartKey set to that value. When LastEvaluatedKey is absent, you have everything. Two traps:

  • **A page can be empty or short and still have a LastEvaluatedKey**, especially with a filter. Loop until the key is absent; do not stop at the first empty page.
  • Limit caps items evaluated per page, not items returned after a filter.
  • The key is opaque state: hand it to the client as an encoded token if you build a paginated API, and treat it as untrusted input when it comes back.
Request 1: Query pk = CUSTOMER#c-17, Limit 2
▶
Returns two items plus LastEvaluatedKey = {pk, sk} of the last item evaluated
Request 2: same query, ExclusiveStartKey = that key
▶
Resumes right after it and returns the next two items
Last page: fewer items evaluated than the limit and no more data
▶
The response has no LastEvaluatedKey: done
⚠ Reading "no LastEvaluatedKey" as "the first page was empty"

With a FilterExpression, a page can evaluate 1 MB of items, discard all of them and return zero items with a LastEvaluatedKey. Code that stops at the first empty page silently drops data. Paginate until the key is missing.

The Java enhanced client's PageIterable hides this loop for you when you iterate items, and exposes each Page (with lastEvaluatedKey()) when you iterate pages. The sample in chapter 10 shows both.

04
Chapter Four - Secondary Indexes

LSI, GSI, Projections and Back-Pressure

The primary key gives you one access path. A secondary index gives you another one over the same items, with a different partition and/or sort key. DynamoDB maintains it for you; you only ever write to the table.

Local secondary index (LSI)Global secondary index (GSI)
KeySame partition key as the table, different sort keyAny partition key and optional sort key
CreatedWhen the table is created (not added later)Any time, with UpdateTable; backfilled from existing data
Per tableUp to 5Up to 20 by default (quota, adjustable)
Read consistencyEventually or strongly consistentEventually consistent only
CapacityShares the base table'sIts own, separate from the table's (inherits the table's mode)
Item collection limit10 GB per partition key value (table plus its LSIs together)None
Key attribute typesS, N or B for key attributesSame
A GSI is a separate, asynchronously updated copy with its own capacity
Client Base table pk = CUSTOMER#17 GSI gsi1 pk = ORDERSTATUS#PENDING 1 write 2 ack 3 async copy milliseconds normally, longer in rare failures GSI cannot absorb its writes: base-table writes are throttled Query gsi1 eventually consistent only A write succeeds only if the base table and every affected GSI have write capacity for it. A read right after a write can miss the item on the GSI. No ConsistentRead on a GSI. Size GSI write capacity like the table.
Projection: what is copied into the index
ProjectionIndex containsTrade-off
KEYS_ONLYIndex keys plus the table's primary keySmallest and cheapest to maintain; a full item needs a second GetItem
INCLUDEKeys plus the attributes you listGood when the list view needs a few fields. Up to 100 projected attributes across all of a table's indexes (quota)
ALLWhole itemOne-hop reads, but every write is copied in full: storage and write cost multiply

If a query asks for an attribute that is not projected, DynamoDB fetches it from the base table, at higher latency and higher read cost. Project what your query returns, not more.

How a GSI behaves
  • Asynchronous. Updates reach a GSI usually within a fraction of a second, but your code must tolerate a query that does not yet see a recent write, or that still shows a deleted one.
  • Sparse by construction. An item appears in the GSI only if it has the index key attributes. Removing the attribute removes the item from the index. That makes a GSI an efficient "work queue" of items in a state (for example orders still pending).
  • Back-pressure. The write that changes a base-table item also writes to each affected GSI, consuming the GSI's write capacity. If the GSI does not have enough, the write to the base table is throttled (the docs call this out, including while a new GSI is being created and backfilled). In provisioned mode the guidance is: give each GSI at least the table's write capacity.
  • Hot GSI keys are table problems. A GSI keyed by a low-cardinality value such as ORDERSTATUS#PENDING funnels all pending-order writes into a single index key, and the throttling it causes lands on your base table.
  • Each GSI costs. More indexes multiply write units and storage; add one only for a real, frequent access pattern.
⚠ Using a GSI for a strongly consistent read

ConsistentRead=true on a GSI query is rejected, since GSIs only support eventually consistent reads. If you must read your own write, read the base table (or an LSI) with ConsistentRead, or design the flow so the writer already knows the result (return it from the write, not from a re-query).

05
Chapter Five - Modelling

Access Patterns First, and Single-Table Design Without the Religion

Relational design starts from entities and normalisation and sorts out queries later. DynamoDB design runs the other way, and the AWS guidance is explicit: do not design the schema until you know the questions it must answer, and keep the number of tables low.

1. List the access patterns
▶
Every read and write, with expected frequency, result size and latency need: "list a customer's orders, newest first", "load an order with its lines"
2. Choose primary keys for the hottest patterns
▶
Pick partition keys that spread load, and sort keys that put the data in the order you read it
3. Cover leftovers with GSIs
▶
One index per remaining query shape; check each against the cost rules of chapter 04
4. Check the sharp edges
▶
Hot keys, item size, item collection growth, transactions, consistency needs
5. Write the table down
▶
A key-schema table and sample items (chapter 06) is the real schema document of a DynamoDB system
Single-table design: when it helps and when it hurts

Single-table design stores several entity types (customers, orders, order lines) in one table, using generic key names (pk, sk) and prefixes in the values (CUSTOMER#..., ORDER#...). Related items share a partition key, so **one Query returns a customer and its orders, and a transaction** can span entity types with one table in one call. It is a technique, not a rule, and the trade-offs are real.

HelpsHurts
ReadsOne round trip for related items (parent plus children)Reading one entity type needs prefix discipline; results of mixed types need type-aware mapping
OperationsFewer tables, alarms, backups and IAM policies to manageCapacity, TTL, streams and PITR are per table: you cannot tune or restore one entity type alone
EvolutionCheap to add an entity that fits existing keysA genuinely new access pattern can force a re-key and a backfill of the whole table
TeamCompact once everyone knows the key conventionsOpaque to newcomers and to ad-hoc analysis; harder to read in the console
Analytics / exportsPossibleMixed-type export needs decoding before it is useful
Hot spotsShared table capacity smooths peaksOne noisy entity type can throttle the others in the same partition
A balanced rule of thumb
  • Use one table per bounded context or microservice and put entities in the same table when you read them together or transact over them.
  • Use separate tables when entities have different lifecycles, throughput profiles, retention (TTL) or security boundaries, or when the team would struggle to maintain the key conventions.
  • Single-table is an optimisation for specific access patterns, not a goal. A clear multi-table design beats a clever single-table one nobody can read.
Modelling techniques you will meet again
TechniqueWhat it doesExample
Item collectionRelated items share a partition key and sort by sort keypk = ORDER#o-1001, sk = LINE#1, LINE#2
Sort-key prefixA type tag in the sort key lets one Query pick a type with begins_withsk = ORDER#... vs sk = PROFILE
Composite sort keyConcatenate attributes so sort order matches a readORDER#<createdAt>#<orderId> sorts by time, breaks ties by id
Sparse indexOnly some items carry the index keyPending orders only
Write shardingAdd a suffix to a hot key and read all suffixesORDERSTATUS#PENDING#0 ... #9
DenormalisationCopy data you read together into one itemCustomer name copied onto the order
Store large objects elsewhereItem keeps the pointerInvoice PDF in S3, key in the item
⚠ Unbounded item collections

A partition key whose collection grows forever (all events of a busy tenant under one key, every comment under one post) leads to ever larger Query pages, hot keys, and, if the table has an LSI, a hard stop at 10 GB for that key. Bound collections by design: add a time bucket to the key (TENANT#t1#2026-10), cap or archive, or TTL old items.

06
Chapter Six - Worked Model

E-Commerce: Customers, Orders and Order Lines

Let us apply chapter 05 to a small shop. This is the model the Java sample in chapter 10 implements and the hands-on in chapter 11 runs. First the access patterns, with frequency and the operation that serves each:

#Access patternFrequencyOperationKeys used
A1Load a customer profileVery highGetItempk = CUSTOMER#id, sk = PROFILE
A2List a customer's orders, newest first, pagedHighQuery (descending)pk = CUSTOMER#id, sk begins_with ORDER#
A3Load all lines of one orderHighQuerypk = ORDER#id
A4Work list: orders awaiting shipment, oldest firstMediumQuery on GSIgsi1pk = ORDERSTATUS#PENDING
A5Place an order: check customer, write header and lines atomicallyHigh (writes)TransactWriteItemsCondition checks + puts
A6Count of orders per customerMediumUpdateItem with ADDAtomic counter on the profile
A7Ship an order exactly onceMediumVersioned UpdateItemOptimistic locking on the order
Key schema
Item typepkskgsi1pk / gsi1skOther attributes
CustomerCUSTOMER#<customerId>PROFILE(none)name, email, orderCount
OrderCUSTOMER#<customerId>ORDER#<createdAt>#<orderId>ORDERSTATUS#PENDING / <createdAt> while pending, absent afterorderId, status, totalCents, version
Order lineORDER#<orderId>LINE#<n>(none)sku, qty, priceCents
The e-commerce table: key layout and the sparse GSI (sample data)
pk = CUSTOMER#c-17 sk = PROFILE sk = ORDER#2026-10-31T10:15:00.000Z#o-1001 sk = ORDER#2026-10-31T11:40:00.000Z#o-1002 item collection: one Query returns profile and orders pk = ORDER#o-1001 sk = LINE#1 sku-12 qty 2 sk = LINE#2 sku-47 qty 1 GSI gsi1 (gsi1pk, gsi1sk) ORDERSTATUS#PENDING | 10:15:00 ORDERSTATUS#PENDING | 11:40:00 Profile and order lines carry no gsi1 keys, so they are not in the index (sparse). When an order ships, remove gsi1pk and gsi1sk: it leaves the index and the pending list shrinks. Order header lives with the customer (list newest first); order lines live under the order (load all lines of one order). Timestamps are fixed-width so text order equals time order.
Text - three sample items in the table (conceptual form, not wire format)
{ "pk": "CUSTOMER#c-17", "sk": "PROFILE", "name": "Asha", "email": "asha@example.com", "orderCount": 2 } { "pk": "CUSTOMER#c-17", "sk": "ORDER#2026-10-31T10:15:00.000Z#o-1001", "orderId": "o-1001", "status": "PENDING", "totalCents": 5997, "gsi1pk": "ORDERSTATUS#PENDING", "gsi1sk": "2026-10-31T10:15:00.000Z", "version": 1 } { "pk": "ORDER#o-1001", "sk": "LINE#1", "sku": "sku-12", "qty": 2, "priceCents": 1999 }
Design decisions worth saying out loud
  • The order header sits under the customer so A2 is one Query; the lines sit under the order so A3 is one Query. Two item collections, two queries, no join.
  • **The sort key ORDER#<createdAt>#<orderId> orders by time and keeps keys unique when two orders share a millisecond. The timestamp must be fixed width** (always three fractional digits). Java's Instant.toString() drops trailing zeros, so .4Z would sort after .41Z: format it explicitly.
  • **gsi1pk = ORDERSTATUS#PENDING is deliberately a hot key** to show the pattern's limit. It is fine for hundreds of writes per second at most; at real scale shard it (#0..#N) and query all shards.
  • A customer with unbounded orders is acceptable here: ORDER# items carry the time, the list is paged, and no LSI caps the collection. Archive or TTL old orders if it ever matters.
  • Denormalise on purpose: copy the shipping address and unit prices into the order and line items. An order is a historical fact; it must not change when the customer or catalogue does.
07
Chapter Seven - Capacity

On-Demand, Provisioned and the Unit Arithmetic

On-demandProvisioned (with auto scaling)
You pay forEach request you make (read and write request units)Capacity you provision per hour, used or not
Capacity planningNone. Scales with trafficYou set read and write capacity units (RCU/WCU); auto scaling adjusts within min and max
Spiky or unknown trafficFits wellAuto scaling reacts after a threshold breach (two consecutive minutes, plus alarm delay), so sharp spikes can throttle
Steady, predictable trafficCan cost more than well-utilised provisioned capacityUsually cheaper, and reserved capacity can lower it further
SafetyOptional maximum throughput per table/index to bound cost and runaway useFixed ceiling = provisioned capacity (plus burst)
SwitchingProvisioned to on-demand: up to four times in a rolling 24 hours. On-demand to provisioned: any timeProvisioned decreases are limited per day (starts with 4 available, +1 per hour)

On-demand is described in the docs as the default and recommended option. A new on-demand table can sustain up to 4,000 writes and 12,000 reads per second, and on-demand instantly accommodates up to double the previous peak; more than double within 30 minutes can throttle, so a launch that jumps far past anything the table has seen should be pre-warmed or ramped. Default per-table quotas are 40,000 read and 40,000 write units per second and are adjustable (quotas page, 2026-10-31).

The unit arithmetic

One read capacity unit reads one item up to 4 KB per second with a strongly consistent read; an eventually consistent read costs half; a transactional read costs double. One write capacity unit writes one item up to 1 KB; a transactional write costs double. Sizes round up: reads to the next 4 KB multiple, writes to the next 1 KB multiple.

OperationStrongly consistentEventually consistentTransactional
Read, item up to 4 KB1 RCU0.5 RCU2 RCU
Write, item up to 1 KB1 WCUn/a2 WCU

Worked examples (the arithmetic follows the documented rounding rules; the item sizes are made up):

ExampleRounded sizeStrongly consistentEventually consistentTransactional
Read one 3.5 KB item4 KB1 RCU0.5 RCU2 RCU
Read one 6 KB item8 KB2 RCU1 RCU4 RCU
Read one 10 KB item12 KB3 RCU1.5 RCU6 RCU
Query returns 10 items totalling 40.8 KB44 KB (rounded once for the whole query)11 RCU5.5 RCUn/a
BatchGetItem of 1.5 KB + 6.5 KB items4 + 8 = 12 KB (rounded per item)3 RCU1.5 RCUn/a
Write one 500-byte item1 KB1 WCU2 WCU
Write one 2.5 KB item3 KB3 WCU6 WCU
UpdateItem growing an item from 1.2 KB to 2.4 KB3 KB (the larger of before and after)3 WCU

Sizing a workload (example): 200 eventually consistent reads per second of 10 KB items need 200 x 1.5 = 300 RCU. 50 writes per second of 2.5 KB items need 50 x 3 = 150 WCU; if every write also changes one GSI that projects the whole item, the GSI needs about another 150 WCU, which is the "GSI at least as large as the table" rule in numbers.

GotchaDetail (docs, read/write operations page)
Scan bills for what it readsEvaluated item sizes count, not returned ones. An example only: scanning a 1 GB table reads about 262,144 4 KB units, 131,072 RCU eventually consistent
Projection does not reduce costAsking for fewer attributes does not change the item size used for read units
Reading a missing item still costsRead units are consumed even when the key does not exist
Failed conditional writes still costA condition that evaluates to false still consumes write capacity based on item size
Cancelled transactions still costCapacity is consumed even when the transaction is cancelled
COUNT is not freeCounting reads every matching item
A cost mental model (qualitative)
  • You pay for request units or provisioned capacity, storage, and optional features (global table replication writes, backups and restores, DAX nodes, export and import). Prices vary by Region and change over time: use the pricing page and the AWS calculator, not a number from a tutorial.
  • Writes are the expensive direction: roughly one unit per KB, doubled by transactions, multiplied by every GSI that is touched, multiplied again by every global table replica Region.
  • Eventually consistent reads are half price: use them wherever staleness is acceptable.
  • Item size is a cost lever twice: every extra KB adds write units, and every extra 4 KB adds read units. Keep hot items small and move cold bulk to S3.
  • A Standard-IA table class exists for tables where storage dominates cost (old order history, logs); the Standard class is the default and is recommended for most workloads.
08
Chapter Eight - Writes

Conditions, Optimistic Locking, Counters and Transactions

Consistency options at a glance
Read typeBehaviourWhere available
Eventually consistent (default)May not reflect a write that just completed; converges quicklyTables, LSIs, GSIs
Strongly consistent (ConsistentRead=true)Reflects every write that succeeded before the read; costs doubleTables and LSIs only. Not GSIs, not streams
Transactional (TransactGetItems)A consistent, isolated read of several itemsTables (not indexes)

A successful write response means the write is durably persisted. DynamoDB gives read-committed isolation: you never see a value from a write that did not succeed, but nothing stops another writer changing the item right after your read. That gap is what conditions close.

Conditional writes

PutItem, UpdateItem and DeleteItem accept a **ConditionExpression**. DynamoDB evaluates it against the current stored item and applies the write only if it is true, otherwise it throws ConditionalCheckFailedException. The check and the write are one atomic step on the server, so this is the primitive for: create-if-absent (attribute_not_exists(pk)), update-only-if-exists, state transitions (status = :pending), and stock checks (stock >= :qty).

Optimistic locking with a version attribute

Add a numeric version to the item. Read the item, change it in memory, then write with the condition version = :versionYouRead while setting version = version + 1. If somebody else wrote in between, the condition fails and you reload and retry (or give up). No locks are held, so nothing blocks, and a lost update becomes a detectable error. The Java enhanced client automates exactly this with @DynamoDbVersionAttribute (chapter 10).

Worker A and B both read order o-1001 at version 1
▶
Both plan to mark it shipped
A writes with condition version = 1
▶
Succeeds, item is now SHIPPED, version 2
B writes with condition version = 1
▶
ConditionalCheckFailedException: stale. B reloads, sees SHIPPED, and stops
Atomic counters

UpdateItem with SET n = n + :v or ADD n :v increments inside DynamoDB, with no read first, and concurrent increments do not lose updates. It is not idempotent: if a call times out and you retry, you may count twice. Use it for approximate or tolerant counters (views, per-customer order counts). For exact accounting under retries, use a conditional write keyed by a unique event id, or a transaction. A single counter item is also a hot key at high rates.

Transactions

TransactWriteItems groups up to 100 actions (Put, Update, Delete, ConditionCheck) into one all-or-nothing, idempotent operation, over distinct items in one or more tables in the same account and Region, with an aggregate size of at most 4 MB. TransactGetItems reads a set of items consistently. Facts from the transactions page:

  • You cannot target the same item twice in one transaction (for example a ConditionCheck and an Update on one item).
  • Transactions work on tables, not indexes.
  • Each item costs two underlying writes (prepare and commit): a transactional write of an item up to 1 KB uses 2 WCU, and the capacity is consumed even if the transaction is cancelled.
  • A ClientRequestToken makes the call idempotent: the token is valid for 10 minutes after the request finishes, and reusing it with different parameters returns an IdempotentParameterMismatch error. The SDKs generate a token for you.
  • A conflict with another in-flight transaction on the same item fails with TransactionCanceledException; retry with backoff.
  • On a multi-Region eventually consistent global table a transaction is atomic only in the Region where it ran. Replication is not transactional, so other Regions can briefly see part of it. Multi-Region strong consistency tables do not support transactions at all.
TransactWriteItems: prepare every item, then commit, or cancel everything
Client TransactWriteItems up to 100 actions ConditionCheck customer exists Put order header attribute_not_exists Put LINE#1 Put LINE#2 phase 1: prepare, evaluate conditions phase 2: commit all or nothing All succeed: all visible Any fails: nothing applied TransactionCanceledException Two underlying writes per item (prepare and commit): double capacity, charged even if cancelled. CancellationReasons: one entry per action, in request order. Limits: 100 distinct items, 4 MB.
When to prefer a conditional write over a transaction

A single-item rule (create once, transition state, decrement stock) needs only a conditional UpdateItem: cheaper (one write, not two) and faster. Reach for a transaction when several items must change together (header plus lines, balance transfer, uniqueness record plus main item). Because DynamoDB has no foreign keys or joins, transactions plus conditions are how you enforce cross-item invariants.

⚠ Retrying a non-idempotent write blindly

A timeout does not tell you whether the write happened. Retrying an ADD counter or an unconditioned PutItem of a derived value can double-apply. What you see: counts slightly above reality after incidents. Fix: make writes idempotent with a conditional on a unique id, a stored processed-event marker, or a TransactWriteItems client token.

09
Chapter Nine - Beyond One Table

TTL, Streams, Global Tables, DAX, Backups and PartiQL

TTL: expiry without write cost

Time to Live deletes items after a timestamp you store. Enable it on the table and name an attribute holding a Number in Unix epoch seconds; items whose attribute is not a Number are ignored. Behaviour that surprises people:

  • Deletion is asynchronous. Expired items are deleted by a background process, typically within a few days of expiry, not at the instant. There is no stricter guarantee in the docs.
  • Expired items remain readable until deleted. Filter them out in your reads with a FilterExpression comparing the TTL attribute to now.
  • The deletion consumes no write capacity in the Region where it happens. The deleted item flows to streams (marked as a service deletion rather than a user delete) and out of indexes, like any delete.
  • With global tables, the TTL delete is replicated and does consume replicated write capacity in the other Regions.
  • You can still update an expired item before it is deleted, including pushing the TTL forward; use a condition so you do not resurrect an item that was just deleted.
Streams: change data capture

DynamoDB Streams records item-level changes in a time-ordered log kept for up to 24 hours. You choose the view when enabling it: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE or NEW_AND_OLD_IMAGES. Each record appears exactly once in the stream, and changes to the same item appear in order (there is no ordering across different items). Stream reads are eventually consistent. A Lambda event source mapping polls the shards and invokes your function with batches; by default one function instance processes one shard, and ParallelizationFactor (up to 10) raises concurrency while keeping per-item order. Amazon Kinesis Data Streams is an alternative destination when you need longer retention or more consumers.

Change capture: table to stream to consumers (the outbox shape)
App writes order + outbox item Table committed Stream 24 hours Lambda Other consumer Stream records appear once, in order per item (not across items), for up to 24 hours. Consumers can still see a record more than once (retries, a failed batch), so make them idempotent. Lambda publishes the change to SNS, SQS, EventBridge or Kafka: the DB commit is the source of truth. No dual write: the event cannot be lost because the write committed.

This is the natural implementation of the transactional outbox: write the business item and an event item in one TransactWriteItems (or just use the stream of the business table itself), then let a Lambda publish to your broker. See 07 Event-Driven Patterns for outbox, idempotent consumers and the dual-write problem this solves.

⚠ Treating the stream as an unlimited log

Records live up to 24 hours. A consumer that is down longer loses events. For replay or audit, copy to Kinesis, S3 export or an events table. Also: more simultaneous readers than documented (two per shard for single-Region tables) can cause throttling.

Global tables: multi-Region replication
MREC (multi-Region eventual consistency)MRSC (multi-Region strong consistency)
Default?YesNo: chosen at creation, cannot be changed later
ReplicationAsynchronous, typically within a secondSynchronous to at least one other Region before the write returns
ConflictsLast writer wins, by latest internal timestamp, per itemStrongly consistent reads on any replica return the latest version; conditions see the latest version
RegionsAny number of replica RegionsExactly three: three replicas, or two replicas and one witness
TransactionsAtomic only within the writing RegionNot supported
TTL / LSISupportedNot supported (GSIs are)
LatencyLocal writes and readsWrites and strongly consistent reads cross Regions

With MREC, a conditional write is evaluated against the item as it exists in that Region, and concurrent writes to one item in two Regions are resolved by timestamp, so one of them is silently overwritten. Design so that each item is mostly written in one Region (home-Region routing), or so a last-writer-wins merge is acceptable. Facts from the global tables pages, current version 2019.11.21, checked 2026-10-31.

DAX, backups and PartiQL
FeatureWhat it gives youRemember
DAX (DynamoDB Accelerator)An in-memory cache cluster in front of DynamoDB, API compatible; serves GetItem, BatchGetItem, Query, Scan cache hits in memoryOnly eventually consistent reads are served from cache; strongly consistent reads pass through and are not cached. Writes are write-through. It adds a cluster to run and a cache-staleness window
On-demand backupFull table snapshot on requestRestore is a managed operation; check the docs for which settings come back with it
Point-in-time recovery (PITR)Continuous backups, restore to any second within a window of up to 35 daysOff by default: enable it on tables you care about
PartiQLA SQL-compatible syntax (SELECT, INSERT, UPDATE, DELETE) over DynamoDBIt is just another syntax for the same operations: a SELECT without the key in its WHERE still becomes a scan
Export to S3Analytics copy without consuming table read capacityThe answer to "I need to run SQL over this"
10
Chapter Ten - Java

AWS SDK v2 Enhanced Client: Beans, Queries, Locking and Transactions

The DynamoDB Enhanced Client (software.amazon.awssdk:dynamodb-enhanced) maps annotated Java beans to items. Every snippet below comes from one program that was compiled with JDK 21 against AWS SDK for Java 2.57.8 (latest release on Maven Central when this page was written) and run against DynamoDB Local; the full output is in chapter 11.

XML - the dependency (pom.xml)
<dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>dynamodb-enhanced</artifactId> <version>2.57.8</version> </dependency>
A bean for the order item
Java - Order.java (getters and setters trimmed to the annotated ones)
@DynamoDbBean public class Order { private String pk; // CUSTOMER#c-17 private String sk; // ORDER#2026-10-31T10:15:00.000Z#o-1001 private String orderId; private String status; private long totalCents; private String gsi1pk; // ORDERSTATUS#PENDING (removed when shipped -> sparse index) private String gsi1sk; // createdAt private Long version; @DynamoDbPartitionKey public String getPk() { return pk; } @DynamoDbSortKey public String getSk() { return sk; } @DynamoDbSecondaryPartitionKey(indexNames = "gsi1") public String getGsi1pk() { return gsi1pk; } @DynamoDbSecondarySortKey(indexNames = "gsi1") public String getGsi1sk() { return gsi1sk; } @DynamoDbVersionAttribute // optimistic locking, see markShipped below public Long getVersion() { return version; } // ... plain getters/setters for the other fields; beans need a public no-arg constructor }

The annotations sit on the getters. @DynamoDbVersionAttribute lives in software.amazon.awssdk.enhanced.dynamodb.extensions.annotations, the others in ...enhanced.dynamodb.mapper.annotations. Several beans (Customer, Order, OrderLine) can map to the same table: build one DynamoDbTable per bean with enhanced.table("shop", TableSchema.fromBean(Order.class)), which is how single-table design looks in the enhanced client.

Client and table setup (DynamoDB Local)
Java - client pointed at DynamoDB Local
static DynamoDbClient lowLevel() { return DynamoDbClient.builder() .endpointOverride(URI.create("http://localhost:8000")) // DynamoDB Local .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create("local", "local"))) // any value works locally .build(); }
Java - create the table with a GSI, on-demand
static void createTable(DynamoDbClient ddb) { ddb.createTable(CreateTableRequest.builder() .tableName("shop") .billingMode(BillingMode.PAY_PER_REQUEST) .attributeDefinitions( AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(), AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build(), AttributeDefinition.builder().attributeName("gsi1pk").attributeType(ScalarAttributeType.S).build(), AttributeDefinition.builder().attributeName("gsi1sk").attributeType(ScalarAttributeType.S).build()) .keySchema( KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(), KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build()) .globalSecondaryIndexes(GlobalSecondaryIndex.builder() .indexName("gsi1") .keySchema( KeySchemaElement.builder().attributeName("gsi1pk").keyType(KeyType.HASH).build(), KeySchemaElement.builder().attributeName("gsi1sk").keyType(KeyType.RANGE).build()) .projection(Projection.builder().projectionType(ProjectionType.ALL).build()) .build()) .build()); }

PAY_PER_REQUEST is on-demand. Against real AWS, drop endpointOverride and the static credentials: the default credentials provider chain and region resolution apply.

Query a customer's orders, newest first, with paging
Java - Query with QueryConditional, descending, limit and ExclusiveStartKey
/** Newest-first page of a customer's orders; resumes from a previous page's key. */ static List<Order> listOrders(DynamoDbTable<Order> orders, String customerId, Map<String, AttributeValue> startKey, int pageSize) { QueryEnhancedRequest.Builder q = QueryEnhancedRequest.builder() .queryConditional(QueryConditional.sortBeginsWith( Key.builder().partitionValue("CUSTOMER#" + customerId).sortValue("ORDER#").build())) .scanIndexForward(false) // descending by sort key = newest first .limit(pageSize); // max items evaluated per page if (startKey != null) q.exclusiveStartKey(startKey); Page<Order> page = orders.query(q.build()).stream().findFirst().orElseThrow(); System.out.println(" lastEvaluatedKey = " + page.lastEvaluatedKey()); // null on the last page return page.items(); }

QueryConditional.sortBeginsWith builds the key condition. scanIndexForward(false) is the descending order. limit is the per-page evaluation limit. Iterating the PageIterable item by item would follow LastEvaluatedKey automatically; the sample takes the first Page explicitly to show the token.

Java - query a GSI
static List<Order> pendingOrders(DynamoDbTable<Order> orders) { DynamoDbIndex<Order> gsi1 = orders.index("gsi1"); List<Order> out = new ArrayList<>(); for (Page<Order> page : gsi1.query(QueryConditional.keyEqualTo( Key.builder().partitionValue("ORDERSTATUS#PENDING").build()))) { out.addAll(page.items()); } return out; }
Conditional put and optimistic locking
Java - create-if-absent with a condition expression
/** Create the customer only if it does not exist yet (idempotent sign-up). */ static boolean createCustomerOnce(DynamoDbTable<Customer> customers, Customer c) { try { customers.putItem(PutItemEnhancedRequest.builder(Customer.class) .item(c) .conditionExpression(Expression.builder() .expression("attribute_not_exists(pk)").build()) .build()); return true; } catch (ConditionalCheckFailedException e) { return false; // somebody created it first } }
Java - versioned update (the extension adds the version condition)
/** Read-modify-write guarded by the @DynamoDbVersionAttribute on Order. */ static void markShipped(DynamoDbTable<Order> orders, Order loaded) { loaded.setStatus("SHIPPED"); loaded.setGsi1pk(null); // leaves the sparse "pending" index loaded.setGsi1sk(null); try { // updateItem(item) = UpdateItem with ignoreNulls=false: null attributes are removed. // The versioned-record extension adds: version = :expected, and bumps it to expected+1. orders.updateItem(loaded); } catch (ConditionalCheckFailedException e) { throw new IllegalStateException("order changed under us, reload and retry", e); } }

With @DynamoDbVersionAttribute, the client's default VersionedRecordExtension writes a new item with version 1 and, on later putItem / updateItem, adds a condition that the stored version equals the one you loaded, then increments it. A stale writer gets ConditionalCheckFailedException. Passing null attributes to updateItem(item) removes them from the stored item, which is how the sample drops the order out of the sparse index.

Atomic counter (low-level client)
Java - ADD expression through the low-level client
/** Atomic counter: the increment happens inside DynamoDB, no read needed. */ static void bumpOrderCount(DynamoDbClient ddb, String customerId) { ddb.updateItem(UpdateItemRequest.builder() .tableName("shop") .key(Map.of("pk", AttributeValue.fromS("CUSTOMER#" + customerId), "sk", AttributeValue.fromS("PROFILE"))) .updateExpression("ADD orderCount :one") .expressionAttributeValues(Map.of(":one", AttributeValue.fromN("1"))) .build()); }
Transaction: place an order
Java - TransactWriteItems across entity types in one table
/** Atomically: customer must exist, order must be new, then write order + lines. */ static void placeOrder(DynamoDbEnhancedClient enhanced, DynamoDbTable<Customer> customers, DynamoDbTable<Order> orders, DynamoDbTable<OrderLine> lines, String customerId, String orderId, Map<String, Integer> skuQty) { String createdAt = TS.format(Instant.now()); // fixed width: sorts correctly as text Order o = new Order(); o.setPk("CUSTOMER#" + customerId); o.setSk("ORDER#" + createdAt + "#" + orderId); o.setOrderId(orderId); o.setStatus("PENDING"); o.setGsi1pk("ORDERSTATUS#PENDING"); o.setGsi1sk(createdAt); o.setTotalCents(skuQty.values().stream().mapToLong(q -> q * 1999L).sum()); Expression mustExist = Expression.builder().expression("attribute_exists(pk)").build(); Expression mustBeNew = Expression.builder().expression("attribute_not_exists(pk)").build(); TransactWriteItemsEnhancedRequest.Builder tx = TransactWriteItemsEnhancedRequest.builder() .addConditionCheck(customers, ConditionCheck.builder() .key(Key.builder().partitionValue("CUSTOMER#" + customerId).sortValue("PROFILE").build()) .conditionExpression(mustExist) .build()) .addPutItem(orders, TransactPutItemEnhancedRequest.builder(Order.class) .item(o).conditionExpression(mustBeNew).build()); int n = 1; for (var e : skuQty.entrySet()) { OrderLine l = new OrderLine(); l.setPk("ORDER#" + orderId); l.setSk("LINE#" + n++); l.setSku(e.getKey()); l.setQty(e.getValue()); l.setPriceCents(1999); tx.addPutItem(lines, l); } try { enhanced.transactWriteItems(tx.build()); } catch (TransactionCanceledException ex) { // one CancellationReason per action, in request order; code is e.g. ConditionalCheckFailed ex.cancellationReasons().forEach(r -> System.out.println(" reason: " + r.code())); throw ex; } }

The enhanced ConditionCheck is imported explicitly from ...enhanced.dynamodb.model, because the low-level ...services.dynamodb.model package has a class with the same name. TransactionCanceledException.cancellationReasons() returns one reason per action in request order: in the sample, an unknown customer shows ConditionalCheckFailed for the first action and None for the other two.

Handling retries and throttling
  • The SDK retries throttling and transient errors for you with backoff; keep your own retry loop for ConditionalCheckFailedException (reload and decide) and TransactionCanceledException (inspect the reasons: conflict vs condition).
  • TransactionCanceledException for a conflict is retryable; for a failed condition it is not.
  • Prefer the SDK's DynamoDbClient singleton: it is thread-safe and expensive to create.
11
Chapter Eleven - Hands-On and Common Mistakes

Run It Locally with DynamoDB Local

DynamoDB Local is a downloadable emulator of the DynamoDB API. The official image is amazon/dynamodb-local on Docker Hub; it runs java -jar DynamoDBLocal.jar -inMemory by default and listens on port 8000. The AWS documentation's own compose example uses -jar DynamoDBLocal.jar -sharedDb -dbPath ./data to persist data to a mounted folder. It accepts any access key, and region and credentials only need to be present.

Shell - start DynamoDB Local (in memory, data is lost when it stops)
$ docker run -d --rm --name ddb-local -p 8000:8000 amazon/dynamodb-local:latest $ export AWS_ACCESS_KEY_ID=local AWS_SECRET_ACCESS_KEY=local AWS_DEFAULT_REGION=us-east-1 $ aws dynamodb list-tables --endpoint-url http://localhost:8000
YAML - persistent variant with Docker Compose (from the AWS documentation pattern)
services: dynamodb-local: image: "amazon/dynamodb-local:latest" container_name: dynamodb-local command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data" ports: - "8000:8000" volumes: - "./docker/dynamodb:/home/dynamodblocal/data" working_dir: /home/dynamodblocal

Now run the chapter 10 program (the project is a plain Maven project with the dynamodb-enhanced dependency and ShopDemo as main class). It creates the table, creates a customer twice (second time rejected), places five orders through the transaction, tries one for an unknown customer, pages through orders, reads the GSI, ships one order and shows a stale writer rejected. Output below is the real output of one run, with the long lastEvaluatedKey lines shortened (abridged):

Text - output of ShopDemo against DynamoDB Local (abridged)
create customer first time: true create customer second time: false reason: ConditionalCheckFailed reason: None reason: None unknown customer -> transaction cancelled customer: 5 orders counted lastEvaluatedKey = {sk=ORDER#2026-10-31T...#o-1004, pk=CUSTOMER#c-17} page1 o-1005 page1 o-1004 lastEvaluatedKey = {sk=ORDER#2026-10-31T...#o-1002, pk=CUSTOMER#c-17} page2 o-1003 page2 o-1002 pending via GSI: 5 pending after ship: 4 stale writer rejected: order changed under us, reload and retry

Explore with the CLI. --limit with --no-paginate shows the raw LastEvaluatedKey; without it, the CLI follows pages for you and prints a NextToken instead:

Shell - Query the item collection, then compare Query and Scan cost
$ aws dynamodb query --endpoint-url http://localhost:8000 --table-name shop \ --key-condition-expression 'pk = :p AND begins_with(sk, :s)' \ --expression-attribute-values '{":p":{"S":"CUSTOMER#c-17"},":s":{"S":"ORDER#"}}' \ --no-scan-index-forward --limit 2 --no-paginate $ aws dynamodb query --endpoint-url http://localhost:8000 --table-name shop \ --key-condition-expression 'pk = :p' \ --expression-attribute-values '{":p":{"S":"CUSTOMER#c-17"}}' \ --select COUNT --return-consumed-capacity TOTAL $ aws dynamodb scan --endpoint-url http://localhost:8000 --table-name shop \ --select COUNT --return-consumed-capacity TOTAL

Observe: the Query touches only the customer's collection while the Scan counts the whole table (11 items in this run: 1 customer, 5 orders, 5 lines). The tiny data hides the cost difference, but ScannedCount shows it. DynamoDB Local is for functional tests: its capacity accounting, throttling, partitioning, Streams timing and global tables are not a model of the real service, so do not benchmark or capacity-plan against it. Stop it with docker stop ddb-local.

Common mistakes
MistakeWhat you seeFix
Hot key (celebrity customer, TODAY key, single counter)Throttling on one partition while the table is mostly idleSpread keys; write-shard; cache reads (DAX or app cache); batch counters
Low-cardinality partition keyFew partitions busy, throttles at low total trafficPick a key with millions of values
Scan in the request pathLatency and cost rise with table size; throttles neighboursAdd an index or redesign keys; export for analytics
Big itemsHigher cost per read and write, near the 400 KB capMove blobs to S3; split cold attributes into another item
Unbounded item collectionsHuge query pages; LSI tables hit the 10 GB per key capTime-bucket keys, archive or TTL
GSI with a hot or low-cardinality keyBase-table writes throttledShard the GSI key; size GSI write capacity like the table
Reading from a GSI right after a writeItem missing for a momentReturn data from the write, or read the table
Assuming TTL deletes on timeExpired items still returnedFilter on the TTL attribute in reads
Assuming sort keys sort numbers/dates correctly as stringsWrong order: 2 after 10, variable-width timestamps misorderedFixed-width formats, or numeric attributes
Trusting ConsistentRead after global-table replicationStale data from another Region (MREC)Route the item to one home Region, or use MRSC
12
Chapter Twelve - Interview

Questions and Answers

  • Q: What is DynamoDB and when would you choose it?
    A managed key-value/document database with single-digit-millisecond access at any scale, no servers to manage. Choose it when access patterns are known and key-based (profiles, carts, sessions, orders by customer), when you need elastic scaling and low operations. Avoid it for ad-hoc queries, joins and analytics.
  • Q: What is the difference between a partition key and a sort key?
    The partition key is hashed to pick the storage partition. The sort key orders items within one partition key value (an item collection), enabling Query with range conditions. Together they form the unique primary key.
  • Q: Query vs Scan vs GetItem?
    GetItem fetches one item by full key. Query reads one item collection by partition key plus an optional sort key condition. Scan reads the whole table or index and filters afterwards, so cost follows table size. Scan belongs in exports and maintenance, not in request paths.
  • Q: Does a FilterExpression reduce read cost?
    No. It is applied after items are read, so consumed capacity is the same and the 1 MB per call limit applies before filtering. Put the selective condition into the key design.
  • Q: How does pagination work?
    A Query or Scan returns at most 1 MB of evaluated data (or your Limit). If there is more, LastEvaluatedKey is returned; pass it back as ExclusiveStartKey. Absent key means done. A page can be empty and still have a key when a filter is used.
  • Q: How big can an item be?
    400 KB, including attribute names and values. Larger payloads go to S3 with a pointer in the item.
  • Q: LSI vs GSI?
    LSI: same partition key, different sort key, created with the table, supports strongly consistent reads, shares table capacity, and caps an item collection at 10 GB. GSI: any keys, can be added later, own capacity, eventually consistent only.
  • Q: Can a GSI affect the base table?
    Yes. Writes also update each affected GSI using the GSI's write capacity. If the GSI lacks capacity, the base-table write is throttled. Size GSI write capacity at least like the table and avoid hot GSI keys.
  • Q: What is single-table design and its downsides?
    Several entity types in one table with generic keys and prefixes so related items share a partition key and can be fetched or transacted together. Downsides: harder to read and evolve, per-table settings (TTL, streams, PITR, capacity) cannot be tuned per entity, and a new access pattern may force re-keying. Use it where co-access justifies it.
  • Q: How are read and write capacity units computed?
    Read unit = one strongly consistent read of up to 4 KB per second, eventually consistent costs half, transactional double; sizes round up to the next 4 KB. Write unit = one write of up to 1 KB, transactional double; sizes round up to the next 1 KB. A query rounds the total size once, a batch rounds per item.
  • Q: On-demand or provisioned?
    On-demand for new, spiky or unpredictable workloads (pay per request, instant scaling up to double the previous peak). Provisioned with auto scaling for steady, predictable traffic where it is cheaper, optionally with reserved capacity. You can switch, with limits on how often.
  • Q: What is a hot partition and how do you handle it?
    A partition receiving traffic beyond its documented 3000 read / 1000 write units per second ceiling, usually from a skewed key. Adaptive capacity helps with uneven traffic but cannot exceed the partition maximum for one item. Spread keys, shard writes, cache reads.
  • Q: How do you do optimistic locking?
    A numeric version attribute: read it, then write with a condition that the version still equals what you read, incrementing it. A concurrent writer makes the condition fail, and you reload and retry. The enhanced client does this with @DynamoDbVersionAttribute.
  • Q: Are atomic counters safe?
    They never lose concurrent increments, but they are not idempotent: a retried call after a timeout can count twice. Fine for tolerant counters; use conditions or transactions with unique ids for exact counts.
  • Q: What are the limits of TransactWriteItems?
    Up to 100 actions over distinct items (no two actions on one item), aggregate size up to 4 MB, tables not indexes, same account and Region. It uses double capacity and is idempotent with a client token valid for 10 minutes.
  • Q: When does TTL delete an item?
    Asynchronously, typically within a few days after the expiry timestamp, at no write cost. Expired items can still be read until deleted, so filter them. Deletes appear in streams and replicate through global tables.
  • Q: How do Streams and Lambda support the outbox pattern?
    Streams record changes in order per item for 24 hours. A Lambda event source mapping publishes each change to a broker, so the event comes from the committed write rather than a dual write. Consumers must be idempotent because delivery to your function can repeat.
  • Q: How do global tables resolve conflicts?
    In the default MREC mode, replication is asynchronous and concurrent writes to one item are resolved last-writer-wins by timestamp. MRSC mode (exactly three Regions) replicates synchronously and offers strongly consistent reads across Regions, but has no transactions, TTL or LSIs.
  • Q: When would you use DAX?
    To cut latency and read load for read-heavy, eventually consistent workloads (hot items). It does not cache strongly consistent reads, is write-through, and adds a cluster and a staleness window. A local application cache is the lighter alternative.
  • Q: Design question: a leaderboard top 100 per game.
    Table keyed by game and player, with a GSI keyed by game (sharded if one game is very hot) and a score sort key to read descending. A single-key "top 100" read is a Query limit 100 on that GSI. Mention eventual consistency of the GSI, write amplification and sharding the hot game key.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
TopicRememberValue
Item sizeIncludes attribute names400 KB
Key lengthPartition / sort key value2048 / 1024 bytes
Query / Scan pageThen use LastEvaluatedKey1 MB per call
BatchBatchGetItem / BatchWriteItem100 items / 25 requests (16 MB)
Read unitRound up to 4 KBSC 1, EC 0.5, transactional 2
Write unitRound up to 1 KBStandard 1, transactional 2
Partition ceilingPer partition, per second3000 read / 1000 write units
LSICreated with table, 10 GB per keyUp to 5 per table
GSIEventually consistent, own capacity20 per table by default
TransactionsDistinct items, tables only100 actions, 4 MB, token 10 min
StreamsOrdered per item24 hour retention
TTLEpoch seconds as a NumberDeleted typically within a few days
PITRContinuous, per secondUp to 35 days
Global tablesMREC default (LWW) or MRSC (3 Regions)Mode fixed at creation
∑ DynamoDB - what to remember
  • Design from the access patterns: write the queries first, then the keys, then indexes.
  • The partition key decides everything: distribution, hot spots, and which questions are cheap.
  • **Query is the workhorse; Scan is the exception.** Filters do not reduce read cost.
  • GSIs are eventually consistent, separately provisioned and can throttle the base table.
  • Single-table design is an optimisation, valuable for co-accessed entities, costly for readability and per-entity tuning.
  • Unit arithmetic: round up to 4 KB (reads) or 1 KB (writes); eventually consistent halves, transactions double.
  • Use conditions for correctness: create-if-absent, state transitions, optimistic locking; transactions only when several items must change together.
  • TTL is lazy, Streams last 24 hours, global tables are last-writer-wins by default: design for each behaviour.
Version note

Limits, units and behaviours on this page were checked against the Amazon DynamoDB Developer Guide (Constraints, Quotas, read/write operations, read consistency, partitions, adaptive capacity, on-demand and provisioned capacity, GSI/LSI, transactions, TTL, Streams, global tables, PITR, DAX) on 2026-10-31. Java code was compiled with JDK 21 against AWS SDK for Java 2.57.8 and run against the amazon/dynamodb-local:latest image. Prices are intentionally not quoted: see the DynamoDB pricing page. Quotas and the global tables feature set evolve; re-check the docs for your Region and account.

Next in this section
  • 04 MongoDB - the document-database alternative: flexible queries and secondary indexes.
  • 06 Caching Patterns - when a cache (DAX, Redis) belongs in front of the table.
  • 07 Event-Driven Patterns - outbox, idempotent consumers and change capture with Streams.
  • AWS Messaging - SNS, SQS and EventBridge as destinations for DynamoDB change events.