Skip to main content

Redis Sets and Sorted Sets: Leaderboards in Python

Redis sets are unordered collections of unique members. Redis sorted sets are sets where each member has a score, automatically ranked from highest to lowest. Sorted sets power real-time leaderboards, priority queues, and time-series data. With sorted sets, you can fetch the top 10 players by score, find a player's rank, and update scores in microseconds—orders of magnitude faster than querying a database.

After implementing leaderboards for a multiplayer game with 1 million daily active users, I learned that Redis sorted sets are the only tool that handles the concurrent updates, rank queries, and time-window filtering required by modern gaming and social platforms. This guide shows you production patterns.

Sets: Membership Testing and Unique Counting

Sets store unique members. Use them for deduplication, membership testing, and set operations (union, intersection).

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# Add members to a set
r.sadd('game:players', 'alice', 'bob', 'carol')

# Check membership
is_player = r.sismember('game:players', 'alice')
print(f"Alice is a player: {is_player}")

# Get all members
players = r.smembers('game:players')
print(f"Players: {players}")

# Get the number of members
player_count = r.scard('game:players')
print(f"Total players: {player_count}")

# Remove a member
r.srem('game:players', 'david')

# Get the difference of two sets
banned = r.sdiff('all:users', 'game:players')
print(f"Users not in game: {banned}")

# Get the intersection of two sets
online_players = r.sinter('game:players', 'currently_online')
print(f"Online players: {online_players}")

# Get the union of two sets
all_game_users = r.sunion('game:players', 'guest_players')

Sets are used for tags, categories, and group membership. A user's tags (r.sadd('user:123:tags', 'python', 'mongodb')) is a set. Finding all users with a specific tag requires a database query on SQL, but a single smembers() call on Redis if you maintain a reverse index.

Sorted Sets: Ranking and Leaderboards

Sorted sets are sets where each member has a score. Members are ranked from lowest to highest score (configurable).

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# Add members with scores
tournament = 'chess_championship_2024'
r.zadd(f'leaderboard:{tournament}', {
'alice': 2500,
'bob': 2400,
'carol': 2300,
'david': 2200,
'eve': 2100
})

# Get a member's score
alice_score = r.zscore(f'leaderboard:{tournament}', 'alice')
print(f"Alice's score: {alice_score}")

# Get a member's rank (0-based, lowest to highest)
alice_rank = r.zrank(f'leaderboard:{tournament}', 'alice')
print(f"Alice's rank (ascending): {alice_rank}")

# Get rank with scores descending (highest first)
alice_rank_desc = r.zrevrank(f'leaderboard:{tournament}', 'alice')
print(f"Alice's rank (descending, 0 = highest): {alice_rank_desc}")

# Get top 10 players (highest scores first)
top_10 = r.zrevrange(f'leaderboard:{tournament}', 0, 9, withscores=True)
print("Top 10 players:")
for rank, (player, score) in enumerate(top_10, start=1):
print(f"{rank}. {player}: {int(score)} Elo")

# Get players in a score range
players_2200_2400 = r.zrangebyscore(
f'leaderboard:{tournament}',
min=2200,
max=2400,
withscores=True
)

# Update a player's score (increment)
r.zincrby(f'leaderboard:{tournament}', 50, 'bob') # Add 50 to Bob's score
print(f"Bob's new score: {r.zscore(f'leaderboard:{tournament}', 'bob')}")

# Get count of players in a range
mid_tier_count = r.zcount(f'leaderboard:{tournament}', min=2200, max=2400)
print(f"Players with scores 2200–2400: {mid_tier_count}")

# Get the total number of members
player_count = r.zcard(f'leaderboard:{tournament}')
print(f"Total players: {player_count}")

# Remove a player
r.zrem(f'leaderboard:{tournament}', 'eve')

The zrevrange() method retrieves members in descending score order (highest first). For leaderboards, always use zrevrange() because you want the top players first. Setting withscores=True returns tuples of (member, score).

Leaderboard Patterns in Production

Time-Window Leaderboards

Create separate leaderboards for daily, weekly, and monthly rankings.

from datetime import datetime, timedelta

def record_score(game_id, player_id, score):
"""Record a player's score in hourly, daily, and weekly leaderboards."""
now = datetime.now()

# Hourly leaderboard (expires after 24 hours)
hour_key = f"leaderboard:{game_id}:hourly:{now.strftime('%Y-%m-%d-%H')}"
r.zadd(hour_key, {player_id: score})
r.expire(hour_key, 86400) # 24 hours

# Daily leaderboard (expires after 32 days to handle month boundaries)
day_key = f"leaderboard:{game_id}:daily:{now.strftime('%Y-%m-%d')}"
r.zadd(day_key, {player_id: score})
r.expire(day_key, 2764800) # 32 days

# Weekly leaderboard
week_key = f"leaderboard:{game_id}:weekly:{now.strftime('%Y-W%W')}"
r.zadd(week_key, {player_id: score})
r.expire(week_key, 864000) # 10 days

# Get daily leaderboard
daily_top = r.zrevrange(f"leaderboard:chess_tournament:daily:2024-06-02", 0, 9, withscores=True)

Time-window leaderboards automatically expire via Redis TTL. No background job needed.

Multi-Criteria Ranking

For complex rankings (score + tiebreaker), use sorted sets with composite scores.

import time

def add_player_with_tiebreaker(tournament, player_id, score, submission_time):
"""Add a player's score; use submission time as tiebreaker (earliest wins)."""
# Composite score: score * 1e9 - submission_time
# Higher game score ranks higher; earlier submission ranks higher on tie
composite_score = score * 1e9 - submission_time

r.zadd(f'leaderboard:{tournament}', {player_id: composite_score})

# Example: Programming contest where score is points, tiebreaker is submission time
add_player_with_tiebreaker('python_contest_2024', 'alice', 950, time.time())
add_player_with_tiebreaker('python_contest_2024', 'bob', 950, time.time() + 100) # Later

# Bob ranks lower due to later submission
top = r.zrevrange('leaderboard:python_contest_2024', 0, 1, withscores=True)
print(f"Top: {top}") # Alice first

This pattern combines multiple ranking criteria into a single score. For more complex ranking logic, store rankings in MongoDB and cache frequently accessed ranks in Redis.

Player's Percentile Rank

Find what percentile a player ranks in the full leaderboard.

def get_percentile(leaderboard_key, player_id):
"""Return player's percentile (0–100, where 100 is best)."""
total = r.zcard(leaderboard_key)
rank = r.zrevrank(leaderboard_key, player_id) # Descending (0 = highest)

if rank is None:
return None # Player not in leaderboard

percentile = ((total - rank) / total) * 100
return round(percentile, 2)

percentile = get_percentile('leaderboard:chess_championship_2024', 'alice')
print(f"Alice is in the {percentile}th percentile")

Percentile ranking is useful for matchmaking and player achievement unlocks.

Pattern: Pagination in Leaderboards

Fetch leaderboards in pages efficiently.

def get_leaderboard_page(leaderboard_key, page=1, page_size=20):
"""Get a page of the leaderboard (1-indexed)."""
start = (page - 1) * page_size
end = start + page_size - 1

players = r.zrevrange(leaderboard_key, start, end, withscores=True)
total = r.zcard(leaderboard_key)
total_pages = (total + page_size - 1) // page_size

return {
'players': [(name, int(score)) for name, score in players],
'page': page,
'total_pages': total_pages,
'total': total
}

# Get page 1 of the leaderboard
page = get_leaderboard_page('leaderboard:chess_championship_2024', page=1, page_size=10)
print(f"Page 1 of {page['total_pages']} ({page['total']} players)")
for i, (name, score) in enumerate(page['players'], start=1):
print(f"{i}. {name}: {score}")

Always paginate leaderboards. Fetching all members with zrange() on millions of players is slow.

Key Takeaways

  • Sets store unique members; use for membership testing and deduplication; sets support union, intersection, and difference operations
  • Sorted sets automatically rank members by score; zrevrange() gets top players; zrank() gets a player's rank
  • Build leaderboards with separate sorted sets per time window (hourly, daily, weekly); use Redis TTL for automatic expiration
  • Update scores atomically with zincrby() to avoid race conditions in concurrent games
  • Fetch leaderboard pages with zrevrange(start, end) instead of loading all members into memory

Frequently Asked Questions

How do I handle ties in a leaderboard?

Ties are common: multiple players with the same score. Redis ranks them by insertion order (stable sort). For deterministic tiebreaking, use composite scores: combine score and tiebreaker (e.g., submission time) into a single number.

Can I get a player's neighbors in the leaderboard (players ranked just above/below)?

Yes. Get the player's rank, then fetch the members above and below: zrevrange(key, rank - 5, rank + 5).

What is the maximum leaderboard size Redis can handle?

Redis sorted sets scale to billions of members (limited by RAM). For a leaderboard of 1 million players (each entry ~40 bytes), you need ~40 MB of RAM. Redis can handle this easily on a single server.

How do I reset a leaderboard for a new season?

Delete the old key and create a new one: r.delete('leaderboard:old_season'); r.zadd('leaderboard:new_season', {...}). Or use time-based keys as shown in the time-window pattern.

Can I rank by multiple criteria (score, then level, then experience)?

For two criteria, use a composite score as shown. For three or more, it becomes complex. Store rankings in MongoDB and cache top ranks in Redis for best performance.

Further Reading