Skip to content

DynamoDB Deep Dive: Managed Serverless Scale

DynamoDB is AWS's serverless key-value database. You don't manage clusters or nodes — AWS handles replication, failover, and scaling transparently.


Why DynamoDB

DynamoDB trades operational complexity for simplicity:

Concern DynamoDB Cassandra
Cluster management Zero (AWS does it) You own it (gossip, repair, compaction)
Scaling Automatic (provisioned or on-demand) Manual (add nodes, rebalance)
Availability Multi-AZ by default You configure
Query flexibility Limited (keys only, or GSI) Design your schema carefully
Consistency Eventually consistent (or strong per-request) Tunable (CL)
Cost Pay per request or reserved Pay per node + operations

Part 1: Partition Keys and Sort Keys

Partition Key (Required)

Every table has a partition key that determines which partition stores the item. DynamoDB does not expose (or internally use) a fixed 4,096-slot array the way this is sometimes taught — the actual internal partitioning scheme isn't part of DynamoDB's public contract, and the number and boundaries of partitions grow and split dynamically as a table's data size and throughput grow. The model that is accurate and useful for reasoning about hot keys:

Partition key: user_id

AWS hashes the partition key to decide which physical partition an
item lives on. The number of partitions is NOT fixed — DynamoDB adds
partitions automatically as a table grows past capacity or storage
thresholds for its current partition count, and splits an existing
partition when it outgrows its own throughput ceiling.

What IS fixed, and load-bearing for capacity planning: each partition
has a hard throughput ceiling (~3,000 RCU or 1,000 WCU, whichever is
hit first) and a storage ceiling (~10GB). Once a table needs more
capacity than one partition can serve, DynamoDB adds partitions and
redistributes data across them — you don't control or see this
directly, but the ceiling per partition is why a single hot key can
throttle even when the table overall has huge configured capacity.

AWS replicates each partition across multiple Availability Zones for
durability — the replication factor itself isn't a number DynamoDB
exposes as a tunable, unlike Cassandra's RF.

Sort Key (Optional)

Adds a second dimension for sorting within a partition:

CREATE TABLE orders (
  user_id string PRIMARY KEY,      -- partition key
  order_id string SORT KEY         -- sort key
);

Items:
  user_id="alice", order_id="order_1"
  user_id="alice", order_id="order_2"
  user_id="alice", order_id="order_3"

Query:
  SELECT * FROM orders WHERE user_id="alice" ORDER BY order_id DESC;
   Fast: queries one partition, sorts by order_id

Composite Key

Partition key: user_id
Sort key: timestamp (DESC)

Query: "Get all events for user_id='alice' in last 7 days"

WHERE user_id = 'alice' AND timestamp > now() - 7 days
→ Scans partition for alice, filters by timestamp
→ Fast (if sorted order matches query)

Part 2: Global Secondary Indexes (GSI)

GSI lets you query by any attribute, not just the partition key:

CREATE TABLE users (
  user_id string PRIMARY KEY,
  email string,
  name string,
  created_at timestamp
);

-- Primary key query (fast, no GSI needed)
SELECT * FROM users WHERE user_id = 'alice';

-- Query by email (need GSI). Query requires *equality* on the GSI partition key.
CREATE GLOBAL SECONDARY INDEX ON users(email);
SELECT * FROM users WHERE email = 'alice@example.com';

-- Time-range is NOT a Query if created_at is the GSI partition key.
-- `WHERE created_at > ...` with no equality PK is a Scan.
-- Pattern: bucket as PK, created_at as SK (watch the hot "today" partition).
GSI: pk = created_at_day (e.g. '2024-01-15'), sk = created_at
SELECT * FROM users
 WHERE created_at_day = '2024-01-15'
   AND created_at > '2024-01-15T12:00:00Z';
-- All new writes land in today's bucket — that partition can hotspot.

GSI Cost

GSI consumes separate capacity:

Main table (user_id partition):
  Allocated: 1000 RCU (read capacity units), 500 WCU (write capacity units)

GSI (email partition):
  Allocated: 1000 RCU, 500 WCU (separate from main table!)

Total cost = main table + all GSIs

Write a user:
  Main table: 1 WCU
  GSI-1 (email): 1 WCU
  GSI-2 (created_at_day + created_at): 1 WCU
  → 3 WCU total

Interview signal: "Every GSI increases write cost and storage. Only create indexes for queries you actually run."

Sparse Indexes

If an attribute is missing from many items, create a sparse index (only indexes items where the attribute exists):

Table: 1 billion users
Attribute: phone (only 10% have it)

Without sparse index:
  GSI stores 1 billion items (even if 900M have null)

With sparse index:
  GSI stores 100 million items (only those with phone)
  Much smaller, cheaper

Part 3: Capacity Modes

Provisioned Capacity

You pay for reserved capacity (even if unused):

Allocated: 1000 RCU, 500 WCU per second

Cost: ~$0.09 per RCU-month + ~$0.47 per WCU-month
      (approximate us-east-1 list prices; dated — check current)

1000 RCU: ~$90/month
500 WCU: ~$235/month
Total: ~$325/month

If you exceed capacity:
  → Requests are throttled (HTTP 400 ProvisionedThroughputExceededException)
  → Application must retry

Good for: predictable workloads (you know capacity needs).

On-Demand Capacity

You pay per request (no reserved capacity):

Cost: ~$0.25 per million RCU + ~$1.25 per million WCU

1 million reads: $0.25
1 million writes: $1.25

If traffic spikes:
  → Capacity auto-scales to absorb it, cost increases with usage
  → BUT on-demand does NOT mean throttling is impossible:
    - Per-partition throughput still has a hard ceiling (a single
      partition tops out around 3,000 RCU / 1,000 WCU) — a hot key
      can still get throttled even on-demand, same failure as
      provisioned mode, see Part 5
    - On-demand tables also cap how fast they scale: a table can
      generally handle up to double its previous peak traffic
      immediately, but a much larger, sudden spike can still outrun
      that ramp-up window and throttle until capacity catches up
    - Account-level and table-level service limits still apply

Good for: unpredictable workloads, spiky traffic, or prototype phases — but "no throttling" is not a guarantee on-demand gives you; it just removes the need to provision capacity in advance.

Choosing Between Modes

Workload Provisioned On-Demand
Steady 10K RCU/sec ~$900/month (10K × $0.09) ~$216/day (10K reads/sec × 86,400 sec/day = 864M reads/day ÷ 1M × $0.25 ≈ $216/day, ~$6,480/month)
Spiky (0-100K RCU/sec) Over-provision (pay for 100K) Auto-scale (pay per request)
Dev/prototype On-demand (cheap) On-demand (cheap)

This is the concrete reason on-demand's per-request pricing crosses over to being more expensive than provisioned once traffic is steady and predictable — on-demand's premium buys you not having to forecast capacity, and it's worth paying only while that uncertainty is real. At 10K RCU/sec sustained, provisioned is roughly 7× cheaper ($900 vs ~$6,480/month). On-demand wins when traffic is spiky enough that provisioning for peak costs more than on-demand at average (crossover ~7× peak/average at these list prices).


Part 4: Streams and Change Capture

DynamoDB Streams

Track changes to items in a stream (like Kafka):

Item: user_id="alice", balance=100

UPDATE users SET balance = 50 WHERE user_id="alice";

DynamoDB Stream:
  MODIFY: {
    "user_id": {"S": "alice"},
    "balance": {"N": "100"}        -- old value
  }
  {
    "user_id": {"S": "alice"},
    "balance": {"N": "50"}         -- new value
  }

Lambda Integration

Trigger Lambda on every DynamoDB change:

DynamoDB table → stream → Lambda function
                          (e.g., update cache, send email, log analytics)
flowchart LR
    App["Application"] -->|"write/update/delete"| Table[("DynamoDB Table")]
    Table -->|"change event<br/>(INSERT/MODIFY/REMOVE)"| Stream["DynamoDB Stream<br/>(ordered, per-shard)"]
    Stream --> Lambda["Lambda Consumer"]
    Lambda --> ES[("Elasticsearch<br/>search index")]
    Lambda --> Cache[("ElastiCache<br/>read-through cache")]
    Lambda --> Notif["SNS / Email<br/>notifications"]
    style Table fill:#1b5e20,color:#fff

Use case: Sync DynamoDB writes to Elasticsearch for full-text search:

1. Write to DynamoDB
2. Stream captures change
3. Lambda reads stream, indexes in ES
4. ES stays in sync with DynamoDB

Part 5: The Hot Partition Problem

DynamoDB partitions data by hash(partition_key). If one partition key gets all traffic, one partition serves everything:

Partition key: user_id

Celebrity user (100M followers):
  Every write to user_id="elon" goes to one partition
  That partition maxes out write capacity
  Other partitions sit idle

Actual: 1 partition at 95% capacity while others at 5%
flowchart TB
    subgraph Before["Before — key = user_id"]
        A1["All traffic for<br/>user_id='elon'"] --> P1["Partition 1<br/>95% capacity"]
        P2a["Partition 2<br/>5% capacity"]
        P3a["Partition 3<br/>5% capacity"]
    end
    subgraph After["After — key = user_id#random(0,99)"]
        A2["Same traffic,<br/>salted key"] --> P1b["Partition 1<br/>~33%"]
        A2 --> P2b["Partition 2<br/>~33%"]
        A2 --> P3b["Partition 3<br/>~33%"]
    end
    style P1 fill:#b71c1c,color:#fff
    style P1b fill:#1b5e20,color:#fff
    style P2b fill:#1b5e20,color:#fff
    style P3b fill:#1b5e20,color:#fff

Solutions

Solution 1: Distribute Key Writes

Instead of: user_id="elon"
Write to: "elon#" + random(0, 100)

"elon#0": write 1
"elon#1": write 1
...
"elon#99": write 1

Total: 100 writes distributed across 100 partitions

Read: Query "elon#*" (all 100 keys)

Solution 2: Cache Hot Reads

If elon's profile is read 10M times/sec:
  Cache in ElastiCache (Redis)
  Only cache misses hit DynamoDB

Solution 3: Adaptive Capacity (Not a Full Fix)

AWS DynamoDB's adaptive capacity:
  Reallocates a table's UNUSED provisioned throughput toward a hot
  partition, up to that partition's own hard ceiling (~3,000 RCU /
  1,000 WCU per partition).

What it does NOT do:
  - Does not replicate or split the hot partition itself
  - Does not raise the per-partition throughput ceiling
  - Cannot help once the hot partition is already saturating that
    ceiling — a single truly hot key (like "elon") can still throttle
    even with adaptive capacity fully engaged, because the ceiling is
    per-partition, not per-table

It buys you some headroom when a partition is hot but the table still
has slack capacity elsewhere — it does not make a single key infinitely
scalable. Solution 1 (spread the key itself across multiple physical
partitions) is still the actual fix for a genuinely hot single key.

Part 6: Transactions

DynamoDB supports atomic transactions across multiple items (different partition keys):

BEGIN TRANSACTION
  UPDATE accounts SET balance = 100 WHERE user_id="alice";
  UPDATE accounts SET balance = 50 WHERE user_id="bob";
COMMIT;

If either update fails → entire transaction rolls back
Both succeed → both changes persist

Cost: Transactions consume 2× the capacity (coordination overhead).

Normal write: 1 WCU
Transactional write: 2 WCU

If you do 1000 transactional writes/sec:
  Provisioned: 2000 WCU (vs 1000 for normal writes)
  Cost: 2× higher

Part 7: Operational Patterns

Point-in-Time Recovery (PITR)

Backup automatically (up to 35 days retention)

Restore: restore_time="2024-08-10 03:00:00"
→ DynamoDB creates a new table with data from that point
→ No downtime for original table

TTL (Time-to-Live)

Auto-delete items after a time period:

CREATE TABLE sessions (
  session_id string PRIMARY KEY,
  created_at timestamp,
  ttl_timestamp timestamp
);

Set TTL on column: ttl_timestamp

INSERT sessions VALUES ('session_123', now(), now() + 1 hour);
-- After 1 hour, item is automatically deleted (no write cost)

Global Tables

Multi-region replication (for low-latency reads across regions):

DynamoDB table in us-east-1
  ↕ (replicates both ways)
DynamoDB table in eu-west-1

Write in us-east-1 → automatically replicated to eu-west-1 (< 1 second)
Read from nearest region → always fast

Interview Scenarios

Scenario Answer
"How do we prevent hot partitions?" "Monitor by partition key. If one key gets > 30% of traffic: distribute writes across multiple sub-keys (user_123#0 through #99), cache hot reads in Redis, or enable DynamoDB adaptive capacity."
"When should we use provisioned vs on-demand?" "Provisioned: steady, predictable traffic (save money). On-demand: spiky or unpredictable. Measure peak/average — crossover is ~7× at these list prices (provisioned for peak vs on-demand at average)."
"Can we do complex queries?" "Not really. Query by partition key + sort key only (or GSI). Complex filtering (multiple columns) requires ExpressionAttribute. No JOINs. If you need that, consider Postgres."
"GSI capacity — how much do we need?" "Same as main table for the same throughput. Writes flow to both. If GSI falls behind (lag), queries might return stale data. Monitor with metrics."
"How do we sync DynamoDB to ES for search?" "DynamoDB Streams → Lambda → ES. Every write triggers Lambda, which updates ES. Lag depends on Lambda cold start (typically < 1 second)."

Key Takeaways

  • Partition key is fundamental: determines which partition stores data. Choose carefully (avoid hotkeys).
  • GSI costs extra: each GSI is a separate table with its own capacity. Write goes to main table + all GSIs.
  • Provisioned vs On-Demand: provisioned is cheap at steady scale; on-demand is cheap for spiky/prototype.
  • Hot partitions can't be solved by buying more table RCU: you must split the key (salting/suffixes) or cache hot reads.
  • Transactions are 2× cost: use only when needed (multi-item atomicity).
  • Streams + Lambda: powerful event-driven pattern. Use for real-time sync to ES, caches, analytics.
  • TTL is cheap: auto-delete items without write cost.