This website uses cookies

Read our Privacy policy and Terms of use for more information.

What it actually is

DynamoDB is a hash table that AWS runs for you across multiple Availability Zones, with a query language bolted on the side. You give every item a partition key, optionally a sort key, and DynamoDB hashes the partition key to decide which physical partition stores the item. Everything it is good at, and everything it is bad at, follows from that one sentence.

It is not a relational database with the joins removed. It is a system where you decide your access patterns first, then shape the data so each pattern is a single key lookup or a single range read on one partition. If you design the table first and discover the queries later, you will fight the service until you migrate off it. That is the mental model the docs spread across forty pages.

What AWS operates: partition placement and splitting, replication, patching, scaling the fleet, failover. What you operate: the key design, the index design, the capacity mode, item sizes, and every access pattern. There are no servers to size, but there is a data model to get right, and it is much harder to change than a schema.

The model

A table is a collection of items. Each item is a bag of attributes, up to 400 KB including attribute names and values. The primary key is either a partition key alone, or a partition key plus a sort key. Partition keys can be 1 to 2048 bytes, sort keys 1 to 1024 bytes. All items sharing a partition key form an item collection, stored together and sorted by sort key, which is why a Query for "everything under this partition key between these two sort values" is cheap.

Each partition is designed to handle up to 3,000 read units per second and 1,000 write units per second. That is a per-partition ceiling, independent of how much capacity the table as a whole has. The docs give a concrete example: with a 20 KB item, one strongly consistent read costs 5 read units, so a single item tops out near 600 strongly consistent reads per second before its partition throttles (3,000 divided by 5).

Capacity is measured in units. One write unit is a write of up to 1 KB. One read unit is one strongly consistent read of up to 4 KB, or two eventually consistent reads of up to 4 KB. Transactional operations cost double. Reads are eventually consistent by default, and you ask for strong consistency per request on the base table. Global secondary indexes only offer eventual consistency.

Secondary indexes are how you get a second access pattern. A global secondary index (GSI) is a separate copy of your data with a different key, maintained asynchronously. A local secondary index (LSI) shares the partition key but changes the sort key, and must be created with the table. LSIs also impose a 10 GB limit per item collection, which is why most teams skip them. You get 20 GSIs and 5 LSIs per table by default.

Since November 2025, a GSI key can be built from up to four attributes on the partition side and up to four on the sort side, so you no longer have to concatenate fields into synthetic strings like TENANT#acme#REGION#eu. The rules are strict: every partition attribute needs an equality condition, sort attributes are queried left to right with no gaps, and an inequality must be the last condition.

Two more behaviors worth knowing before you build. Burst capacity keeps up to 300 seconds of unused capacity in reserve for spikes. Adaptive capacity shifts throughput toward hot partitions automatically and can isolate a single hot item onto its own partition. Both help, neither removes the 3,000/1,000 per-partition ceiling.

When to use it, when not to

DynamoDB fits when you know your access patterns, need predictable single-digit-millisecond lookups at any scale, and want to stop thinking about servers. Session stores, shopping carts, user profiles, entitlement checks, idempotency tables, device state, leaderboards with known keys, and event metadata are the sweet spot.

It fits badly when the access patterns are unknown or ad hoc, when you need joins and aggregations across entities, or when the team will run analytical queries against it. The honest comparison:

Need

DynamoDB

Aurora / RDS

DocumentDB

Known key-based access at huge scale

Excellent

Works, you manage scaling limits

Works

Ad hoc queries and joins

Poor (scan or export)

Excellent

Moderate

Capacity planning

None in on-demand mode

Instance and storage sizing

Instance sizing

Schema change cost

Data model rework

ALTER TABLE

Flexible documents

Multi-item transactions

Up to 100 items, 4 MB

Full SQL transactions

Supported

Pricing shape

Per request or per provisioned unit

Per instance hour

Per instance hour

If you are unsure, the tiebreaker is the question "can I list every query this system will ever run?". If yes, DynamoDB. If the honest answer is "no, product will invent more", start relational and move hot paths to DynamoDB later.

What it costs

There are two capacity modes. The docs describe on-demand as the default and recommended choice for most workloads, and provisioned as the choice when traffic is steady and forecastable. Prices below are the us-east-1 Standard table class figures from the pricing page at the time of writing; check the live page for your Region.

On-demand charges $0.625 per million write request units and $0.125 per million read request units. Provisioned charges $0.00065 per WCU-hour and $0.00013 per RCU-hour. Storage is $0.25 per GB-month on Standard, or $0.10 on Standard-IA, which raises request prices by roughly 25 percent in exchange for cheaper storage. Point-in-time recovery adds $0.20 per GB-month, and on-demand backups $0.10 per GB-month.

The free tier gives you, per Region per month, 25 provisioned WCUs and 25 RCUs, 25 GB of storage, and 2.5 million Streams read requests. Note that on-demand request units are not part of that free allowance, so a toy on-demand table is not strictly free, only nearly so.

The surprise is write amplification. Every GSI that includes an item receives its own write, billed on top of the base table write. A 3 KB item costs 3 write units; put it in a table with two GSIs that project it and you pay for roughly three times that. Transactional writes double again. The second surprise is item size rounding: a 1.1 KB item costs 2 write units, so a table full of 1.1 KB items costs double what a table of 1 KB items does. Shrink attribute names and compress blobs before you reach for provisioned mode.

Global tables bill replicated writes at $0.625 per million in us-east-1, in each replica Region. Streams reads cost $0.02 per 100,000 read request units, though Lambda triggers and global table replication are not charged for those reads.

Our tutorial: roughly 20,000 small writes and a few thousand reads. That is on the order of a few cents. We will turn on deletion protection and delete everything at the end.

The limits that bite

Item size is 400 KB, and attribute names count, which makes long JSON key names expensive in both bytes and money. A Query or Scan returns at most 1 MB per call, so pagination via LastEvaluatedKey is not optional. BatchWriteItem takes 25 put or delete requests and 16 MB; BatchGetItem takes 100 items and 16 MB. Transactions cover 100 unique items and 4 MB, within one account and one Region, and they are not supported across global table replicas.

At the table level, the default quotas are 40,000 read and 40,000 write request units per table in on-demand mode, and 40,000 RCU and WCU per table in provisioned mode, both adjustable. Provisioned mode also has an account-level cap of 80,000 RCU and WCU per Region. A new on-demand table starts with a warm throughput of 12,000 reads and 4,000 writes per second, and it can serve sustained traffic up to those levels instantly. Above that, it scales as traffic grows, and you can set a maximum throughput to cap runaway spend; requests beyond the maximum are throttled by design.

Operational limits trip people too. You can switch from provisioned to on-demand only 4 times in a rolling 24 hours. TTL can be enabled or disabled once per table per hour. You can create 2,500 tables per account per Region by default. Capacity decreases on provisioned tables are limited per day.

The failure mode that matters most is not any of these numbers. It is a skewed key. A table with 40,000 write units of headroom still throttles if everything lands on one partition key, because that partition tops out at 1,000 writes per second. We will do that on purpose in a moment.

Build it

We build an orders table in on-demand mode with a composite primary key, one GSI, deletion protection, a transactional write, and a deliberately hot key. Python and boto3, nothing else.

Prerequisites and IAM

You need Python 3.10 or later, pip install boto3, and an AWS account with credentials configured for a sandbox Region. Use a dedicated IAM user or role with this policy, replacing the account ID and Region:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "dynamodb:CreateTable", "dynamodb:DescribeTable", "dynamodb:UpdateTable",
      "dynamodb:DeleteTable", "dynamodb:PutItem", "dynamodb:GetItem",
      "dynamodb:Query", "dynamodb:UpdateItem", "dynamodb:TransactWriteItems",
      "dynamodb:UpdateContributorInsights", "dynamodb:DescribeContributorInsights"
    ],
    "Resource": [
      "arn:aws:dynamodb:us-east-1:111122223333:table/sl-orders",
      "arn:aws:dynamodb:us-east-1:111122223333:table/sl-orders/index/*"
    ]
  }]
}

Everything below is written from the current DynamoDB documentation and API reference. I did not execute it against an account for this issue, so run it in a sandbox and treat any deviation as a bug report to me.

Step 1: model the access patterns, then the keys

Write the queries before the table. Ours are: get a customer's profile, list a customer's orders newest first, fetch one order with its line items, and list all open orders across customers for a fulfillment worker. The first three fit one partition: partition key pk = CUSTOMER#<id>, sort key sk of PROFILE or ORDER#<timestamp>#<orderId>. The fourth crosses customers, so it needs a GSI.

The naive GSI is status as partition key. Do not do that. "OPEN" is one value shared by every open order, so every write to the index lands on a single partition. We use a sharded key instead: gsi1pk = OPEN#<n> with n between 0 and 7, and gsi1sk as the timestamp. The worker queries all eight shards and merges. That is write sharding, and it is the single most useful DynamoDB trick you will use.

import boto3
ddb = boto3.client("dynamodb", region_name="us-east-1")
TABLE = "sl-orders"

ddb.create_table(
    TableName=TABLE,
    BillingMode="PAY_PER_REQUEST",
    AttributeDefinitions=[
        {"AttributeName": "pk", "AttributeType": "S"},
        {"AttributeName": "sk", "AttributeType": "S"},
        {"AttributeName": "gsi1pk", "AttributeType": "S"},
        {"AttributeName": "gsi1sk", "AttributeType": "S"},
    ],
    KeySchema=[
        {"AttributeName": "pk", "KeyType": "HASH"},
        {"AttributeName": "sk", "KeyType": "RANGE"},
    ],
    GlobalSecondaryIndexes=[{
        "IndexName": "gsi1",
        "KeySchema": [
            {"AttributeName": "gsi1pk", "KeyType": "HASH"},
            {"AttributeName": "gsi1sk", "KeyType": "RANGE"},
        ],
        "Projection": {"ProjectionType": "ALL"},
    }],
    DeletionProtectionEnabled=True,
)
ddb.get_waiter("table_exists").wait(TableName=TABLE)
print("created")

On-demand tables take no throughput settings, which is the point. Note that you declare only key attributes in AttributeDefinitions. Every other attribute is schemaless. Projection ALL copies the full item into the index, which doubles storage and write cost for that index; in production, project only what the worker reads.

Step 2: write items with a condition and a transaction

Placing an order must create the order and bump the customer's order counter atomically, and it must be idempotent. A transaction plus a condition expression gives both.

import time, uuid, random

def place_order(customer_id, total_cents):
    order_id = uuid.uuid4().hex[:12]
    ts = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
    shard = random.randint(0, 7)
    ddb.transact_write_items(TransactItems=[
        {"Put": {
            "TableName": TABLE,
            "Item": {
                "pk": {"S": f"CUSTOMER#{customer_id}"},
                "sk": {"S": f"ORDER#{ts}#{order_id}"},
                "status": {"S": "OPEN"},
                "total": {"N": str(total_cents)},
                "gsi1pk": {"S": f"OPEN#{shard}"},
                "gsi1sk": {"S": f"{ts}#{order_id}"},
            },
            "ConditionExpression": "attribute_not_exists(pk)",
        }},
        {"Update": {
            "TableName": TABLE,
            "Key": {"pk": {"S": f"CUSTOMER#{customer_id}"}, "sk": {"S": "PROFILE"}},
            "UpdateExpression": "ADD order_count :one",
            "ExpressionAttributeValues": {":one": {"N": "1"}},
        }},
    ])
    return order_id

# seed one customer profile, then place three orders
ddb.put_item(TableName=TABLE, Item={
    "pk": {"S": "CUSTOMER#c1"}, "sk": {"S": "PROFILE"}, "name": {"S": "Ada"}})
for cents in (1200, 3400, 560):
    print(place_order("c1", cents))

The transaction costs double: each of the two actions is billed at 2 units per KB. That is the price of atomicity, and it is why you should not wrap every write in a transaction by default. ADD on a missing attribute creates it at zero first, so the counter needs no initialization. The condition attribute_not_exists(pk) makes the Put fail if that exact key exists, which protects against a retry writing a duplicate.

Step 3: query, paginate, and read the bill

A Query reads one item collection in sort order. We read a customer's orders newest first and print what it consumed.

def list_orders(customer_id):
    items, kwargs = [], dict(
        TableName=TABLE,
        KeyConditionExpression="pk = :pk AND begins_with(sk, :p)",
        ExpressionAttributeValues={":pk": {"S": f"CUSTOMER#{customer_id}"},
                                   ":p": {"S": "ORDER#"}},
        ScanIndexForward=False,
        ReturnConsumedCapacity="TOTAL",
    )
    consumed = 0.0
    while True:
        r = ddb.query(**kwargs)
        items += r["Items"]
        consumed += r["ConsumedCapacity"]["CapacityUnits"]
        if "LastEvaluatedKey" not in r:
            return items, consumed
        kwargs["ExclusiveStartKey"] = r["LastEvaluatedKey"]

items, units = list_orders("c1")
print(len(items), "orders,", units, "read units")

Two habits to take from this. First, always loop on LastEvaluatedKey, because a 1 MB page boundary will arrive in production the week after you stop testing with small data. Second, always request ReturnConsumedCapacity in development; it turns the abstract pricing model into numbers you can reason about. An eventually consistent read of a small collection should show a fraction of a unit per page.

The fulfillment worker reads the sharded GSI by querying each shard:

def open_orders():
    out = []
    for n in range(8):
        r = ddb.query(TableName=TABLE, IndexName="gsi1",
            KeyConditionExpression="gsi1pk = :p",
            ExpressionAttributeValues={":p": {"S": f"OPEN#{n}"}})
        out += r["Items"]
    return sorted(out, key=lambda i: i["gsi1sk"]["S"])
print(len(open_orders()), "open orders")

Eight queries instead of one is the tax for write distribution. Run them in parallel in real code. Remember the index is eventually consistent, so an order placed a millisecond ago may not show up yet.

Step 4: create the hot partition on purpose

Turn on Contributor Insights, which reports the most accessed and most throttled keys, then hammer a single partition key.

ddb.update_contributor_insights(TableName=TABLE, ContributorInsightsAction="ENABLE")

import concurrent.futures as cf
def hammer(i):
    try:
        ddb.put_item(TableName=TABLE, Item={
            "pk": {"S": "CUSTOMER#hot"},
            "sk": {"S": f"ORDER#{i:08d}"},
            "pad": {"S": "x" * 900}})
        return "ok"
    except ddb.exceptions.ProvisionedThroughputExceededException:
        return "throttled"
    except Exception as e:
        return type(e).__name__

with cf.ThreadPoolExecutor(64) as pool:
    results = list(pool.map(hammer, range(20000)))
print({k: results.count(k) for k in set(results)})

Each item is about 1 KB, so each write is one unit, and a single partition sustains roughly 1,000 per second. With 64 threads against one key you are asking for more than that. Expect some throttled results once burst capacity is spent and adaptive capacity cannot add headroom, even though the table has 40,000 write units of default headroom. The SDK also retries throttled calls internally, so the counts you see are the ones that exhausted retries, and your latency will climb before errors appear. If your run shows zero throttles, raise the thread count or item count; the point is the shape of the curve, not an exact number.

Now rerun with the key spread: change "CUSTOMER#hot" to f"CUSTOMER#c{i % 200}". Same table, same total writes, same capacity mode, and the throttling disappears because 200 keys hash across many partitions. That contrast is the whole lesson of DynamoDB capacity planning, and it costs a few cents to see.

Verify it works

Run aws dynamodb describe-table --table-name sl-orders and confirm TableStatus is ACTIVE, BillingModeSummary.BillingMode is PAY_PER_REQUEST, DeletionProtectionEnabled is true, and the gsi1 index is ACTIVE. The WarmThroughput block on a fresh on-demand table should show 12,000 read and 4,000 write units per second, matching the docs.

Then check that list_orders("c1") returns three orders, newest first; that the profile item shows order_count equal to 3; and that open_orders() returns three items once the index catches up. In CloudWatch, look at ThrottledRequests and WriteThrottleEvents for the table during the hot run, and open the Contributor Insights view to see CUSTOMER#hot dominating the most-accessed keys. If the spread run shows a flat line where the hot run showed spikes, the experiment worked.

When it breaks

ValidationException: One or more parameter values were invalid: Missing the key means your key attributes do not match the schema, usually a typo in pk or sk. ValidationException: Value provided in ExpressionAttributeNames unused in expressions means you declared a #name placeholder you never used; DynamoDB rejects it, and even AWS sample code occasionally trips on this.

ProvisionedThroughputExceededException on an on-demand table is confusing at first. It almost always means a hot partition or a request above your configured maximum throughput, not a lack of table capacity. Check Contributor Insights for the dominant key, and check whether you set a max throughput cap on the table.

TransactionCanceledException carries a list of per-item cancellation reasons. ConditionalCheckFailed means your condition rejected the write, TransactionConflict means another transaction touches the same item, and ThrottlingError points at capacity on one of the items. Read CancellationReasons rather than guessing. ResourceInUseException on delete means deletion protection is still on.

Finally, if a Query returns fewer items than you expect with no error, you hit the 1 MB page limit. Look for LastEvaluatedKey.

Cleanup

Deletion protection stops you from deleting the table by accident, so turn it off first, then delete:

ddb.update_table(TableName=TABLE, DeletionProtectionEnabled=False)
ddb.get_waiter("table_exists").wait(TableName=TABLE)
ddb.update_contributor_insights(TableName=TABLE, ContributorInsightsAction="DISABLE")
ddb.delete_table(TableName=TABLE)
ddb.get_waiter("table_not_exists").wait(TableName=TABLE)
print("deleted")

Confirm with aws dynamodb list-tables that sl-orders is gone. A deleted on-demand table with no backups stops all charges. If you enabled point-in-time recovery or took a backup elsewhere, delete those too, since backup storage bills separately at $0.20 and $0.10 per GB-month respectively. Delete the IAM policy you created for the exercise as well.

What changed recently

DynamoDB keeps shipping. Recent announcements include multi-attribute composite keys for GSIs (November 2025), global tables replicating across multiple AWS accounts (February 2026), real-time vector search with vector indexes (August 2026), and, on October 15, 2026, timestamp-based Streams processing that lets consumers start from a chosen point in time using the AT_TIMESTAMP shard iterator type. None of these change the core contract. A partition key still decides where your item lives, and a skewed key still decides when you get throttled.

The practical takeaway is that DynamoDB rewards the engineers who do the boring modeling work up front. Write your access patterns on a whiteboard, shard anything that is shared by everyone, and read ReturnConsumedCapacity until the pricing model feels obvious. If a design needs a scan or an index on a low-cardinality attribute, treat that as a bug in the design, not a feature to tune.

Sources