Skip to content

DynamoDB Schema Pattern: Gaming Leaderboard

Leaderboards are the canonical “DynamoDB or Redis?” question. Both can do it. The right answer depends on the size of your boards, how often you query rank vs top-N, and whether you need durability of every score or just the current snapshot.

This pattern handles the DynamoDB-friendly cases: daily, weekly, and seasonal leaderboards with thousands to low-millions of players, and game session history that you actually want to keep. The trick is the padded score sort key - storing scores as zero-padded fixed-width strings so DynamoDB’s lexicographic sort matches numeric sort. With that, top-N becomes a simple Query with ScanIndexForward: false.

For global all-time leaderboards with tens of millions of players and high-frequency rank lookups, Redis sorted sets are the right tool. I’ll be honest about the trade-off below.

Access patterns

#Access PatternOperation
AP1Get player by IDGetItem
AP2Get player by email (login)Query (GSI)
AP3Get top N scores on a leaderboardQuery (sorted desc)
AP4Get player’s best score on a leaderboardQuery (GSI, scoped)
AP5Get player’s scores across all leaderboardsQuery (GSI)
AP6List player’s recent game sessionsQuery (primary)
AP7List sessions in a game (across players)Query (GSI)
AP8Get session detailGetItem
AP9Get leaderboard metadataGetItem
AP10Player’s rank on a leaderboard (≤ 10K players)Query (count items above)

Ten access patterns, four entities, two GSIs.

The padded score trick

DynamoDB sort keys are strings. Sorting is lexicographic, not numeric. That means "95" sorts after "100" because "9" > "1" byte-wise.

Pad the score to a fixed width and the problem disappears:

score 100  → "0000000100"
score 95   → "0000000095"
score 1500 → "0000001500"

Lex sort ascending: "0000000095", "0000000100", "0000001500"
Numeric sort:        95, 100, 1500
                     ✅ same order

For most games, 10 digits (max ~10 billion) is plenty. Bigger games can use 12 or 16. The choice is permanent for the leaderboard - changing pad width invalidates the entire sort - so pick a comfortable max upfront.

The Score entity computes paddedScore from score automatically. Application code only ever sees the numeric value.

Entities

  • Player: profile, lifetime aggregate stats (denormalized for fast display).
  • Leaderboard: metadata for a board (daily, weekly, seasonal, global). Each board has its own ID.
  • Score: one player’s score on one leaderboard. The leaderboard’s partition collects all scores.
  • Session: the play that produced the score. Stored in the player’s partition for “my recent games.”
Gaming Leaderboard entity diagram showing Player, Leaderboard, Score, Session relationships
4 entities · 2 GSIs · padded-score sort key for top-N queries

Table design

Primary key structure

EntityPKSK
PlayerPLAYER#<playerId>#METADATA
LeaderboardLEADERBOARD#<leaderboardId>#METADATA
ScoreLEADERBOARD#<leaderboardId>SCORE#<paddedScore>#<playerId>
SessionPLAYER#<playerId>SESSION#<ulid>

The Score’s primary key collects all scores for a leaderboard in one partition, sorted by paddedScore then playerId. Querying LEADERBOARD#season_3 with ScanIndexForward: false, Limit: 100 returns the top 100 scores, no GSI required.

Sessions are stored in the player’s partition because “my game history” is the most common query.

GSI design

GSIEntitygsi1pk / gsi2pkgsi1sk / gsi2skPurpose
GSI1PlayerPLAYER_EMAIL#<email>PLAYER#<playerId>Login
GSI1ScorePLAYER#<playerId>SCORE#<leaderboardId>Player’s scores across boards
GSI2SessionGAME#<gameId>SESSION#<sessionId>All sessions in a game

GSI1 is overloaded: PLAYER_EMAIL# and PLAYER# prefixes don’t collide. GSI2 is dedicated to Sessions.

Gaming Leaderboard DynamoDB schema: PK, SK, GSI columns for Player, Leaderboard, Score, Session
4 entities · 2 GSIs · top-N served from primary partition with no GSI

Sample data

pkskgsi1pkgsi1skEntity Data
PLAYER#p_alice#METADATAPLAYER_EMAIL#alice@x.comPLAYER#p_alice{ name: "alice42", lifetimeScore: 145200, gamesPlayed: 87 }
LEADERBOARD#season_3#METADATA--{ name: "Season 3", type: "season", startsAt: "...", endsAt: "..." }
LEADERBOARD#season_3SCORE#0000009850#p_bobPLAYER#p_bobSCORE#season_3{ score: 9850, attainedAt: "..." }
LEADERBOARD#season_3SCORE#0000008420#p_alicePLAYER#p_aliceSCORE#season_3{ score: 8420, attainedAt: "..." }
LEADERBOARD#season_3SCORE#0000005100#p_carolPLAYER#p_carolSCORE#season_3{ score: 5100, attainedAt: "..." }
PLAYER#p_aliceSESSION#01HW...--{ gameId: "tetris", score: 8420, durationSec: 285 }

The LEADERBOARD#season_3 partition contains the leaderboard metadata and all scores in one partition. A descending query with Limit: 10 returns the top 10 - in one read.

Resolving each access pattern

AP1 - Get player by ID: GetItem(pk=PLAYER#p_alice, sk=#METADATA)

AP2 - Get player by email: Query(GSI1, gsi1pk=PLAYER_EMAIL#alice@x.com)

AP3 - Top N scores on a leaderboard:

Query(pk=LEADERBOARD#season_3, sk begins_with SCORE#,
      ScanIndexForward=false, Limit=N)

The killer query. One read, sorted, capped at N. The padded score makes lex sort equivalent to numeric sort.

AP4 - Player’s best score on a leaderboard:

Query(GSI1, gsi1pk=PLAYER#p_alice, gsi1sk=SCORE#season_3)

Returns the player’s score on a specific leaderboard. If you allow multiple scores per player per board (e.g. attempts), keep only the best on insert via a conditional write.

AP5 - Player’s scores across all leaderboards:

Query(GSI1, gsi1pk=PLAYER#p_alice, gsi1sk begins_with SCORE#)

AP6 - Player’s recent game sessions:

Query(pk=PLAYER#p_alice, sk begins_with SESSION#, ScanIndexForward=false)

AP7 - All sessions in a game:

Query(GSI2, gsi2pk=GAME#tetris)

AP8 - Session detail: GetItem(pk=PLAYER#<playerId>, sk=SESSION#<sessionId>) - requires both keys.

AP9 - Get leaderboard metadata: GetItem(pk=LEADERBOARD#season_3, sk=#METADATA)

AP10 - Player’s rank on a leaderboard:

Query(pk=LEADERBOARD#season_3,
      sk > SCORE#<player's paddedScore>#zzz,
      Select=COUNT)

Counts the number of scores higher than the player’s. Add 1 to get the rank.

This works fine for boards with < 10,000 players - one query, one round trip. For boards with millions of players, this becomes expensive because the count is O(N) above the player. See “When DynamoDB isn’t the right fit” below.

ElectroDB entity definitions

export const PlayerEntity = new Entity({
  model: { entity: "player", version: "1", service: "leaderboard" },
  attributes: {
    playerId:      { type: "string", required: true },
    name:          { type: "string", required: true },
    email:         { type: "string", required: true },
    lifetimeScore: { type: "number", required: true, default: 0 },
    gamesPlayed:   { type: "number", required: true, default: 0 },
    createdAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(), readOnly: true,
    },
    updatedAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(),
      set: () => new Date().toISOString(), watch: "*",
    },
  },
  indexes: {
    primary: {
      pk: { field: "pk", composite: ["playerId"], template: "PLAYER#${playerId}" },
      sk: { field: "sk", composite: [],           template: "#METADATA" },
    },
    byEmail: {
      index: "GSI1",
      pk: { field: "gsi1pk", composite: ["email"],    template: "PLAYER_EMAIL#${email}" },
      sk: { field: "gsi1sk", composite: ["playerId"], template: "PLAYER#${playerId}" },
    },
  },
}, { client, table });

Why this design

Padding the score in the SK is the entire reason DynamoDB can serve top-N at all. Without it, you’d need to either store scores as numbers in a GSI sort key (DynamoDB doesn’t support numeric SKs in this way), or post-sort everything in application code. Padding is permanent per board but trivial to implement.

Separate boards per period. Don’t try to maintain one global leaderboard partition forever. Spawn a new LEADERBOARD#daily_<date> each day and a new LEADERBOARD#season_N per season. Each board’s partition stays bounded in size and read traffic, and you can TTL old boards out of existence.

Score updates use conditional writes. A player improving their score on a leaderboard means deleting the old score record and inserting a new one - DynamoDB doesn’t let you mutate a key. Wrap the delete + insert in a transaction to avoid the “old score gone, new score not yet written” race window.

lifetimeScore and gamesPlayed on the Player record are read-optimized. Update them via a Stream-driven projection when sessions are written, rather than computing on every read.

Session history and leaderboard scores are different entities. A session is the immutable record of one play. A score is the mutable best-on-this-board record. Treating them as one entity would mean either re-keying on improvement (expensive) or duplicating the most recent attempt onto the score record (works, but conflates two concerns).

When DynamoDB isn’t the right fit

For some leaderboard shapes, Redis sorted sets (ZADD + ZRANGE) are simply better:

For massive global leaderboards (> 1M players with frequent rank lookups), Redis stores the leaderboard as a sorted set in memory. ZRANK is O(log N) - rank lookups are microseconds regardless of size. DynamoDB’s count-based rank (AP10) is fine for tens of thousands of players, gets uncomfortable at hundreds of thousands, and becomes a real cost at millions.

For real-time updates with very high write volume, Redis can sustain hundreds of thousands of ZADDs per second on a single instance. DynamoDB partition limits cap a single LEADERBOARD#X partition at 1,000 WCU/sec. You can shard the leaderboard partition, but Redis avoids the problem entirely.

For live dashboards with dozens of rank queries per page load, Redis is in-memory - the entire leaderboard is in RAM. DynamoDB charges per query.

The hybrid approach is what many production games use: Redis for live ranking and top-N display; DynamoDB for durable storage of player profiles, session history, and per-period leaderboard archives. When a season ends, the Redis sorted set gets snapshotted into DynamoDB Score records via a one-time job, and the Redis set can be discarded.

For most games though - especially if you’re early-stage, your boards are scoped (daily, weekly, season), and you don’t have millions of concurrent players - DynamoDB-only is simpler, cheaper, and avoids running another stateful service.

What this schema doesn’t support

Unsupported QueryWhyIf You Need It
Player’s percentile rank globallyO(N) count above; expensive at scaleRedis, or precomputed and cached
”Players within 100 of my score” (window query)Possible but awkward (sk BETWEEN bounds)Two-sided range query on the leaderboard partition
Cross-game leaderboardsNo index spans gamesAdd a GAME#<gameId> partition with cross-board scores, or stream to a separate index
Real-time top-100 with sub-100ms freshness for millions of playersPartition write limitsRedis or sharded leaderboard partitions

This is the unsupported queries exercise. Most games don’t need any of these; the ones that do should plan for Redis from the start.

Design this visually → coming soon

Drag a Player, Leaderboard, Score, and Session onto a canvas. See the padded-score SK pattern. Spot the partition that would be hot if you tried to make it global. That’s what I’m building at singletable.dev.

Join the waitlist →


Pattern #8 of 10 in the SingleTable pattern library. The padded-key trick also appears in the analytics events pattern for time-bucketed sorting. The Redis-vs-DynamoDB tradeoff returns in the chat messaging pattern (read receipts) and the event sourcing pattern (rapid replay).

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.