Designing from access patterns

Amazon DynamoDB · Advanced · 18 min

Relational habits produce slow, expensive DynamoDB tables; access-pattern-first design is what makes it scale.

Relational design starts from entities and normalizes; queries come later. DynamoDB design runs the other way:

  • write down every access pattern with its parameters: "get user by id", "list a user's orders newest first", "get an order with its line items", "list open orders for a warehouse"
  • choose keys so each pattern is a single GetItem or Query on the table or an index
  • denormalize and duplicate data where it saves a read

A common technique is single-table design: store several entity types in one table with generic key names (PK, SK) and prefixed values. A customer and their orders share a partition, so one query fetches a customer with orders, which is DynamoDB's substitute for a join:

  • customer: PK = CUST#42, SK = PROFILE
  • order: PK = CUST#42, SK = ORDER#2025-03-01#A17
  • line item: PK = ORDER#A17, SK = ITEM#1

Querying PK = CUST#42 returns the profile and all orders; begins_with(SK, "ORDER#") returns orders only.

Single-table design is a tool, not a rule. Separate tables are fine when entities are never fetched together, and they are easier to understand, back up and stream. The goal is that every hot path is one request against a well-distributed key.

Example

import boto3
from boto3.dynamodb.conditions import Key

table = boto3.resource("dynamodb").Table("App")   # keys: PK (S), SK (S)
with table.batch_writer() as b:
    b.put_item(Item={"PK": "CUST#42", "SK": "PROFILE", "name": "Ann"})
    b.put_item(Item={"PK": "CUST#42", "SK": "ORDER#2025-01-09#A11", "total": 20})
    b.put_item(Item={"PK": "CUST#42", "SK": "ORDER#2025-03-01#A17", "total": 35})

# access pattern: customer with all orders, one request
both = table.query(KeyConditionExpression=Key("PK").eq("CUST#42"))["Items"]
print([i["SK"] for i in both])
# ['ORDER#2025-01-09#A11', 'ORDER#2025-03-01#A17', 'PROFILE']

# access pattern: latest order only
last = table.query(KeyConditionExpression=Key("PK").eq("CUST#42")
                   & Key("SK").begins_with("ORDER#"), ScanIndexForward=False, Limit=1)
print(last["Items"][0]["total"])   # 35

Remember

  • List access patterns before choosing keys
  • Every hot path should be one GetItem or Query
  • Pre-join related items by giving them the same partition key
  • Prefixed generic keys (PK/SK) let one table hold many entity types

Watch out for

  • Normalizing into many tables and then doing several sequential reads, or scans, to assemble one screen.

Check yourself

  1. In single-table design, how do you fetch a customer together with their orders in one request?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html