DynamoDB Composite Primary Key
A composite primary key is two attributes: a partition key and a sort key. The partition key decides where an item lives; the sort key orders items inside that partition.
Coming from SQL, think of it less as a unique id column and more as
GROUP BY partition, ORDER BY sort baked into the table itself.
What is a DynamoDB composite primary key?
A DynamoDB composite primary key combines two attributes: a partition key and a sort key. The partition key decides which physical partition an item lives in; the sort key orders items inside that partition. Together they form the item's unique identity and let a single Query return a sorted range instead of one item.
- Two parts, two jobs. The partition key routes the item to a physical partition; the sort key orders every item that shares that partition key.
- Uniqueness is the pair. Two items can share a partition key value as long as their sort keys differ — that's how one partition holds many rows.
- The sort key is the whole point. It's what lets a
Queryreturn a range (>=,between,begins_with) instead of one item, with noScan. - Keys must be scalars. Partition and sort keys can only be string, number, or binary — no maps, no lists (AWS docs).
Simple key vs composite key
A simple primary key is just a partition key. It uniquely identifies an
item, and you read it back with GetItem. That's it — no range reads, no
"give me the newest N".
A composite key adds the sort key, and that single addition is what makes DynamoDB feel like a database instead of a hash map.
| Simple key | Composite key | |
|---|---|---|
| Attributes | Partition key only | Partition key + sort key |
| Uniqueness | Partition key value | The pair of values |
| Multiple items per partition | No | Yes |
Query a range | No (GetItem only) | Yes (begins_with, between, >) |
| Natural fit | Lookup by id | Time series, one-to-many, history |
Model a sensor-readings table
Say you collect temperature samples from a fleet of field sensors. The access pattern is "get the readings for one device, newest first, within a time window". That's a textbook composite key.
Use the device id as the partition key and the reading timestamp as the sort key:
| deviceId | readingTs | tempC | humidity |
|---|---|---|---|
| DEV#a1b2 | 2026-06-23T08:00:00Z | 21.4 | 48 |
| DEV#a1b2 | 2026-06-23T08:05:00Z | 21.7 | 47 |
| DEV#a1b2 | 2026-06-23T08:10:00Z | 22.1 | 46 |
| DEV#c9d8 | 2026-06-23T08:00:00Z | 19.8 | 55 |
All three DEV#a1b2 readings land in the same partition, physically stored
together and sorted by readingTs.
AWS calls the partition key the hash attribute and the sort key the range attribute — the sort key is a range you can scan within (AWS docs).
Here's how the items collapse into one item collection under each partition key:
One Query against the partition key reads every reading for that device,
already in timestamp order — no sorting on the client, no second round trip.
Query the range, don't scan it
Because readingTs is an ISO-8601 string, it sorts lexicographically the same
way it sorts chronologically. So a time-window read is a key-condition range,
not a filter:
Query
deviceId = "DEV#a1b2"
readingTs BETWEEN "2026-06-23T08:00:00Z" AND "2026-06-23T08:10:00Z"
That's a KeyConditionExpression — it narrows the read before DynamoDB
returns data, so you pay only for the items in the window. A FilterExpression
runs after the read and bills you for everything it scanned through; that's
the Scan footgun in miniature.
The expression itself, with placeholders and typed values, is fiddly to write
by hand. Build it visually with the
DynamoDB Expression Builder and copy the
exact KeyConditionExpression into your SDK call.
Design the sort key on purpose
The sort key isn't free metadata — it's the only lever for range reads, so shape it to your queries.
- Use a sortable timestamp. ISO-8601 strings or zero-padded epoch numbers sort correctly; raw localised dates don't.
- Prefix it for one-to-many overloading. A sort key like
READING#2026-06-23T08:00:00Zlets you mix entity types under one partition and slice them withbegins_with. That's the seam into single-table design. - Put the high-cardinality dimension in the partition key. Sensor id has
thousands of values, so it spreads writes evenly. A low-cardinality partition
key (say,
region) creates a hot partition.
When a composite key bites you
It's a commitment, not a convenience. The trap: you pick a partition key, ship, then discover an access pattern that needs a different grouping — "all readings above 30°C across the whole fleet".
The base table can't answer that; the partition key is fixed. Your options are a global secondary index with a different key, or restructuring.
Enumerate your reads before you commit the key schema. Changing a primary key
means a table migration, not an ALTER TABLE.
Next steps
Composite keys are the foundation under item collections, one-to-many relationships, and most useful index designs — read single-table design and GSI vs LSI next to see where they lead.
Sketch your KeyConditionExpression in the
DynamoDB Expression Builder, then
try DynoTable to browse your real partitions and watch the sort
order line up against your own tables.