DynamoDB Single-Table Design: Start From the Questions, Not the Table
The short code in the previous article was generated and then vanished, because there was nowhere to store it. This article builds that place with DynamoDB. But before creating the table, there's something more important than syntax: the way you think about data design in DynamoDB is the exact opposite of what's familiar from a relational database. Get this wrong and every query afterward fights you.
Goal
Grasp the design approach that starts from access patterns, understand what partition key and sort key really do, then build a single-table for the URL shortener with the concept of an item collection. We create a real table, write and query real data to see why this design is both fast and cheap. The table uses on-demand mode, so storing a few items costs essentially nothing.
Why not design like a relational database
With an RDBMS, you normalize data into separate tables without yet knowing how you'll query it, then write a JOIN when needed. The DynamoDB documentation describes the difference precisely: "In RDBMS, you design for flexibility without worrying about implementation details or performance... In DynamoDB, you design your schema specifically to make the most common and important queries as fast and as inexpensive as possible."
The reason lies in DynamoDB trading flexibility for scalability. The documentation says it bluntly: "In a NoSQL database such as DynamoDB, data can be queried efficiently in a limited number of ways, outside of which queries can be expensive and slow." You get a handful of query paths that are very fast at any scale, in exchange for designing those paths up front. The consequence is a line that's almost a mantra in the documentation: "you shouldn't start designing your schema for DynamoDB until you know the questions it will need to answer."
So the first step isn't drawing a table, it's listing the questions.
Access patterns of the URL shortener
These are the questions the system must answer, along with how often each runs, because whichever runs most has to be the fastest:
- Open a link: given a short
code, get the original URL. This is the hot path, running every time someone clicks a link, and must be the fastest. - Create a link: write a new link.
- List a user's links: given an
ownerId, get the links that person created (for the management page). - Count and view a link's click stats: increment a counter on each open, and pull per-day numbers for a dashboard.
This article handles pattern 1 and lays the groundwork for pattern 4. Pattern 3 (list by user) needs a global secondary index and is the subject of the next article. Pinning down these four questions up front is what determines the entire key design underneath.
Partition key and sort key
When you create a table, you must declare a primary key, the thing that uniquely identifies each item. DynamoDB has two kinds. The simple kind consists only of a partition key. The documentation explains the mechanism: "DynamoDB uses the partition key's value as input to an internal hash function. The output from the hash function determines the partition (physical storage internal to DynamoDB) in which the item will be stored." The partition key value is hashed to pick the physical partition that holds the item.
The second kind is a composite key, made of a partition key plus a sort key. With it, multiple items can share one partition key, and they're sorted by the sort key within that partition. This is the technical key to single-table design, because it lets you group related items into the same partition and retrieve them in order in a single query.
The best-practices documentation gives two principles that lead to good design. First, "Keep related data together", keep related data in the same place, because locality of reference improves speed. Second, "Use sort order", design keys so related items sort next to each other for compact queries. And one overarching piece of advice: "maintain as few tables as possible", keep as few tables as possible.
Single-table and item collection
Combining those three ideas gives the single-table model: every kind of data (links, stats, and later even users) lives together in one table, distinguished by a prefix in the key. The table has two key attributes with neutral names, PK (partition key) and SK (sort key), because they'll hold many different kinds of values.
For the URL shortener, we design the keys like this:
PK (partition) SK (sort) attributes
────────────────── ───────────────── ─────────────────────────────────
LINK#<code> META target, ownerId, createdAt, clicks
LINK#<code> STAT#<date> count
An item collection is the set of items that share a partition key. Here everything belonging to link aK9xQ2z (the link itself and all its per-day stat records) shares PK = LINK#aK9xQ2z, so they sit in the same partition:
PK = LINK#aK9xQ2z (one item collection)
┌─────────────────────────────────────────────────────────┐
│ SK = META target=..., ownerId=..., clicks=17 │ ← the link itself
│ SK = STAT#2026-05-24 count=5 │ ← stats for the 24th
│ SK = STAT#2026-05-25 count=12 │ ← stats for the 25th
└─────────────────────────────────────────────────────────┘
sorted by SK: META < STAT#2026-05-24 < STAT#2026-05-25
The sort key is chosen deliberately. META comes before any STAT#... because the letter M sorts before S, so the link itself is always the first item in the collection. The stat records use the prefix STAT# plus the date in YYYY-MM-DD form, so they naturally sort in chronological order. This key layout gives us two very different query paths from the same data, which you'll see below.
Create a real table
In the SAM template, the table is declared directly with AWS::DynamoDB::Table (not SimpleTable, because we need a sort key):
Table:
Type: AWS::DynamoDB::Table
Properties:
TableName: url-shortener
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: PK, AttributeType: S }
- { AttributeName: SK, AttributeType: S }
KeySchema:
- { AttributeName: PK, KeyType: HASH } # partition key
- { AttributeName: SK, KeyType: RANGE } # sort key
BillingMode: PAY_PER_REQUEST is on-demand mode: you pay per read/write rather than provisioning capacity in advance, which fits the bursty load of serverless. One easy point of confusion: AttributeDefinitions declares only the attributes that are part of a key, not every attribute of an item. Beyond PK and SK, the table is schemaless and each item carries its own attributes. After sam deploy, the table is in ACTIVE state:
$ aws dynamodb describe-table --table-name url-shortener \
--query 'Table.{Keys:KeySchema,Billing:BillingModeSummary.BillingMode,Status:TableStatus}'
{
"Keys": [
{ "AttributeName": "PK", "KeyType": "HASH" },
{ "AttributeName": "SK", "KeyType": "RANGE" }
],
"Billing": "PAY_PER_REQUEST",
"Status": "ACTIVE"
}
Write data
Write one link and two stat records for it. Note all three items share PK:
aws dynamodb put-item --table-name url-shortener --item '{
"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"META"},
"target":{"S":"https://docs.aws.amazon.com/dynamodb/"},
"ownerId":{"S":"user-001"},"createdAt":{"S":"2026-05-24T09:00:00Z"},
"clicks":{"N":"17"}
}'
aws dynamodb put-item --table-name url-shortener --item \
'{"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"STAT#2026-05-24"},"count":{"N":"5"}}'
aws dynamodb put-item --table-name url-shortener --item \
'{"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"STAT#2026-05-25"},"count":{"N":"12"}}'
Each attribute is wrapped in an object that names the type (S for string, N for number). This is DynamoDB's native JSON form; in the Lambda code we'll use the DocumentClient so we don't have to write types by hand, but seeing the raw form once helps you understand what's underneath.
Two query paths from the same data
The hot path is opening a link: given a code, get the original URL. Because we know both PK and SK exactly, this is a GetItem, DynamoDB's cheapest and fastest operation, reading exactly one item:
$ aws dynamodb get-item --table-name url-shortener \
--key '{"PK":{"S":"LINK#aK9xQ2z"},"SK":{"S":"META"}}' \
--query 'Item.{target:target.S,clicks:clicks.N}'
{
"target": "https://docs.aws.amazon.com/dynamodb/",
"clicks": "17"
}
The second path is fetching the entire item collection of a link, for a dashboard that needs both the link and all stats. This is a Query by PK only, returning the whole collection sorted by SK, in a single call:
$ aws dynamodb query --table-name url-shortener \
--key-condition-expression "PK = :pk" \
--expression-attribute-values '{":pk":{"S":"LINK#aK9xQ2z"}}' \
--query 'Items[].{SK:SK.S,target:target.S,count:count.N}' --output table
-----------------------------------------------------------------------
| SK | count | target |
+------------------+--------+-----------------------------------------+
| META | None | https://docs.aws.amazon.com/dynamodb/ |
| STAT#2026-05-24 | 5 | None |
| STAT#2026-05-25 | 12 | None |
+------------------+--------+-----------------------------------------+
This is the core point of single-table design, seen with real data. One Query brings back the link and both stat records at once, in the right order, because they sit in the same partition. In an RDBMS this would need a JOIN between a links table and a stats table; here it's a single-partition read, fast and cheap at any scale. That's exactly the "keep related data together" the documentation talks about, made concrete as a number: one read instead of one join.
🧹 Cleanup
Delete the demo items above with delete-item per key:
for sk in META STAT#2026-05-24 STAT#2026-05-25; do
aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"LINK#aK9xQ2z\"},\"SK\":{\"S\":\"$sk\"}}"
done
Keep the table around for later articles to keep using (it's part of the product). An empty on-demand table costs essentially nothing. If you want to stop entirely here, sam delete removes both the stack and the table.
Wrap-up
DynamoDB design starts from the query questions, not from tables. The partition key picks a partition via a hash function, the sort key orders items within the partition, and placing multiple item types under one partition key creates an item collection retrieved compactly in a single query. Our URL shortener now has a single-table where links and stats live together, serving both a single-item read path and a whole-collection read path.
One access pattern is still unsolved: listing links by user. The current key can't answer that, because we don't know a person's code to query by PK. The next article adds a global secondary index to open exactly that query path, and discusses the sparse index, a technique that turns the very absence of an attribute into an indexing tool.