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.