AWS Builder Center

DynamoDB Data Modeling for Beginners: Design Your Tables Around Access Patterns

Why DynamoDB tables are designed around queries instead of entities, and how to apply that with a worked example: key design, a global secondary index, boto3 code, and the pitfalls to avoid, including when single-table design isn't the right call.

Amazon DynamoDB is a fast, fully managed NoSQL database that shows up in a huge share of serverless projects. It is also where many developers coming from relational databases get stuck, because the habits that work in SQL work against you here.
In a relational database you model your entities first (customers, orders, products), normalize them, and figure out your queries later, because SQL can join almost anything. DynamoDB flips that order. There are no joins, so you start from the questions your application will ask and design the keys so each question can be answered with a single, efficient request.
This article walks through that mindset with a small worked example.

Step 0: List your access patterns

Before creating a table, write down every way the application will read data. For a simple online store, that might be:
  1. Get a customer's profile.
  2. List a customer's orders, newest first.
  3. Get the line items of a specific order.
  4. List all orders with a given status (for example, orders waiting to ship).
    This list is your real schema. If a pattern isn't on it, DynamoDB won't be able to serve it efficiently later.

Key concepts in 60 seconds

  • Every item is identified by a primary key. A composite key has a partition key (PK) and a sort key (SK).
  • A Query reads items that share one partition key value, and can narrow them by sort key (begins_with, ranges, ordering).
  • A Scan reads the whole table. It is slow and expensive at scale, so treat it as a last resort.
  • A global secondary index (GSI) gives you an alternate PK/SK so you can query the same data a different way.

Step 1: Design the keys

Using generic attribute names (PK, SK) and prefixes lets several entity types share one table:
EntityPKSKNotes
CustomerCUSTOMER#c1PROFILEname, email
Order (listing)CUSTOMER#c1ORDER#2026-10-08T14:30:00Z#o100status, total
Order line itemORDER#o100ITEM#sku-1quantity, price
The order's sort key starts with an ISO-8601 timestamp. Those sort alphabetically in chronological order, so listing a customer's orders newest first is just a Query in reverse.
For pattern 4 we add a GSI with GSI1PK = STATUS#<status> and GSI1SK = <timestamp>, which groups orders by status and sorts them by time.

Step 2: Create the table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import boto3

dynamodb = boto3.resource("dynamodb")

table = dynamodb.create_table(
TableName="AppTable",
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"},
}
],
)
table.wait_until_exists()
PAY_PER_REQUEST (on-demand) mode means you don't have to guess capacity up front, which is a comfortable default while you're learning or have unpredictable traffic.

Step 3: Write some data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from decimal import Decimal

table.put_item(Item={
"PK": "CUSTOMER#c1", "SK": "PROFILE",
"name": "Ana Torres", "email": "ana@example.com",
})

table.put_item(Item={
"PK": "CUSTOMER#c1",
"SK": "ORDER#2026-10-08T14:30:00Z#o100",
"orderId": "o100",
"status": "PLACED",
"total": Decimal("39.90"),
"GSI1PK": "STATUS#PLACED",
"GSI1SK": "2026-10-08T14:30:00Z",
})

table.put_item(Item={
"PK": "ORDER#o100", "SK": "ITEM#sku-1",
"quantity": 2, "price": Decimal("19.95"),
})
Notice that numbers with decimals use Python's Decimal. The boto3 resource interface doesn't accept floats.

Step 4: Serve each access pattern with one request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from boto3.dynamodb.conditions import Key

# 1. Customer profile
profile = table.get_item(
Key={"PK": "CUSTOMER#c1", "SK": "PROFILE"}
).get("Item")

# 2. A customer's orders, newest first
orders = table.query(
KeyConditionExpression=Key("PK").eq("CUSTOMER#c1") & Key("SK").begins_with("ORDER#"),
ScanIndexForward=False,
)["Items"]

# 3. Line items of an order
items = table.query(
KeyConditionExpression=Key("PK").eq("ORDER#o100") & Key("SK").begins_with("ITEM#"),
)["Items"]

# 4. Orders by status, oldest first
placed = table.query(
IndexName="GSI1",
KeyConditionExpression=Key("GSI1PK").eq("STATUS#PLACED"),
)["Items"]
Four questions, four requests, no joins and no scans. That is the payoff of designing around access patterns.
One practical note: a single Query returns at most 1 MB of data. For larger result sets, check LastEvaluatedKey in the response and pass it back as ExclusiveStartKey to fetch the next page.

Trade-offs to understand

  • Denormalization is normal. The order appears in the customer's partition (for listing) and its items live under their own key. If you duplicate fields across items, you are responsible for keeping them consistent, for example with TransactWriteItems.
  • GSIs are not free. Each index stores a copy of the data and every write to the table also writes to the index. GSIs are also eventually consistent.
  • Beware hot partitions. Using a low-variety value such as STATUS#PLACED as a GSI partition key sends all of those items to one key. That's fine for a small app, but at high write volume you would spread the load, for example by adding a suffix like STATUS#PLACED#3.
  • Filter expressions don't make reads cheaper. DynamoDB applies them after reading the items, so you still pay for everything it read. Put what you filter on into your keys instead.
  • Plan for limits. A single item can be at most 400 KB, so don't use items as a dumping ground for large documents. Store big files in Amazon S3 and keep a reference.

Is single-table design always the answer?

Not necessarily. Putting multiple entity types in one table helps when you often need related entities in one request. But it demands that you know your access patterns well up front, and it is harder to read and to change later. Using one table per entity is a perfectly valid approach when your access patterns don't need to combine entities. Start from your access patterns, and let them decide.

Clean up

When you finish experimenting, delete the table so it doesn't linger in your account:
1
table.delete()

Where to go next

  • Add TTL to expire temporary items such as sessions automatically.
  • Use DynamoDB Streams to react to changes, and connect them to Lambda.
  • Explore PartiQL if you prefer a SQL-like syntax for some operations.
    Remember the rule that ties it all together: list your access patterns first, design keys for them second, and write code last.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article