DynamoDB rewards up-front modeling and punishes relational habits; good key design is the difference between fast and throttled.
DynamoDB modeling starts from access patterns, not entities. List every query the app makes, then design keys so each is a GetItem or a single Query.
Single-table design stores several entity types together with generic keys: PK=CUSTOMER#42, SK=PROFILE and PK=CUSTOMER#42, SK=ORDER#2026-10-02#A17. One Query on CUSTOMER#42 returns the customer and their orders, the DynamoDB equivalent of a join. Many teams prefer one table per bounded context; the principle that matters is pre-joining data that is read together.
Secondary indexes add access patterns:
- GSIs have their own partition and sort keys, are eventually consistent, can be added any time, and project only the attributes you choose. Sparse GSIs index only items that have the key attribute, giving cheap filtered lists.
- LSIs share the table's partition key with a different sort key, must be created with the table, and cap each item collection at 10 GB. Prefer GSIs.
Throughput: each physical partition serves up to about 3,000 read units and 1,000 write units per second. Adaptive capacity helps, but a single hot key (a global counter, today's date as partition key) still throttles. Spread writes with high-cardinality keys or write sharding (DATE#2026-10-17#7).
Correctness tools: conditional writes for uniqueness (attribute_not_exists(PK)) and optimistic locking with a version attribute; TransactWriteItems for all-or-nothing changes across up to 100 items (at double cost); Streams to react to changes with Lambda, for projections, search indexing or events. For ad-hoc analytics, export to S3 rather than scanning.
Example
import boto3
from boto3.dynamodb.conditions import Key
from botocore.exceptions import ClientError
t = boto3.resource("dynamodb", region_name="eu-west-1").create_table(
TableName="app", BillingMode="PAY_PER_REQUEST",
KeySchema=[{"AttributeName": "PK", "KeyType": "HASH"}, {"AttributeName": "SK", "KeyType": "RANGE"}],
AttributeDefinitions=[{"AttributeName": a, "AttributeType": "S"} for a in ("PK", "SK")])
t.put_item(Item={"PK": "CUSTOMER#42", "SK": "PROFILE", "name": "Ada", "ver": 1})
t.put_item(Item={"PK": "CUSTOMER#42", "SK": "ORDER#2026-10-02#A17", "total": 35})
def rename(new_name, expected_ver): # optimistic locking
try:
t.update_item(Key={"PK": "CUSTOMER#42", "SK": "PROFILE"},
UpdateExpression="SET #n = :n, ver = ver + :one",
ConditionExpression="ver = :v", ExpressionAttributeNames={"#n": "name"},
ExpressionAttributeValues={":n": new_name, ":v": expected_ver, ":one": 1})
return "ok"
except ClientError as e:
return e.response["Error"]["Code"]
print(rename("Ada L.", 1)) # ok
print(rename("Ada K.", 1)) # ConditionalCheckFailedException (stale version)
items = t.query(KeyConditionExpression=Key("PK").eq("CUSTOMER#42"))["Items"]
print([i["SK"] for i in items]) # ['ORDER#2026-10-02#A17', 'PROFILE']Remember
- List access patterns first, then design keys and indexes for them
- Store data read together under one partition key
- GSIs add access patterns; hot keys throttle regardless of table capacity
- Use conditional writes, transactions and Streams for correctness and reactions
Watch out for
- Using a low-cardinality partition key such as status or today's date, concentrating traffic on one partition.