← DynamoDB patterns

07 - Performance & scale

2026-10-20

DynamoDB scales by spreading data across partitions, and each partition has a throughput ceiling of about 3,000 reads and 1,000 writes per second. That one fact explains almost every performance problem we'll meet: it nearly always comes down to too much traffic on too few partition key values.

High-cardinality partition keys (user ID, order ID) spread traffic naturally, while low-cardinality ones (status, date, country) create hot partitions. When one logical key really is hot, write sharding splits it into KEY#0..N, either with random suffixes plus a scatter-gather read, or with calculated suffixes so we can still do a direct get. Caching (DAX or a read-through cache) absorbs repeated reads of the same hot items, at the cost of staleness. And time-series data, which is written to "now" and mostly read recently, wants keys bucketed by time, with old data expired by TTL or by rotating a table per period.

# Pattern What we'll learn
34 High-cardinality partition keys Measuring key distribution; moving low-cardinality lookups to a GSI
35 Write sharding Random vs calculated shard suffixes, parallel reads
36 Caching (DAX / ElastiCache) Read-through and write-through caching, hit rates, staleness
37 Time-series tables Table-per-period rotation vs time-bucketed keys with TTL

34 · High-cardinality partition keys

Code: 34-high-cardinality-keys.py · run with just run 34

Problem

DynamoDB hashes the partition key to choose a physical partition. If the key has only a few distinct values (status, country, date, or tenant when one tenant is huge), traffic piles onto a few partitions. Those partitions get throttled while the rest of the table sits idle, and we're paying for capacity we can't use.

Pattern

We choose a partition key with many distinct values that are used about evenly. User ID, order ID, device ID and session ID are all good examples.

Choice Distinct values Verdict
status 3 ❌ hot partitions
date 1 per day ❌ today's partition gets all writes
customerId 100k+ ✅
orderId millions ✅

If we still need to query by a low-cardinality attribute, we put it in a GSI. But GSIs have the same per-partition limits, so we either shard the GSI key too (35) or make it sparse (10 · Sparse index).

In practice

The script generates 1,000 orders across 300 customers and three statuses. It counts how the items would spread under each key choice, keys the table by customer, and puts status on a GSI with a 4-way shard suffix:

Python
"PK": f"CUSTOMER#{o['customer']}",
"SK": f"ORDER#{o['order_id']}",
"status": o["status"],
# the GSI has the same cardinality problem, so add a shard suffix
"GSI1PK": f"STATUS#{o['status']}#{int(o['order_id'][1:]) % 4}",
"GSI1SK": f"ORDER#{o['order_id']}",
=== BAD: PK=status -> distinct partition keys ===
{
  "distinct": 3,
  "items_per_key": {
    "SHIPPED": 336,
    "PENDING": 333,
    "DELIVERED": 331
  }
}

>>> BatchWriteItem
    p34-high-cardinality: 25 puts, 0 deletes
    ... (+37 more BatchWriteItem calls not shown)

=== GOOD: PK=customer -> distinct partition keys ===
{
  "distinct": 290,
  "max_items_on_one_key": 9
}

>>> Query p34-high-cardinality
    key:        PK = 'CUSTOMER#c234'

=== Orders for CUSTOMER#c234 ===
[
  "ORDER#o0000",
  "ORDER#o0416",
  "ORDER#o0674",
  "ORDER#o0704"
]

>>> Query p34-high-cardinality
    key:        GSI1PK = 'STATUS#PENDING#0'
    IndexName:  GSI1

>>> Query p34-high-cardinality
    key:        GSI1PK = 'STATUS#PENDING#1'
    IndexName:  GSI1

>>> Query p34-high-cardinality
    key:        GSI1PK = 'STATUS#PENDING#2'
    IndexName:  GSI1
    ... (+1 more Query calls not shown)

=== PENDING orders via sharded GSI ===
333

With status as the key, a thousand items land on just 3 keys, around 330 each. With customer as the key, the same data spreads over 290 keys with at most 9 items on any one. The "pending orders" question still works: we query all 4 shards of the GSI and merge, getting back all 333.

Why

Trade-offs

Gotchas


35 · Write sharding

Code: 35-write-sharding.py · run with just run 35

Problem

Sometimes every write naturally goes to one partition key, such as "today's events" (EVENTS#2026-10-20). A single partition handles about 1,000 WCU and 3,000 RCU per second. Above that we get throttled, however much capacity the table has overall. Adaptive capacity helps, but it can't split the traffic for a single key.

Pattern

We add a suffix to the hot key so writes spread over N partitions. On read, we query all N and merge the results. This is scatter-gather.

PK SK
EVENTS#2026-10-20#0 EVT#00017
EVENTS#2026-10-20#1 EVT#00003
… …
EVENTS#2026-10-20#9 EVT#00199
flowchart LR
    W[Writers] -->|suffix 0..9| S0[EVENTS#day#0]
    W --> S1[EVENTS#day#1]
    W --> S9[EVENTS#day#9]
    S0 --> R[Reader: query all N in parallel, merge]
    S1 --> R
    S9 --> R

There are two ways to pick the suffix:

In practice

The script writes 200 events with a random suffix over 10 shards and reads them back in parallel. Then it writes three orders with a calculated suffix and fetches one directly.

Python
def query_shard(shard):
    resp = table.query(KeyConditionExpression=Key("PK").eq(f"EVENTS#{DAY}#{shard}"))
    return resp["Items"]

# Scatter-gather: query all shards in parallel, merge the results.
with ThreadPoolExecutor(max_workers=SHARDS) as pool:
    results = list(pool.map(query_shard, range(SHARDS)))

def shard_for(order_id: str) -> int:
    return int(hashlib.md5(order_id.encode()).hexdigest(), 16) % SHARDS
>>> BatchWriteItem
    p35-write-sharding: 25 puts, 0 deletes
    ... (+5 more BatchWriteItem calls not shown)

>>> Query p35-write-sharding
    key:        PK = 'EVENTS#2026-10-20#0'

>>> Query p35-write-sharding
    key:        PK = 'EVENTS#2026-10-20#1'

>>> Query p35-write-sharding
    key:        PK = 'EVENTS#2026-10-20#2'
    ... (+7 more Query calls not shown)

=== Random suffix: items per shard ===
{
  "shard 0": 26,
  "shard 1": 14,
  "shard 2": 21,
  "shard 3": 22,
  "shard 4": 21,
  "shard 5": 20,
  "shard 6": 15,
  "shard 7": 22,
  "shard 8": 22,
  "shard 9": 17
}

=== Total gathered ===
200

>>> PutItem p35-write-sharding
    item:       {PK: 'ORDERS#2026-10-20#3', SK: 'ORDER#o-1001', total: 10}

>>> PutItem p35-write-sharding
    item:       {PK: 'ORDERS#2026-10-20#7', SK: 'ORDER#o-1002', total: 10}

>>> PutItem p35-write-sharding
    item:       {PK: 'ORDERS#2026-10-20#5', SK: 'ORDER#o-1003', total: 10}

>>> GetItem p35-write-sharding
    key:        {PK: 'ORDERS#2026-10-20#7', SK: 'ORDER#o-1002'}

=== Calculated suffix: direct GetItem for o-1002 (shard 7) ===
{
  "SK": "ORDER#o-1002",
  "total": 10,
  "PK": "ORDERS#2026-10-20#7"
}

The random suffix spreads 200 writes roughly evenly (14 to 26 per shard), and reading "all of today" takes 10 queries to gather them back. With the calculated suffix, o-1002 always hashes to shard 7, so a single GetItem finds it without touching the other shards.

Why

Trade-offs

Gotchas


36 · Caching (DAX / ElastiCache)

Code: 36-caching.py · run with just run 36

Problem

Some keys are read far more than others: a product page, a config item, a celebrity profile. One partition serves at most about 3,000 RCU/s, and paying for the same read over and over is wasteful anyway.

Pattern

We put a cache in front of DynamoDB. There are two common choices:

Whichever we use, the strategies are the same:

Strategy Read Write
Read-through cache miss → read DynamoDB → fill cache write to DynamoDB only
Write-through from cache write DynamoDB, then update cache
TTL entries expire after N seconds n/a, bounds how stale data can get

In practice

DynamoDB Local has no DAX, so the script simulates it with a tiny in-process read-through cache: a dict keyed by primary key, with a TTL. With real DAX the code barely changes:

Python
from amazondax import AmazonDaxClient
dax = AmazonDaxClient.resource(endpoint_url="daxs://my-cluster.xxxx.dax-clusters.us-east-1.amazonaws.com")
table = dax.Table("my-table")          # same API as boto3
table.get_item(Key={"PK": "PRODUCT#p1", "SK": "META"})

The stand-in cache, trimmed:

Python
def get(self, key):
    k = (key["PK"], key["SK"])
    hit = self.store.get(k)
    if hit and hit[1] > time.monotonic():
        self.hits += 1
        return hit[0]
    self.misses += 1
    item = self.table.get_item(Key=key).get("Item")
    self.store[k] = (item, time.monotonic() + self.ttl)
    return item

def put(self, item):
    # Write-through: update DynamoDB, then the cache
    self.table.put_item(Item=item)
    self.store[(item["PK"], item["SK"])] = (item, time.monotonic() + self.ttl)

The script reads a hot key 100 times, updates it from "another process" that bypasses the cache, waits for the TTL (0.5 s), and finally writes through the cache:

>>> PutItem p36-caching
    item:       {PK: 'PRODUCT#p1', SK: 'META', name: 'Widget', price: 10}

>>> GetItem p36-caching
    key:        {PK: 'PRODUCT#p1', SK: 'META'}

=== 100 reads of a hot key ===
{
  "dynamodb_reads": 1,
  "cache_hits": 99
}

>>> UpdateItem p36-caching
    key:        {PK: 'PRODUCT#p1', SK: 'META'}
    update:     SET price = 12

=== Right after an external update (stale) ===
{
  "price": 10
}

>>> GetItem p36-caching
    key:        {PK: 'PRODUCT#p1', SK: 'META'}

=== After TTL expiry (fresh) ===
{
  "price": 12
}

>>> PutItem p36-caching
    item:       {PK: 'PRODUCT#p1', SK: 'META', name: 'Widget', price: 15}

=== After write-through ===
{
  "price": 15
}

A hundred reads cost one GetItem. That's the whole point. But the external update to price = 12 isn't seen: the cache keeps serving 10 until the entry expires, and only then does a fresh GetItem pick up 12. The write-through at the end updates both, so the next read sees 15 straight away without touching DynamoDB.

Why

Trade-offs

Gotchas


37 · Time-series tables

Code: 37-time-series-tables.py · run with just run 37

Problem

Time-series data (metrics, events, logs) has three traits that work against a naive design:

Pattern

There are two common shapes.

Option A: one table per period (day, week or month).

Table Status
metrics-2026-10 current: high write capacity
metrics-2026-09 read-only: low capacity
metrics-2026-08 expired: drop the table (free, instant)

We create the next period's table ahead of time, point writers at the current table, and drop whole tables once they pass retention.

Option B: one table, a time-bucketed partition key, plus TTL (30 · TTL for expiry).

PK SK value expires_at
SENSOR#s1#2026-10-20 2026-10-20T01:20:00+00:00 80 1763600400

The bucket (sensor + day) keeps partitions bounded and spreads writes, and the ISO timestamp in the sort key gives us range queries with between.

In practice

The script does both. For option A it creates three monthly tables, then drops the oldest with a single DeleteTable. For option B it writes nine readings into a day bucket, each with an expires_at 30 days out, and range-queries one hour.

Python
# Option A: retention = 2 months, drop the oldest table. One API call.
client.delete_table(TableName=f"p37-metrics-{oldest}")

# Option B: one partition per sensor per day, TTL removes old items
batch.put_item(
    Item={
        "PK": f"SENSOR#s1#{ts:%Y-%m-%d}",
        "SK": ts.isoformat(),
        "value": hour * 60 + minute,
        "expires_at": int(ts.timestamp()) + retention,
    }
)

resp = table.query(
    KeyConditionExpression=Key("PK").eq("SENSOR#s1#2026-10-20") & Key("SK").between("2026-10-20T01:00", "2026-10-20T02:00")
)
>>> BatchWriteItem
    p37-metrics-2026-10: 5 puts, 0 deletes

>>> BatchWriteItem
    p37-metrics-2026-09: 5 puts, 0 deletes

>>> BatchWriteItem
    p37-metrics-2026-08: 5 puts, 0 deletes

=== Tables before rotation ===
[
  "p37-metrics-2026-08",
  "p37-metrics-2026-09",
  "p37-metrics-2026-10",
  "p37-metrics-ttl"
]

=== Tables after dropping the oldest ===
[
  "p37-metrics-2026-09",
  "p37-metrics-2026-10",
  "p37-metrics-ttl"
]

>>> UpdateTimeToLive p37-metrics-ttl

>>> BatchWriteItem
    p37-metrics-ttl: 9 puts, 0 deletes

>>> Query p37-metrics-ttl
    key:        (PK = 'SENSOR#s1#2026-10-20' AND SK BETWEEN '2026-10-20T01:00' AND '2026-10-20T02:00')

=== Bucketed query: 01:00-02:00 ===
[
  [
    "2026-10-20T01:00:00+00:00",
    60
  ],
  [
    "2026-10-20T01:20:00+00:00",
    80
  ],
  [
    "2026-10-20T01:40:00+00:00",
    100
  ]
]

>>> DescribeTimeToLive p37-metrics-ttl

=== TTL status ===
{
  "TimeToLiveStatus": "ENABLED",
  "AttributeName": "expires_at"
}

Dropping August's data is one call, with no per-item deletes and no write capacity spent. (On AWS we'd also lower the read-only tables' provisioned capacity, or switch them to on-demand.) In option B, the between on the ISO sort key returns exactly the three readings from 01:00 to 02:00, and TTL is enabled to clear them out after 30 days. Locally, though, TTL won't actually delete them on schedule.

Why

Trade-offs

Gotchas


Next up: 08 - Operations, covering capacity modes, backups, global tables and analytics.


← 06 - Lifecycle & events | 08 - Operations →