Zero-Padding Sort Keys in DynamoDB
A DynamoDB string sorts lexicographically — one character at a
time, left to right — not numerically. So "10" lands before "2", because
"1" comes before "2". Zero-padding to a fixed width is how you make string
order match numeric order.
Why does "10" sort before "2" in a DynamoDB sort key?
Because a DynamoDB string is compared lexicographically by UTF-8 byte order, not numerically. The byte for "1" precedes "2", so "10" lands before "2". Pad every number to a fixed width with leading zeros — "2" becomes "0000000002" — and string order then matches numeric order exactly.
- The trap: numbers stored as strings sort like words.
"100","11","2"is the order DynamoDB gives you — not what you meant. - The fix: pad every number to a fixed width with leading zeros, so
"2"becomes"0000000002". Now lexicographic and numeric order agree. - Pick a width once: size it for the largest value you'll ever store, then add a few digits. Changing the width later means rewriting every key.
- Descending for free: to sort high-to-low (the leaderboard case), store
maxValue - value, also zero-padded — DynamoDB has no per-attribute sort direction.
Why string sort keys betray you
Coming from SQL, an ORDER BY score DESC over an integer column "just works" —
the engine knows the column is numeric. DynamoDB has no such luxury for a sort
key that isn't a Number type.
DynamoDB compares string (S) sort keys by UTF-8 byte order, per the
AWS sort-key documentation.
Bytes, not magnitude. "9" (0x39) outranks "10" because its first byte beats
"1" (0x31). Length is irrelevant — only the first differing byte decides.
That's the footgun: the moment a number lives inside a string sort key, every
Query that walks the range returns rows in an order that looks scrambled.
Build a leaderboard sort key
Take a seasonal arcade leaderboard. One per season holds every player's run, and you want the top scores first.
Model it with a in a single item collection:
leaderboardId(partition key) — e.g.SEASON#2026-SPRING.rankKey(sort key) — the zero-padded score plus a tiebreaker.
A naive first attempt stores the raw score as a string:
| leaderboardId | rankKey | playerHandle |
|---|---|---|
| SEASON#2026-SPRING | "9" | quickdraw |
| SEASON#2026-SPRING | "10" | ace_pilot |
| SEASON#2026-SPRING | "1500" | nightowl |
| SEASON#2026-SPRING | "240" | bytecrash |
A Query on SEASON#2026-SPRING returns them in this byte order:
"10", "1500", "240", "9". The 9-point run sits dead last and the
1500-point run is buried in the middle. Useless for a leaderboard.
Pad to a fixed width
Pick a width wide enough for the largest score you'll ever record, then left-pad with zeros. Say scores cap at ten million — that's eight digits, so use ten digits for headroom:
| leaderboardId | rankKey | playerHandle |
|---|---|---|
| SEASON#2026-SPRING | "0000000009" | quickdraw |
| SEASON#2026-SPRING | "0000000010" | ace_pilot |
| SEASON#2026-SPRING | "0000000240" | bytecrash |
| SEASON#2026-SPRING | "0000001500" | nightowl |
Now every key is the same length, so byte-by-byte comparison and numeric
comparison produce the identical order. Ascending Query gives 9, 10, 240, 1500. The math finally matches the bytes.
The width is a one-way door. If you pad to ten digits and a score later exceeds
that, an 11-digit value sorts before a 10-digit one — re-breaking everything —
and fixing it means rewriting every existing rankKey. Over-provision the width;
the cost is a handful of bytes.
Sort descending: store the difference
A leaderboard wants the highest score first. DynamoDB can read a sort key
forward or backward with ScanIndexForward: false, so descending is usually a
read-time flag — reach for that first.
But when one item collection must serve mixed sort directions, or you want the
top score physically first regardless of read flags, flip the number itself.
Store maxValue - score, zero-padded to the same width:
| score | inverted (9999999999 - score) | rankKey |
|---|---|---|
| 1500 | 9999998499 | "9999998499" |
| 240 | 9999999759 | "9999999759" |
| 10 | 9999999989 | "9999999989" |
| 9 | 9999999990 | "9999999990" |
Ascending byte order over the inverted value now yields the original scores
high-to-low: 1500, 240, 10, 9. The trick is in the
2007 Amazon Dynamo paper's
spirit — keys are opaque bytes, so you encode intent into the bytes.
Add a tiebreaker
Two players can tie. A bare padded score collides on the sort key, and a second write would overwrite the first (same PK + SK). Append a unique suffix so each run is a distinct item and ties resolve deterministically:
rankKey = "<paddedScore>#<paddedTimestamp>#<playerId>"For example "0000001500#0000001719100800#p_8842". Same score, earlier
timestamp wins the higher slot — pad the timestamp too, or it reintroduces the
exact bug you just fixed.
In DynoTable, you can browse the season leaderboard sorted by the zero-padded rankKey
and watch the padded values line the rows up correctly — proof the widths are right
before you ship them.
Assembling that composite key by hand, it's easy to fat-finger a width. Generating the
KeyConditionExpression for a "top of the season" Query in the
expression builder keeps the begins_with /
between syntax honest while you experiment with widths.

Pitfalls to avoid
- Padding too narrow. The whole scheme collapses the first time a value overflows the width. Size for the worst case, then add digits.
- Forgetting the read flag. If you only ever read descending,
ScanIndexForward: falsemay be all you need — don't reach for inverted keys when a flag does it. - Mixed widths in one collection. Every key sharing a sort range must use the same width. A migration that pads new rows but not old ones interleaves them wrongly.
- Padding the wrong segment. In a composite key, pad every numeric segment that participates in ordering — score and timestamp both, not just the score.
Next steps
Zero-padding is one tool in the broader
sort-key design toolkit; pair it with
item collections when you overload a key to serve several
patterns, and lean on a precise Query instead of a
Scan once the ordering is right.
Try DynoTable to browse a real table and watch your zero-padded sort keys fall into numeric order before you ship the schema.


