Filter concepts by levelShowing all levels.

AWS · Section 24

DynamoDB

Level
advanced
Read
30 min
Concepts
4

DynamoDB's core vocabulary — tables, items, partition/sort keys, Query vs Scan, GSIs vs LSIs — sets up the two harder decisions that follow. Single-table design pre-bakes known access patterns into one table's key structure, trading upfront design cost for one-Query multi-entity fetches; it fits when patterns are known, and a relational database still fits better when they are not. Hot partitions are a hard per-partition throughput ceiling (3,000 RU/1,000 WU per second) independent of table capacity, defended against primarily through key design rather than adaptive capacity alone. Transactional APIs give all-or-nothing multi-item guarantees at double the capacity cost per item — a plain conditional write is the cheaper tool when only single-item optimistic concurrency is actually needed.

This section

What is true here

  1. Query uses the key structure directly and is cheap; Scan reads the whole table and filters after, expensive at any real size.
  2. A GSI has its own key and can be added anytime; an LSI shares the base partition key and must be defined at table creation.
  3. Single-table design pre-bakes known access patterns into one table via prefixed keys — it requires those patterns to be known upfront.
  4. Every partition caps at 3,000 RU/1,000 WU per second regardless of table capacity — a low-cardinality partition key creates a hot partition.
  5. TransactWriteItems/TransactGetItems cost double capacity per item (prepare + commit); a ConditionExpression gives cheap single-item optimistic concurrency without that overhead.

What you will be able to do

  • Choose Query over Scan for any access pattern that can be expressed through the key structure
  • Decide when single-table design is worth its upfront design cost versus a simpler layout or a relational database
  • Design a partition key that avoids concentrating load on a single partition
  • Choose between a full transaction and a plain conditional write based on whether multi-item atomicity is genuinely required
From the DynamoDB vocabulary to transactional guarantees
organizedviadistributedacrosscoordinatedby

Keys, Query vs Scan, GSI/LSI

Single-table design

Hot partitions, adaptive capacity

Transactions, optimistic concurrency

  • Keys, Query vs Scan, GSI/LSI
    • leads to Single-table design (organized via)
  • Single-table design
    • leads to Hot partitions, adaptive capacity (distributed across)
  • Hot partitions, adaptive capacity
    • leads to Transactions, optimistic concurrency (coordinated by)
  • Transactions, optimistic concurrency

DynamoDB

The core vocabulary (keys, Query vs Scan, GSI/LSI), single-table design, hot partitions and adaptive capacity, and transactional APIs vs optimistic concurrency.

DynamoDB Core Vocabulary

coreintermediate

A table holds items (rows); each item holds attributes (columns), schemaless beyond the primary key. A partition key alone must be unique per item; a composite key (partition + sort) lets multiple items share a partition key, sorted by sort key. A Query fetches by key efficiently; a Scan reads the whole table and is expensive at any real size. A GSI has its own partition/sort key (queryable independent of the base table's key); an LSI shares the base table's partition key with a different sort key, and must be created at table creation time.

Think of it as

The partition key is which filing cabinet drawer an item lives in; the sort key is where within that drawer it sits, kept in order. A Query says "open this specific drawer (optionally a range within it)" — fast and cheap. A Scan says "open every drawer in the building and look through all of them" — thorough but expensive at scale.

What we're doing: See why a Scan with a filter still reads (and pays for) the whole table.

scan-vs-query.txttext
Scan with FilterExpression "status = ACTIVE" on a 10M-item table
→ DynamoDB reads ALL 10M items, THEN discards the ones that don't match —
  you pay for reading all 10M, not just the matching subset
1
The filter expression looks like a WHERE clause, but it is applied client-side of the read, not as part of it.
3
Every one of the 10 million items is read and billed before the filter ever discards a single non-matching one — this is the defining cost characteristic of a Scan.

Why this works: A FilterExpression on a Scan is a post-read filter, not an index lookup — this is precisely why DynamoDB's own guidance is to design access patterns around Query wherever possible, and treat Scan as an operational or small-table-only tool.

Using Scan with a filter as a substitute for designing a proper access pattern

Wrong

text
# "We'll just Scan the table and filter for what we need — simpler than
# designing the right key structure or a GSI."

Better

text
# Design the partition/sort key (or a GSI) around the actual access
# pattern up front, so the read is a Query, not a full-table Scan

What you see: Read costs and latency scale with total table size rather than result size, and the problem gets worse every month as the table grows — even though the query itself always returns a small, similar-sized result.

Why: DynamoDB is explicitly designed around access-patterns-first modeling — a Scan-with-filter approach defers that design work, but the cost is not deferred, it accrues linearly with table growth regardless of how selective the filter actually is.

Query vs Scan

Query

  • +Uses the key structure directly
  • +Reads only the matching partition (+ sort range)
  • +Cheap and fast at any table size

Scan

  • Reads the entire table or index
  • Filters happen after the read, not before
  • Cost grows with table size, regardless of result size
  • Query
    • Uses the key structure directly
    • Reads only the matching partition (+ sort range)
    • Cheap and fast at any table size
  • Scan
    • Reads the entire table or index
    • Filters happen after the read, not before
    • Cost grows with table size, regardless of result size

Remember: Partition key alone must be unique; composite key lets the partition repeat, pair must be unique. Query uses the key structure (cheap); Scan reads everything then filters (expensive at scale). GSI: own key, addable anytime. LSI: shares the base partition key, must be defined at table creation.

See also: single table design and modeling · hot partitions and adaptive capacity

Single-Table Design vs Relational Modeling

coreadvanced

Single-table design stores multiple different entity types in one DynamoDB table, using generic key names (PK/SK) and prefixed values (USER#123, ORDER#456) so one table can serve many access patterns via a small set of GSIs, instead of one table per entity type. It fits well when access patterns are known and stable upfront; a relational database fits better when queries are ad hoc, evolving, or need real joins/transactions across many entity types.

Think of it as

Single-table design is a single, densely-organized warehouse where every shelf is labeled by a generic code (PK/SK) that different departments interpret differently — efficient once you know exactly what everyone needs to retrieve, but hard to reorganize later for a request nobody anticipated. A relational database is more like a warehouse with proper aisles per department and staff who can improvise a new retrieval path on the spot.

text
PK: USER#123      SK: PROFILE       → user profile item
PK: USER#123      SK: ORDER#456     → one of that user's orders, same partition

What we're doing: See how one table serves two different entity relationships through prefixed keys.

single-table-keys.txttext
PK: USER#123   SK: PROFILE        (the user's own profile item)
PK: USER#123   SK: ORDER#456       (one of that user's orders, same partition)
1
A Query on PK = USER#123 with SK beginning with ORDER# fetches every order for this user in one request — no join needed.
2
Both the profile and the order live in the same partition, which is exactly what makes a "get this user and their orders" access pattern a single, cheap Query instead of two separate lookups.

Why this works: This is the core payoff of single-table design — access patterns that would need a join in a relational database are instead pre-baked into the key structure, so they resolve in one request, at the cost of that structure having to be designed around known access patterns in advance.

Applying single-table design when access patterns are still genuinely unknown or evolving

Wrong

text
# Design an intricate single-table key structure for a new product
# whose query patterns are still being figured out

Better

text
# Use a relational database (or a simpler multi-table DynamoDB layout)
# until access patterns stabilize — single-table design pays off once they do

What you see: Every new feature requires a painful key-structure migration because the original design did not anticipate the access pattern the feature needs.

Why: Single-table design's efficiency comes specifically from having pre-computed the access patterns into the schema — applying it before those patterns are known trades away the very thing (ad hoc query flexibility) a relational database would have provided during that exploratory phase.

Single-table design vs a relational database

Single-table design

  • +Access patterns pre-baked into PK/SK + a few GSIs
  • +One Query resolves a known, related fetch
  • +Requires access patterns known upfront

Relational database

  • Real joins, ad hoc queries
  • Multi-entity ACID transactions
  • Fits evolving, not-yet-known access patterns
  • Single-table design
    • Access patterns pre-baked into PK/SK + a few GSIs
    • One Query resolves a known, related fetch
    • Requires access patterns known upfront
  • Relational database
    • Real joins, ad hoc queries
    • Multi-entity ACID transactions
    • Fits evolving, not-yet-known access patterns

Remember: Single-table design pre-bakes known access patterns into generic PK/SK key structures and a few GSIs, resolving multi-entity fetches in one Query — it requires access patterns to be known upfront. Prefer a relational database when patterns are ad hoc/evolving or real cross-entity transactions/joins are core requirements.

See also: dynamodb core vocabulary · choosing a data model

Hot Partitions and Adaptive Capacity

coreadvanced

Every partition caps out at 3,000 read units and 1,000 write units per second — a poorly-chosen partition key (e.g. one popular value getting most of the traffic) creates a "hot partition" that throttles well before the table's overall provisioned/on-demand capacity is exhausted. Adaptive capacity automatically shifts throughput toward hot partitions, but designing for uniform key distribution up front is what avoids depending on it.

Think of it as

The table's total throughput is a shared budget, but each partition is its own separate cash register with a hard per-second limit — a design that sends most transactions to one register hits that register's limit long before the store's total revenue capacity is reached, no matter how much total capacity exists elsewhere.

text
Partition key with even value distribution → load spreads across many partitions
Partition key with a dominant/popular value → hot partition, throttling below table capacity

What we're doing: See a partition key choice that concentrates traffic on one partition regardless of overall table capacity.

hot-partition.txttext
Partition key: "status" (values: ACTIVE, INACTIVE)
90% of items and traffic are "ACTIVE"
→ The "ACTIVE" partition throttles at 3,000 RU/1,000 WU per second,
  even if the table's overall capacity is far higher
1
A low-cardinality attribute like status has only a handful of distinct values, meaning only a handful of partitions can ever exist for this key.
2
With most traffic concentrated on one of those few values, that single partition hits its own hard 3,000 RU/1,000 WU ceiling long before the table's overall throughput budget is anywhere near exhausted.

Why this works: This is the textbook hot-partition scenario — the per-partition limit is fixed regardless of total table capacity, so a low-cardinality or skewed partition key structurally caps throughput at a single partition's limit, not the table's.

Choosing a low-cardinality attribute as the partition key

Wrong

text
# Partition key: "status" or "tenant_tier" — a handful of possible values

Better

text
# Partition key: something naturally high-cardinality (user ID, order ID),
# or a composite/sharded key if the natural key is still skewed

What you see: The table throttles under load well below its provisioned or on-demand capacity, and CloudWatch partition-level metrics show the throttling concentrated on one specific partition key value.

Why: A low-cardinality partition key structurally limits how many partitions the data can ever spread across — no amount of overall table capacity provisioning fixes a bottleneck that is capped at the level of a single partition.

A low-cardinality key concentrates traffic onto one partition

"status" key

only 2 distinct values

90% traffic → ACTIVE

one partition absorbs almost everything

Throttles at 3,000 RU/1,000 WU

far below the table's overall capacity

  • "status" key — only 2 distinct values
  • 90% traffic → ACTIVE — one partition absorbs almost everything
  • Throttles at 3,000 RU/1,000 WU — far below the table's overall capacity

Per-partition throughput limits

Per-partition throughput limits
ResourceLimit per partition per second
Read units3,000 (1 strongly consistent or 2 eventually consistent reads of a 4 KB item)
Write units1,000 (writes of a 1 KB item)

Together

text
20 KB item, strongly consistent read → 5 read units consumed per read

Remember: Every partition caps out at 3,000 RU/1,000 WU per second regardless of overall table capacity — a low-cardinality or skewed partition key creates a hot partition that throttles well below the table's budget. Adaptive capacity mitigates moderate imbalance; it does not replace access-pattern-first key design.

See also: dynamodb core vocabulary · single table design and modeling

Transactional APIs and Optimistic Concurrency

standardadvanced

TransactWriteItems/TransactGetItems group up to 100 actions across tables into one all-or-nothing, serializable operation — but each item costs double the capacity units (one to prepare, one to commit), even on a canceled transaction. ConditionExpression on an ordinary PutItem/UpdateItem gives cheap optimistic concurrency (write only if a version/value still matches) without needing a full transaction.

Think of it as

A transaction is a formal, all-or-nothing group signature — powerful, but it costs double the paperwork per signer, win or lose. A conditional write is a much lighter-weight "only sign if nothing has changed since I last looked" check — optimistic concurrency without the transaction machinery.

text
ConditionExpression "version = :expected"   → cheap optimistic concurrency, single item
TransactWriteItems [{Put...}, {Update...}]  → all-or-nothing across multiple items, 2x capacity cost

What we're doing: See a conditional write implement optimistic concurrency without needing a transaction.

optimistic-concurrency.txttext
UpdateItem: SET balance = :newBalance
ConditionExpression: "version = :expectedVersion"
→ Succeeds only if no other write has changed version since it was last read;
  otherwise fails with ConditionalCheckFailedException
1
The update itself is a standard single-item UpdateItem — no transaction involved, no doubled capacity cost.
2
The condition expression is what turns this into optimistic concurrency control — it fails cleanly if another writer already changed the item since this one last read it.

Why this works: This pattern gets the core benefit people often reach for a transaction to get — safe concurrent updates — for a single item, at the cost of one ordinary write rather than the double-capacity cost every item in a real transaction incurs.

Wrapping a single-item conditional update in a full TransactWriteItems unnecessarily

Wrong

text
# Use TransactWriteItems with one Update action and a ConditionCheck,
# for a single-item optimistic-concurrency update

Better

text
# Use a plain UpdateItem with a ConditionExpression directly — same
# optimistic-concurrency guarantee, without paying the transaction's 2x capacity cost

What you see: Capacity consumption for a workload that only ever needs single-item optimistic concurrency is roughly double what it needs to be, discovered only when reviewing CloudWatch capacity metrics.

Why: A transaction's all-or-nothing, multi-item guarantee is not needed to get optimistic concurrency on one item — a plain conditional write provides that exact guarantee without the doubled prepare/commit capacity cost a transaction always incurs, win or lose.

Remember: TransactWriteItems/TransactGetItems give all-or-nothing, serializable multi-item operations at 2x capacity cost per item (prepare + commit), even on cancellation. A ConditionExpression on a plain write gives cheap single-item optimistic concurrency without transaction overhead — prefer it when a full transaction isn't genuinely needed.

See also: dynamodb core vocabulary · single table design and modeling

Advertisement