DynamoDB Concepts, Solved One Problem at a Time
A beginner's walk through the DynamoDB ideas that matter in practice, each introduced by the problem it solves, using one running example: selling a limited number of tickets.
On this page
- Before you start
- One example, twelve problems
- 01Keep things you read together in one place
- 02Decide how fresh a read must be
- 03Two buyers, one ticket
- 04Many changes, all or nothing
- 05Which write failed?
- 06Pressing buy twice
- 07Cancel and confirm at the same time
- 08Two admins, one setting
- 09At most two per customer
- 10Find an item by a different key
- 11Do not delete what is in use
- 12The busy row
- The practical side: units, throttling and the bill
- The pattern behind all twelve
- Design in this order
- What you give up
- What this note leaves out
- References
Two people click buy on the last ticket.
One of them should get it. Exactly one.
That sounds easy. It is the kind of problem DynamoDB's smaller features exist for, and a good way to learn them.
Before you start
This note assumes you know what DynamoDB is for. If you do not, read the DynamoDB lesson in my System Design course first. It covers how keys, indexes and consistency work, in more depth than this note needs.
If you only have a minute, these five lines are enough to follow along:
- DynamoDB is a managed database from AWS. You ask for items by key, and it answers fast however large the table grows.
- Every item is found by its key. The partition key decides where the item is stored. The sort key orders items inside one partition key.
- There are no joins and no ad hoc queries. You decide the questions first, then design the keys around them.
- You pay for reads and writes, and each partition has a throughput limit.
- Reads are eventually consistent by default. You can ask for a strongly consistent read at a higher cost.
The words you need first
- Table: where items live. DynamoDB has no joins, so you design the table around the questions you will ask.
- Item: one record, like a row. It is a set of attributes.
- Attribute: one named value on an item, like
stock = 40. - Partition key: decides where an item is stored. Items with the same partition key sit together.
- Sort key: orders items inside one partition key. Together, partition key and sort key are the item's address.
One example, twelve problems
Most DynamoDB tutorials list features. That is hard to remember, because a feature without a problem is just a name.
So this note goes the other way. We take one example and let it break, one problem at a time. Each time it breaks, we pick up the DynamoDB concept that fixes it.
The example: a ticket drop. A limited number of tickets go on sale at a fixed time. A buyer holds tickets while they pay, then confirms. A buyer can also cancel. Each person may buy at most two. An admin can change how many tickets exist.
Every section has the same four parts:
What is the problem? What goes wrong.
How do we solve it? The idea, in plain words.
What is the DynamoDB concept? The name to look up.
Why is there a catch? What the fix costs you.
The table we will use
Every item in this note lives in one table. The key attributes are called partition_key and sort_key. Their values are often prefixed strings, so you can tell the kinds of item apart. All names and numbers here are made up. GA means general admission, the standard ticket, and VIP is the premium tier.
partition_key sort_key holds
EVENT#monsoon-live CONFIG price, limits, version
EVENT#monsoon-live STOCK#GA available, held, sold
EVENT#monsoon-live STOCK#VIP available, held, sold
ORDER#ORD-48213907 DETAILS buyer, state, tickets
LIMIT#10482#cust_83921 LIFETIME tickets taken so far
Limits and prices change. Where this note quotes a limit, it is the documented one at the time of writing. Check the linked AWS pages before you rely on a number.
Keep things you read together in one place
What is the problem? The event page needs the event's settings (price, sale window, per-person limit) and the live ticket counts. If they live in different tables, you make several calls, and DynamoDB cannot join them for you.
How do we solve it? Give the settings item and the ticket-count items the same partition key, and different sort keys. One request then returns the whole group, already sorted.
What is the DynamoDB concept? The primary key (partition key plus sort key) and the Query operation. A query reads one partition, optionally a slice of it by sort key. This is often called single-table design: items that are read together are stored together.
Here are the first three of those items as they are stored:
{ "partition_key": "EVENT#monsoon-live",
"sort_key": "CONFIG",
"event_id": 10482, "title": "Monsoon Live",
"price": 1499, "currency": "INR",
"per_person_limit": 2, "config_version": 3 }
{ "partition_key": "EVENT#monsoon-live",
"sort_key": "STOCK#GA",
"available": 1400, "held": 0, "sold": 0 }
{ "partition_key": "EVENT#monsoon-live",
"sort_key": "STOCK#VIP",
"available": 80, "held": 0, "sold": 0 }
A query for partition_key = "EVENT#monsoon-live" returns all three. Adding begins_with(sort_key, "STOCK#") returns only the two stock items.
The habit to build: start from the question your page asks, and pick keys that answer it in one query.
Why is there a catch? The keys decide which questions are cheap. A question you did not plan for needs a new index or a data migration, because there are no joins and no ad hoc queries. A badly chosen partition key sends most traffic to one partition. Mixing several kinds of item in one table also makes it harder to read at a glance.
Decide how fresh a read must be
What is the problem? You update the stock, then read it straight away. The read may still show the old value. DynamoDB keeps copies of your data, and a copy can lag for a moment.
How do we solve it? Ask for a fresh read when you need one. But do not build correctness on it. A guarded write (next section) is what keeps the numbers right. A fresh read only saves you wasted retries.
What is the DynamoDB concept? Eventually consistent versus strongly consistent reads. The default is eventually consistent. A strongly consistent read returns the latest write and costs twice the read capacity. Both are described in the AWS page on read consistency.
Why is there a catch? A strongly consistent read costs twice an eventually consistent one, and it is not available on global secondary indexes. Use it where a stale answer would waste real work, not by default. The practical section near the end shows what that costs in units.
Two buyers, one ticket
What is the problem? Both buyers read "1 ticket left". Both decide to buy. Both write. The stock is now minus one. Checking in your code and then writing is two steps, and another request can slip in between them.
How do we solve it? Put the check inside the write. The write says: subtract one, but only if at least one is left. DynamoDB does the check and the change as one step. The loser gets a clear "condition failed" instead of a silent oversell.
What is the DynamoDB concept? A conditional write, using a ConditionExpression. When it fails, DynamoDB returns ConditionalCheckFailed. For the change itself, use the ADD update action. It changes a number on the server in one step, so you never read, add and write back.
UpdateExpression: ADD available :minus_n, held :n
ConditionExpression: available >= :n
The general-admission (GA) stock item part-way through the sale, before a hold of two tickets:
{ "partition_key": "EVENT#monsoon-live",
"sort_key": "STOCK#GA",
"available": 212, "held": 14, "sold": 1174 }
And the same item after it:
{ "partition_key": "EVENT#monsoon-live",
"sort_key": "STOCK#GA",
"available": 210, "held": 16, "sold": 1174 }
One detail shapes the whole design. A condition can compare an attribute with a value you pass in. It cannot calculate with attributes, so total - sold >= n is not allowed. That is why you store available as its own counter. It is the number you test.
The check and the write must be the same operation. Everything else in this note is a variation on this one idea.
Why is there a catch? The available counter is a second copy of something you could calculate, and you have to keep it in step. Also, ADD is not safe to repeat: AWS recommends SET where you need an idempotent operation. If a request times out and you send it again, the number may change twice. The fix is to guard the retry, with a condition or with the transaction and request token described in sections 04 and 06.
Many changes, all or nothing
What is the problem? Buying a ticket changes several items: the order, the stock, and the buyer's limit counter. If the program stops after the second change, you have a hold with no order, or an order with no stock taken.
How do we solve it? Send all the changes together, as one transaction. Either every change happens or none does. If any one condition fails, nothing is written.
What is the DynamoDB concept? TransactWriteItems, DynamoDB's transaction API. Know three rules before you use it:
- A transaction has a maximum number of items (100 at the time of writing). Keep your requests small enough to fit.
- The same item cannot appear twice in one transaction. If two ticket types draw from one shared pool, add their amounts and update the pool once.
- Transactional writes cost twice a normal write. Use them where all-or-nothing matters, not everywhere.
The details are in the AWS page on transactions.
Why is there a catch? A transaction costs twice a normal write, because DynamoDB prepares and then commits every item. It is limited to 100 actions, and two transactions touching the same item collide, so one is cancelled. AWS advises against grouping operations that do not need to succeed or fail together. The practical section near the end works out the bill for one buy.
Which write failed?
What is the problem? The transaction was cancelled. You need to tell the buyer why: "you already ordered", "sold out", or "limit reached". DynamoDB gives you a list of reasons, but the list has no names.
How do we solve it? The list is in the same order as the writes you sent. So while building the transaction, keep your own list: for each write, what it was and which ticket it touched. When it fails, look up the failed position in your list.
What is the DynamoDB concept? CancellationReasons on the TransactionCanceledException, ordered like the request. Two codes matter most. ConditionalCheckFailed means a condition you wrote was false. That is a real answer. TransactionConflict means another transaction was touching the same item at that moment. That one is temporary, and the right response is to retry.
Keep the order of the writes and your list in step. If you add a write in one place and not the other, every later position points at the wrong thing.
Why is there a catch? The meaning of each position lives in your code, not in DynamoDB. If the list of writes and your own list drift apart, the error points at the wrong thing and nothing warns you.
Pressing buy twice
What is the problem? The user double-clicks, or the app retries after a timeout. Now two orders exist, and two sets of tickets are held.
How do we solve it? Make the order's own id the key of the order item. Create it only if no item with that key exists. The second attempt fails the condition, and you read the existing order and return it.
What is the DynamoDB concept? A conditional put with attribute_not_exists. The key is the identity, and "create if absent" happens in one atomic step. This is how you make a request safe to repeat, which people call idempotency.
The order item, and the condition on the write that creates it:
{ "partition_key": "ORDER#ORD-48213907",
"sort_key": "DETAILS",
"customer_id": "cust_83921", "state": "HELD",
"tickets": [ { "tier": "GA", "units": 2 } ] }
ConditionExpression: attribute_not_exists(partition_key)
Be careful with one trap. When a retry finds an existing order, check that it really is the same request: same buyer, same items, same quantities. An order id that quietly returns someone else's order is a bug that hides well.
Why is there a catch? The order id is used up for good: the item stays, so the id cannot be reused. The condition only proves that an item exists, so you still have to check that the retry matches the first request. A transaction can carry a client request token instead, but it is valid for only 10 minutes after the first request finishes, so it does not replace a key that lasts.
Cancel and confirm at the same time
What is the problem? The payment succeeds and the buyer's confirm arrives, while a cancel for the same order is also running. If both read "held" and both proceed, the order ends up in a state nobody planned.
How do we solve it? Every state change says: move to confirmed, but only if the state is still held. If another request got there first, the condition fails. You re-read and decide again. Add each change to a history list on the item, so you can always see what happened.
What is the DynamoDB concept? An optimistic concurrency pattern: a conditional update on the state attribute. "Optimistic" means you do not lock anything. You assume nobody interferes, and you find out at write time if someone did. The SET action with list_append adds to the history without rewriting it.
The same order item after the confirm. Each change adds an entry to history:
{ "partition_key": "ORDER#ORD-48213907",
"sort_key": "DETAILS",
"customer_id": "cust_83921", "state": "CONFIRMED",
"history": [
{ "to": "HELD", "at": "2026-11-05T10:00:00Z" },
{ "to": "CONFIRMED", "at": "2026-11-05T10:04:00Z" } ] }
UpdateExpression:
SET #state = :confirmed,
history = list_append(history, :entry)
ConditionExpression: #state = :held
The name state is on DynamoDB's reserved words list, so the expression uses a placeholder (#state) and you map it to the real name in a separate ExpressionAttributeNames part of the request.
Why is there a catch? The loser has to re-read and decide again, so under heavy contention requests retry and some give up. The history list also grows the item with every change, and an item has a maximum size, so long histories belong in separate items.
Two admins, one setting
What is the problem? Two admins open the event settings. One changes the price and saves. The other changes the sale window and saves. The second save quietly erases the price change.
How do we solve it? Keep a version number on the settings item. An update says: write this, but only if the version is still the one I read, and bump it. A stale save fails, and that admin is told to reload.
What is the DynamoDB concept? Optimistic locking with a version attribute, built from a conditional write. It is the same idea as the last section, applied to configuration.
The settings item carries config_version (it is 3 in the example in section 01). An admin who read version 3 saves like this:
UpdateExpression:
SET price = :new_price, config_version = :next
ConditionExpression: config_version = :seen
Here :seen is 3 and :next is 4. If someone else saved first, the stored version is already 4, the condition is false, and this save is rejected.
Why is there a catch? A rejected save is an error that someone has to handle, usually by reloading and trying again. There is no automatic merge. Anything that writes without the version check, like a quick script, silently breaks the protection.
At most two per customer
What is the problem? The rule is two tickets per person, for the life of the sale. Two requests from the same person can arrive together. Also, a first-time buyer has no counter yet.
How do we solve it? Keep one counter item per event and customer, and raise it with a guarded write: add n, but only if the counter is missing, or used + n still fits under the limit.
UpdateExpression: ADD used :n
ConditionExpression:
attribute_not_exists(used) OR used <= :limit_minus_n
The counter item for one buyer looks like this:
{ "partition_key": "LIMIT#10482#cust_83921",
"sort_key": "LIFETIME",
"used": 2 }
With a limit of two, a request for one more ticket sends :limit_minus_n = 1. The stored used is 2, and used <= 1 is false, so the write is refused.
Two details make it correct. A missing counter passes the check, so also reject a single request that asks for more than the limit before you write. And when a purchase is cancelled, subtract n only if the counter holds at least n, so it can never go below zero. Remember which counters you raised, so you reverse exactly those.
What is the DynamoDB concept? A bounded atomic counter: ADD for the change, a condition for the bound, and attribute_not_exists to cover the first use.
Why is there a catch? Every customer gets an extra item to store, and to write on each purchase. Reversing a purchase has to remember exactly which counters were raised. If one code path forgets, the counter drifts away from reality.
Find an item by a different key
What is the problem? Your items are addressed by the event's slug, like EVENT#monsoon-live. But a support agent only has the event's numeric id, 10482, and no table scan is acceptable.
How do we solve it? Add a second way in. Put the id on the settings item as the key of a second index. Look the id up there to learn the slug, and then read the table with the real key.
What is the DynamoDB concept? A Global Secondary Index (GSI). Reads from an index are eventually consistent only, which is why the index is used just to find the key, and the data is then read from the table. Indexes also cost extra writes, so index only the items that need it. See the AWS page on secondary indexes.
Only the settings item carries the index attributes:
{ "partition_key": "EVENT#monsoon-live",
"sort_key": "CONFIG",
"event_id": 10482,
"gsi1_pk": "EVENTID#10482", "gsi1_sk": "CONFIG" }
A query on the index with gsi1_pk = "EVENTID#10482" returns this item, and its partition_key tells you where to read next. The stock items have no gsi1_pk, so they are not in the index at all, and they cost no index writes.
Why is there a catch? Index reads can be stale. Each index also costs storage and extra writes, and if an index lacks write capacity, writes to the base table are throttled too (AWS says so). Index only what you must look up this way.
Do not delete what is in use
What is the problem? An admin removes a ticket tier while some of its tickets are still held or sold. Deleting its counter row would erase real inventory.
How do we solve it? The delete carries a condition: held is zero and sold is zero. It sits in the same transaction as the settings change. If anything is in use, the whole change is refused.
ConditionExpression: held = :zero AND sold = :zero
What is the DynamoDB concept? A conditional delete, inside a transaction. Conditions work on deletes just as they work on writes.
Why is there a catch? The delete sits in the same transaction as the settings change, so one tier that is still in use blocks the whole change. The admin has to wait or choose again. Transactions also cost more than single writes.
The busy row
What is the problem? On sale day everyone hits the same stock row. DynamoDB stores each partition on limited capacity (about 1,000 writes per second at the time of writing), and two transactions touching one item at once cannot both proceed.
How do we solve it? Expect the conflicts and handle them. Retry a few times, and wait a little longer after each failure, with a random amount mixed in so that the callers do not all retry at the same instant. Keep the number of hot rows small, and keep other counters, like per-customer limits, in other partitions so they do not add to the pressure.
What is the DynamoDB concept? Partition throughput limits, the temporary TransactionConflict error, and retry with exponential backoff and jitter. Read the AWS guidance on partition key design.
Why is there a catch? Retries add delay exactly when the system is busiest. The AWS SDKs retry throttling errors for you, but a failed condition or a transaction conflict is a decision your code has to make. Splitting one hot counter into several, called write sharding, spreads the load, but then the total lives in several items and is harder to check in one atomic step.
The practical side: units, throttling and the bill
The catches above are about design. The practical ones are about numbers. DynamoDB measures every request in units, and a request that asks for more than is available is refused. This is where a design that looks fine on paper meets a sale day.
What one unit buys
DynamoDB calls them read units and write units. Older documents call them RCU and WCU.
- Reads are measured per 4 KB, rounded up. A strongly consistent read of up to 4 KB costs 1 read unit. An eventually consistent read costs half a unit. A transactional read costs 2.
- Writes are measured per 1 KB, rounded up. A write of up to 1 KB costs 1 write unit. A transactional write costs 2.
- Size is rounded up. A 3.5 KB read costs the same as a 4 KB read. A 2 KB item costs 2 write units, or 4 as part of a transaction.
- A query counts as one read. DynamoDB adds up the size of all the items it returns and rounds that total up to the next 4 KB.
The bill for one buy
Here is the cost of one buy attempt in the ticket example. The numbers are assumptions: every item is under 1 KB, and one query returns under 4 KB in total. A buy reads the event's partition once, checks whether the order exists, and then sends a transaction that writes three items (the order, the stock and the buyer's counter).
one buy attempt
reads 2 strongly consistent reads = 2 read units
writes 3 items x 2 (transaction) = 6 write units
at 1,000 attempts a second
reads 1,000 x 2 = 2,000 read units/s
writes 1,000 x 6 = 6,000 write units/s
That looks like a number you can simply provision. Then the ceiling on one partition appears. AWS designs each partition to deliver at most 3,000 read units and 1,000 write units per second. Every buy writes the same stock row, so the stock row's partition sees one transactional write, which is 2 write units, per attempt.
the stock row's partition
1,000 attempts x 2 write units = 2,000 write units/s
what one partition can deliver = 1,000 write units/s
attempts it can take: 1,000 / 2 = about 500 a second
That is a rough estimate. It ignores the burst and adaptive capacity described below, which can absorb a short spike. But it shows the shape of the problem: the table can have all the capacity in the world, and one hot row is still capped. It is also why section 12 ends with write sharding.
What throttling looks like
When requests need more units than are available, DynamoDB refuses some of them. You see ProvisionedThroughputExceededException or ThrottlingException. The AWS SDKs retry throttled requests for you, but retries use capacity too. Under sustained overload they add load instead of removing it.
Two features soften a spike. Burst capacity lets a table use unused capacity it saved earlier, up to five minutes' worth. Adaptive capacity lets a hot partition draw on the table's unused capacity. Both stop at the table's total capacity and at the partition maximum, so neither is a plan for a sale day.
To see what is happening, watch these CloudWatch metrics:
ThrottledRequests,ReadThrottleEventsandWriteThrottleEventscount refused requests.ConsumedReadCapacityUnitsandConsumedWriteCapacityUnitsshow what you actually used.TransactionConflictcounts transactions that collided. A conflict is not throttling, so look at both.
Provisioned or on-demand
You choose how capacity is set, and each choice has a catch.
- Provisioned mode: you set read and write units per second, and you pay for what you set whether you use it or not. Auto scaling can adjust it, but it follows traffic. A sale that starts with a sudden jump needs headroom set before it starts, or it throttles.
- On-demand mode: nothing to set, and you pay per request. A table instantly handles up to double its previous peak. Traffic that jumps to more than double that can still be throttled while DynamoDB allocates more capacity. At a steady high load it can cost more than provisioned, so compare both on the AWS pricing page for your numbers.
More catches that show up in practice
- Indexes multiply writes. Every write to an indexed item also writes to the index, and an index without enough write capacity throttles the base table.
- Big items cost more. The size is rounded up per kilobyte, so keep hot items small, and keep long lists such as history in separate items.
- Retries and conflicts cost units. AWS notes this for transactions: a retried transaction uses capacity again.
- Strong reads and transactions double the bill. Use them where the answer must be right, and use plain reads where a slightly old answer is fine.
What to do about it
- Work out the units before the sale. Multiply item sizes by operations by the peak rate, as in the example above. Check the hottest partition separately.
- Test with a realistic spike, and watch the throttling metrics while you do.
- Spread the hot row, by write sharding or by splitting stock into several items, and accept that the total is now harder to read exactly.
- Smooth the spike in front of the database. A queue or a waiting room turns a sudden jump into a steady rate. This is a design choice, not a DynamoDB feature.
The pattern behind all twelve
Look back at the list. Almost every fix is the same move: let the database refuse a bad write, instead of trying to predict it in your code.
Reads may be a little stale. That is fine, because a guarded write turns a stale read into a retry instead of a wrong answer.
Design in this order
The twelve fixes follow a routine. It comes from the way AWS describes NoSQL design: start from the questions, not from the data.
- 1. List the access patterns. What does each page or API call need to read or change? Write the questions down first.
- 2. Choose the keys. Pick partition and sort keys so each read is one query (section 01).
- 3. Put the guards in the writes. For every write that can race, make the check part of the write (sections 03 to 09).
- 4. Plan for failure. Decide what happens when a guard fails or a transaction is cancelled, and make every request safe to repeat (sections 05, 06 and 12).
- 5. Look for hot keys. Ask how traffic spreads across partition key values, and which rows everyone will touch at the same moment (section 12).
What you give up
Each fix above has a price, and together they add up.
- You choose the questions in advance. A new question can mean new keys, a new index or a migration.
- There is more code to get right. Guards, retries, position lists and counters are yours to write and test. The database does not do them for you.
- Several features cost extra. Strong reads, transactions and indexes each cost more than the plain version, and retries use capacity too.
- A hot row stays hot. One counter is capped by what a single partition can take, about 1,000 write units a second at the time of writing.
When another database fits better
If you need joins, constraints across many rows, or questions you cannot list in advance, a relational database such as PostgreSQL is usually the simpler choice. DynamoDB pays off when the questions are known, the traffic is large, and you do not want to run the servers yourself.
What this note leaves out
Time to live (automatic expiry), streams, global tables, and caching with DAX. They matter, but none of them is needed to follow the example above.
If you only keep one thing, keep the question from section 03: is the check part of the write? If it is not, you have a race.
References
The limits quoted in this note (up to 100 actions in a transaction, about 1,000 write units per second per partition) and the rule that no two actions in a transaction can target the same item were checked against these pages on 2 November 2026. The unit sizes and multipliers, the 3,000 read and 1,000 write units per partition, the five minutes of burst capacity and the double-the-previous-peak rule for on-demand mode were checked on the same date. The same pages state that ADD is not the idempotent choice (AWS recommends SET), that a client request token is valid for 10 minutes, that reads from a global secondary index are always eventually consistent, and that a global secondary index without enough write capacity throttles writes to the base table. Limits change, so check the pages again before you rely on a number.
Basics and design
- DynamoDB, in the System Design course: the prerequisite lesson.
- Core components of Amazon DynamoDB: tables, items, attributes and keys. Background for the words in the framing section and section 01.
- NoSQL design for DynamoDB: why you design from your access patterns. Section 01.
- DynamoDB read consistency: eventually and strongly consistent reads, and their cost. Sections 02 and 10.
Expressions
- Condition expressions: the checks that make a write conditional. Sections 03, 06, 09 and 11.
- Update expressions:
SET,ADDandlist_append. Sections 03, 07, 08 and 09. - Expression attribute names and reserved words: why
#stateis needed. Section 07.
Transactions
- DynamoDB transactions: how it works: all-or-nothing writes and what they cost. Section 04.
- TransactWriteItems API reference: the item limit and the rule about the same item twice. Section 04.
- CancellationReason API reference: the codes you get back when a transaction is cancelled. Section 05.
Indexes, capacity and retries
- Read and write operations: how reads and writes are measured in units, with the rounding rules. Practical section.
- On-demand capacity mode and provisioned capacity mode: the two ways to pay. Practical section.
- Burst and adaptive capacity: how a hot partition is helped, and where that stops. Practical section.
- Troubleshooting throttling and metrics and dimensions: the errors and the CloudWatch metrics. Practical section.
- Global secondary indexes: section 10.
- Write sharding: spreading one hot key over several. Section 12.
- Designing partition keys and quotas: hot partitions and the limits behind them. Sections 04 and 12.
- Error handling with DynamoDB: which errors to retry. Section 12.
- Exponential Backoff And Jitter, AWS Architecture Blog: why to add randomness to retries. Section 12.