Skip to content

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:

OperatorExampleReturns
=sk = "#METADATA"One specific item
begins_withsk begins_with "USER#"All users in the partition
betweensk 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:

pkskEntity
TENANT#t_01#METADATATenant record
TENANT#t_01#SUBSCRIPTIONBilling
TENANT#t_01USER#u_01User
TENANT#t_01PROJECT#01HVMK3P2QProject

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.


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.

← All glossary terms