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.
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.
| Concept | What it is | Relational analogy |
|---|---|---|
| Table | A collection of items. No fixed schema except the key attributes | Table (without the column definitions) |
| Item | One record: a set of attributes, uniquely identified by its primary key | Row |
| Attribute | A named value. Items in one table may have different attributes | Column (but per item, not per table) |
| Primary key | Partition key, or partition key plus sort key. Unique per item, set at table creation | Primary key, but it also decides where the data lives |
| Secondary index | A second key layout over the same data (chapter 04) | Index, but with its own storage and its own capacity |
| Kind | Types | Notes |
|---|---|---|
| Scalar | S string, N number, B binary, BOOL, NULL | Only S, N and B can be key attributes. Numbers travel as strings on the wire |
| Document | L list, M map | Nest up to 32 levels deep (verified in the Constraints page) |
| Set | SS, NS, BS | Unordered, 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):
| Limit | Value |
|---|---|
| Maximum item size, including attribute names and values | 400 KB |
| Partition key value length | 1 to 2048 bytes |
| Sort key value length | 1 to 1024 bytes |
One Query or Scan call returns at most | 1 MB of evaluated data (then you page, chapter 03) |
BatchGetItem / BatchWriteItem | 100 items, 16 MB / 25 put or delete requests, 16 MB |
| Table size | No practical limit |
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.
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.
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 shape | Lets you | Typical use |
|---|---|---|
| Partition key only | GetItem / PutItem by exact key | Sessions, user profiles, anything fetched by id |
| Partition key + sort key | Everything above plus Query over a range inside one collection | Orders per customer, events per device, messages per chat |
- 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).
| Property | Good | Bad |
|---|---|---|
| Cardinality | Millions of distinct values (customer id, device id, order id) | status, country, true/false, a date |
| Access spread | Traffic spread roughly evenly over keys | One celebrity customer or "TODAY" key takes most traffic |
| Time series | Key = device id, sort key = timestamp | Key = date, so every write today hits one partition |
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.
GetItem, Query, Scan and Pagination
| Operation | What it needs | Cost shape | Use for |
|---|---|---|---|
GetItem | Full primary key | One item, one lookup | Fetch by id |
BatchGetItem | Up to 100 full keys | Each item priced as its own GetItem | Fetch many known keys |
Query | Partition key value, optional sort key condition | Only the one item collection is read | Range, list, "latest N" inside one key |
Scan | Nothing | Every item in the table or index is read, then filtered | Exports, one-off maintenance, small tables |
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.
| Question | Scan with filter | Query on a key designed for it |
|---|---|---|
| "Orders of customer c-17" | Reads every order of every customer | Reads only c-17's collection |
| Cost grows with | Whole table size | Result size |
| At 10x data | 10x the reads for the same answer | Same cost for the same answer |
| Legitimate use | Backfills, analytics export, tiny reference tables | Everything user-facing |
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.
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. Limitcaps 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.
LastEvaluatedKey = {pk, sk} of the last item evaluatedLastEvaluatedKey: doneWith 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.
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) | |
|---|---|---|
| Key | Same partition key as the table, different sort key | Any partition key and optional sort key |
| Created | When the table is created (not added later) | Any time, with UpdateTable; backfilled from existing data |
| Per table | Up to 5 | Up to 20 by default (quota, adjustable) |
| Read consistency | Eventually or strongly consistent | Eventually consistent only |
| Capacity | Shares the base table's | Its own, separate from the table's (inherits the table's mode) |
| Item collection limit | 10 GB per partition key value (table plus its LSIs together) | None |
| Key attribute types | S, N or B for key attributes | Same |
| Projection | Index contains | Trade-off |
|---|---|---|
KEYS_ONLY | Index keys plus the table's primary key | Smallest and cheapest to maintain; a full item needs a second GetItem |
INCLUDE | Keys plus the attributes you list | Good when the list view needs a few fields. Up to 100 projected attributes across all of a table's indexes (quota) |
ALL | Whole item | One-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.
- 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#PENDINGfunnels 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.
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).
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.
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.
| Helps | Hurts | |
|---|---|---|
| Reads | One round trip for related items (parent plus children) | Reading one entity type needs prefix discipline; results of mixed types need type-aware mapping |
| Operations | Fewer tables, alarms, backups and IAM policies to manage | Capacity, TTL, streams and PITR are per table: you cannot tune or restore one entity type alone |
| Evolution | Cheap to add an entity that fits existing keys | A genuinely new access pattern can force a re-key and a backfill of the whole table |
| Team | Compact once everyone knows the key conventions | Opaque to newcomers and to ad-hoc analysis; harder to read in the console |
| Analytics / exports | Possible | Mixed-type export needs decoding before it is useful |
| Hot spots | Shared table capacity smooths peaks | One noisy entity type can throttle the others in the same partition |
- 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.
| Technique | What it does | Example |
|---|---|---|
| Item collection | Related items share a partition key and sort by sort key | pk = ORDER#o-1001, sk = LINE#1, LINE#2 |
| Sort-key prefix | A type tag in the sort key lets one Query pick a type with begins_with | sk = ORDER#... vs sk = PROFILE |
| Composite sort key | Concatenate attributes so sort order matches a read | ORDER#<createdAt>#<orderId> sorts by time, breaks ties by id |
| Sparse index | Only some items carry the index key | Pending orders only |
| Write sharding | Add a suffix to a hot key and read all suffixes | ORDERSTATUS#PENDING#0 ... #9 |
| Denormalisation | Copy data you read together into one item | Customer name copied onto the order |
| Store large objects elsewhere | Item keeps the pointer | Invoice PDF in S3, key in the item |
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.
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 pattern | Frequency | Operation | Keys used |
|---|---|---|---|---|
| A1 | Load a customer profile | Very high | GetItem | pk = CUSTOMER#id, sk = PROFILE |
| A2 | List a customer's orders, newest first, paged | High | Query (descending) | pk = CUSTOMER#id, sk begins_with ORDER# |
| A3 | Load all lines of one order | High | Query | pk = ORDER#id |
| A4 | Work list: orders awaiting shipment, oldest first | Medium | Query on GSI | gsi1pk = ORDERSTATUS#PENDING |
| A5 | Place an order: check customer, write header and lines atomically | High (writes) | TransactWriteItems | Condition checks + puts |
| A6 | Count of orders per customer | Medium | UpdateItem with ADD | Atomic counter on the profile |
| A7 | Ship an order exactly once | Medium | Versioned UpdateItem | Optimistic locking on the order |
| Item type | pk | sk | gsi1pk / gsi1sk | Other attributes |
|---|---|---|---|---|
| Customer | CUSTOMER#<customerId> | PROFILE | (none) | name, email, orderCount |
| Order | CUSTOMER#<customerId> | ORDER#<createdAt>#<orderId> | ORDERSTATUS#PENDING / <createdAt> while pending, absent after | orderId, status, totalCents, version |
| Order line | ORDER#<orderId> | LINE#<n> | (none) | sku, qty, priceCents |
- The order header sits under the customer so A2 is one
Query; the lines sit under the order so A3 is oneQuery. 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'sInstant.toString()drops trailing zeros, so.4Zwould sort after.41Z: format it explicitly. - **
gsi1pk = ORDERSTATUS#PENDINGis 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.
On-Demand, Provisioned and the Unit Arithmetic
| On-demand | Provisioned (with auto scaling) | |
|---|---|---|
| You pay for | Each request you make (read and write request units) | Capacity you provision per hour, used or not |
| Capacity planning | None. Scales with traffic | You set read and write capacity units (RCU/WCU); auto scaling adjusts within min and max |
| Spiky or unknown traffic | Fits well | Auto scaling reacts after a threshold breach (two consecutive minutes, plus alarm delay), so sharp spikes can throttle |
| Steady, predictable traffic | Can cost more than well-utilised provisioned capacity | Usually cheaper, and reserved capacity can lower it further |
| Safety | Optional maximum throughput per table/index to bound cost and runaway use | Fixed ceiling = provisioned capacity (plus burst) |
| Switching | Provisioned to on-demand: up to four times in a rolling 24 hours. On-demand to provisioned: any time | Provisioned 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).
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.
| Operation | Strongly consistent | Eventually consistent | Transactional |
|---|---|---|---|
| Read, item up to 4 KB | 1 RCU | 0.5 RCU | 2 RCU |
| Write, item up to 1 KB | 1 WCU | n/a | 2 WCU |
Worked examples (the arithmetic follows the documented rounding rules; the item sizes are made up):
| Example | Rounded size | Strongly consistent | Eventually consistent | Transactional |
|---|---|---|---|---|
| Read one 3.5 KB item | 4 KB | 1 RCU | 0.5 RCU | 2 RCU |
| Read one 6 KB item | 8 KB | 2 RCU | 1 RCU | 4 RCU |
| Read one 10 KB item | 12 KB | 3 RCU | 1.5 RCU | 6 RCU |
| Query returns 10 items totalling 40.8 KB | 44 KB (rounded once for the whole query) | 11 RCU | 5.5 RCU | n/a |
BatchGetItem of 1.5 KB + 6.5 KB items | 4 + 8 = 12 KB (rounded per item) | 3 RCU | 1.5 RCU | n/a |
| Write one 500-byte item | 1 KB | 1 WCU | 2 WCU | |
| Write one 2.5 KB item | 3 KB | 3 WCU | 6 WCU | |
UpdateItem growing an item from 1.2 KB to 2.4 KB | 3 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.
| Gotcha | Detail (docs, read/write operations page) |
|---|---|
Scan bills for what it reads | Evaluated 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 cost | Asking for fewer attributes does not change the item size used for read units |
| Reading a missing item still costs | Read units are consumed even when the key does not exist |
| Failed conditional writes still cost | A condition that evaluates to false still consumes write capacity based on item size |
| Cancelled transactions still cost | Capacity is consumed even when the transaction is cancelled |
COUNT is not free | Counting reads every matching item |
- 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.
Conditions, Optimistic Locking, Counters and Transactions
| Read type | Behaviour | Where available |
|---|---|---|
| Eventually consistent (default) | May not reflect a write that just completed; converges quickly | Tables, LSIs, GSIs |
Strongly consistent (ConsistentRead=true) | Reflects every write that succeeded before the read; costs double | Tables and LSIs only. Not GSIs, not streams |
Transactional (TransactGetItems) | A consistent, isolated read of several items | Tables (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.
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).
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).
SHIPPED, version 2ConditionalCheckFailedException: stale. B reloads, sees SHIPPED, and stopsUpdateItem 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.
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
ConditionCheckand anUpdateon 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
ClientRequestTokenmakes the call idempotent: the token is valid for 10 minutes after the request finishes, and reusing it with different parameters returns anIdempotentParameterMismatcherror. 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.
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.
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.
TTL, Streams, Global Tables, DAX, Backups and PartiQL
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
FilterExpressioncomparing 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.
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.
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.
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.
| MREC (multi-Region eventual consistency) | MRSC (multi-Region strong consistency) | |
|---|---|---|
| Default? | Yes | No: chosen at creation, cannot be changed later |
| Replication | Asynchronous, typically within a second | Synchronous to at least one other Region before the write returns |
| Conflicts | Last writer wins, by latest internal timestamp, per item | Strongly consistent reads on any replica return the latest version; conditions see the latest version |
| Regions | Any number of replica Regions | Exactly three: three replicas, or two replicas and one witness |
| Transactions | Atomic only within the writing Region | Not supported |
| TTL / LSI | Supported | Not supported (GSIs are) |
| Latency | Local writes and reads | Writes 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.
| Feature | What it gives you | Remember |
|---|---|---|
| DAX (DynamoDB Accelerator) | An in-memory cache cluster in front of DynamoDB, API compatible; serves GetItem, BatchGetItem, Query, Scan cache hits in memory | Only 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 backup | Full table snapshot on request | Restore 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 days | Off by default: enable it on tables you care about |
| PartiQL | A SQL-compatible syntax (SELECT, INSERT, UPDATE, DELETE) over DynamoDB | It is just another syntax for the same operations: a SELECT without the key in its WHERE still becomes a scan |
| Export to S3 | Analytics copy without consuming table read capacity | The answer to "I need to run SQL over this" |
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.
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.
PAY_PER_REQUEST is on-demand. Against real AWS, drop endpointOverride and the static credentials: the default credentials provider chain and region resolution apply.
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.
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.
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.
- The SDK retries throttling and transient errors for you with backoff; keep your own retry loop for
ConditionalCheckFailedException(reload and decide) andTransactionCanceledException(inspect the reasons: conflict vs condition). TransactionCanceledExceptionfor a conflict is retryable; for a failed condition it is not.- Prefer the SDK's
DynamoDbClientsingleton: it is thread-safe and expensive to create.
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.
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):
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:
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.
| Mistake | What you see | Fix |
|---|---|---|
Hot key (celebrity customer, TODAY key, single counter) | Throttling on one partition while the table is mostly idle | Spread keys; write-shard; cache reads (DAX or app cache); batch counters |
| Low-cardinality partition key | Few partitions busy, throttles at low total traffic | Pick a key with millions of values |
Scan in the request path | Latency and cost rise with table size; throttles neighbours | Add an index or redesign keys; export for analytics |
| Big items | Higher cost per read and write, near the 400 KB cap | Move blobs to S3; split cold attributes into another item |
| Unbounded item collections | Huge query pages; LSI tables hit the 10 GB per key cap | Time-bucket keys, archive or TTL |
| GSI with a hot or low-cardinality key | Base-table writes throttled | Shard the GSI key; size GSI write capacity like the table |
| Reading from a GSI right after a write | Item missing for a moment | Return data from the write, or read the table |
| Assuming TTL deletes on time | Expired items still returned | Filter on the TTL attribute in reads |
| Assuming sort keys sort numbers/dates correctly as strings | Wrong order: 2 after 10, variable-width timestamps misordered | Fixed-width formats, or numeric attributes |
Trusting ConsistentRead after global-table replication | Stale data from another Region (MREC) | Route the item to one home Region, or use MRSC |
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), enablingQuerywith range conditions. Together they form the unique primary key. - Q: Query vs Scan vs GetItem?
GetItemfetches one item by full key.Queryreads one item collection by partition key plus an optional sort key condition.Scanreads 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?
AQueryorScanreturns at most 1 MB of evaluated data (or yourLimit). If there is more,LastEvaluatedKeyis returned; pass it back asExclusiveStartKey. 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 aQuerylimit 100 on that GSI. Mention eventual consistency of the GSI, write amplification and sharding the hot game key.
Cheat Sheet and Recap
| Topic | Remember | Value |
|---|---|---|
| Item size | Includes attribute names | 400 KB |
| Key length | Partition / sort key value | 2048 / 1024 bytes |
| Query / Scan page | Then use LastEvaluatedKey | 1 MB per call |
| Batch | BatchGetItem / BatchWriteItem | 100 items / 25 requests (16 MB) |
| Read unit | Round up to 4 KB | SC 1, EC 0.5, transactional 2 |
| Write unit | Round up to 1 KB | Standard 1, transactional 2 |
| Partition ceiling | Per partition, per second | 3000 read / 1000 write units |
| LSI | Created with table, 10 GB per key | Up to 5 per table |
| GSI | Eventually consistent, own capacity | 20 per table by default |
| Transactions | Distinct items, tables only | 100 actions, 4 MB, token 10 min |
| Streams | Ordered per item | 24 hour retention |
| TTL | Epoch seconds as a Number | Deleted typically within a few days |
| PITR | Continuous, per second | Up to 35 days |
| Global tables | MREC default (LWW) or MRSC (3 Regions) | Mode fixed at creation |
- 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.
- **
Queryis the workhorse;Scanis 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.
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.
- 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.