Sort Key
The sort key is the optional second component of a DynamoDB primary key. Within a partition, items are stored physically ordered by sort key, which makes range queries possible and makes a single Query able to return a precisely scoped subset of a partition.
Also called the range key in older AWS documentation. Same thing.
Two jobs, in tension
A sort key does two things at once, and good sort key design is mostly about balancing them:
Uniqueness. Partition key plus sort key must be unique across the table. The sort key is what lets a single partition hold many items.
Ordering. Items within a partition are sorted by sort key, which determines the order results come back and which ranges you can query.
These pull in different directions. A UUID gives you uniqueness with no useful ordering. A timestamp gives you ordering but isn’t unique and can’t be used for direct lookup. Resolving that tension is the subject of ULIDs vs UUIDs vs Timestamps.
Range operators
Unlike the partition key, which requires an exact match, the sort key supports a full set of conditions:
| Operator | Example | Returns |
|---|---|---|
= | sk = "#METADATA" | One specific item |
begins_with | sk begins_with "USER#" | All users in the partition |
between | sk between "ORDER#01HV0" and "ORDER#01HVZ" | A range of orders |
< <= > >= | sk > "2026-01-01" | Items after a boundary |
Plus ScanIndexForward: false to reverse the order, which is how you get “newest first” without a second index.
begins_with is the workhorse of single-table design. Because entity type prefixes are baked into sort key values, begins_with("USER#") is simultaneously a type filter and a range query, served by the primary key with no GSI required.
Hierarchical sort keys
Sort keys can encode a hierarchy by concatenating values most-general to most-specific:
LOCATION#US#CA#SAN_FRANCISCO#94103
Because ordering is lexicographic and left-to-right, one key structure serves queries at every level:
sk begins_with "LOCATION#US" → everything in the US
sk begins_with "LOCATION#US#CA" → everything in California
sk begins_with "LOCATION#US#CA#SAN_FRAN" → one city
The order of components is the constraint. You can filter on a prefix, never on a suffix. If you need to query by city without knowing the state, the hierarchy is in the wrong order, or you need a second index.
Sort key overloading
In a single-table schema, one sort key attribute holds unrelated patterns for different entity types within the same partition:
| pk | sk | Entity |
|---|---|---|
TENANT#t_01 | #METADATA | Tenant record |
TENANT#t_01 | #SUBSCRIPTION | Billing |
TENANT#t_01 | USER#u_01 | User |
TENANT#t_01 | PROJECT#01HVMK3P2Q | Project |
The # prefix on #METADATA and #SUBSCRIPTION is deliberate. # sorts before letters in UTF-8 byte order, so metadata records reliably appear first in the partition, and sk begins_with "#" fetches all singleton records for the entity in one query.
The lexicographic trap
Sort keys are strings, and strings sort by byte order, not numerically. This breaks numeric sort keys in a way that’s silent until your data crosses a digit boundary:
ORDER#1
ORDER#10 ← sorts before ORDER#2
ORDER#100
ORDER#2
Two fixes:
Zero-pad to a fixed width. ORDER#0000000002 sorts correctly, as long as you never exceed the width you chose. Pick generously.
Use a numeric sort key type. DynamoDB sort keys can be N instead of S, and numbers sort numerically. This works but forfeits prefixing, which means you can’t overload the sort key with other entity types in the same partition. In single-table design that’s usually disqualifying.
The same trap applies to dates. Use ISO 8601 (2026-08-05T14:22:00Z), which sorts correctly lexicographically. Anything like 05/08/2026 or Aug 5 2026 does not.
Common mistakes
Choosing a sort key that blocks direct lookup. PROJECT#<createdAt>#<projectId> sorts projects chronologically, but fetching one project requires knowing its creation timestamp, which callers rarely have. A ULID solves this: the timestamp is embedded in the ID, so PROJECT#<ulid> gives ordering and direct lookup from a single value.
Forgetting the sort key is optional. A table can have a partition key alone. If items in a partition are always singletons, adding a constant sort key like #METADATA is still worth it, because it leaves room to add related items later without a migration.
Using a delimiter that appears in the data. If # can occur inside a value you’re concatenating, parsing breaks and prefixes can collide. Sanitise inputs or pick a delimiter outside your data’s character set.
Putting a variable-length field in the middle. USER#<name>#<id> produces unpredictable ordering because names vary in length and content. Variable-length components belong at the end.
Assuming sort keys can be changed. Like partition keys, sort keys are immutable. Changing one is delete-plus-put.
Related terms
- Partition Key — the first key component, exact match only
- Composite Key — partition and sort key together
- Single-Table Design — where sort key overloading does the heavy lifting
Sort key design decides which queries are possible and which need a GSI you’ll pay for forever. I’m building singletable.dev to surface those consequences while the schema is still editable.