Build a Real-Time Leaderboard with Redis Sorted Sets
Use Redis sorted sets to implement real-time leaderboards with rank tracking, score updates, and top-N queries in O(log N) time
Redis sorted sets (ZSETs) store unique members ordered by score. They are the ideal data structure for leaderboards — you can update a score, get a rank, and fetch the top N players in logarithmic time. This recipe builds a leaderboard service with score updates, rank queries, and pagination.
When to Use This
- Game leaderboards with real-time score updates
- Ranking systems for content popularity or user activity
- Any scenario where you need to maintain an ordered set with frequent updates
Prerequisites
- Python 3.10+
redispackage (pip install redis)
Solution
1. Install Dependencies
pip install redis
2. Implement the Leaderboard
import logging
from redis import Redis
logger = logging.getLogger(__name__)
class Leaderboard:
def __init__(self, redis_client: Redis, key: str = "leaderboard"):
self.redis = redis_client
self.key = key
def add_score(self, member: str, score: float) -> int:
"""Add or update a member's score.
Args:
member: Unique member identifier (e.g., user ID).
score: Score to set.
Returns:
Number of new elements added (0 if updated existing).
"""
return self.redis.zadd(self.key, {member: score})
def increment_score(self, member: str, increment: float) -> float:
"""Increment a member's score by a delta.
Args:
member: Member identifier.
increment: Amount to add (can be negative).
Returns:
New score after increment.
"""
return self.redis.zincrby(self.key, increment, member)
def get_rank(self, member: str) -> int | None:
"""Get a member's rank (0-indexed, highest score first).
Args:
member: Member identifier.
Returns:
Rank (0 = top) or None if not on the leaderboard.
"""
rank = self.redis.zrevrank(self.key, member)
return rank
def get_score(self, member: str) -> float | None:
"""Get a member's current score."""
return self.redis.zscore(self.key, member)
def get_top_n(self, n: int = 10) -> list[dict]:
"""Get the top N members with scores.
Args:
n: Number of top members to return.
Returns:
List of {member, score, rank} dicts, highest first.
"""
results = self.redis.zrevrange(
self.key, 0, n - 1, withscores=True
)
return [
{"member": member.decode() if isinstance(member, bytes) else member,
"score": score, "rank": idx}
for idx, (member, score) in enumerate(results)
]
def get_around_member(self, member: str, count: int = 5) -> list[dict]:
"""Get members ranked around a specific member.
Args:
member: Member to center on.
count: Number of members above and below.
Returns:
List of nearby members with scores and ranks.
"""
rank = self.redis.zrevrank(self.key, member)
if rank is None:
return []
start = max(0, rank - count)
end = rank + count
results = self.redis.zrevrange(
self.key, start, end, withscores=True
)
return [
{"member": m.decode() if isinstance(m, bytes) else m,
"score": s, "rank": start + idx}
for idx, (m, s) in enumerate(results)
]
def remove_member(self, member: str) -> int:
"""Remove a member from the leaderboard."""
return self.redis.zrem(self.key, member)
def total_members(self) -> int:
"""Get the total number of members on the leaderboard."""
return self.redis.zcard(self.key)
def clear(self) -> int:
"""Remove all members from the leaderboard."""
return self.redis.delete(self.key)
3. Use the Leaderboard
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
lb = Leaderboard(r, key="game:scores")
# Add scores
lb.add_score("alice", 1500)
lb.add_score("bob", 2300)
lb.add_score("charlie", 1800)
lb.increment_score("alice", 500) # alice now has 2000
# Get top 3
top3 = lb.get_top_n(3)
# [{'member': 'bob', 'score': 2300.0, 'rank': 0},
# {'member': 'alice', 'score': 2000.0, 'rank': 1},
# {'member': 'charlie', 'score': 1800.0, 'rank': 2}]
# Get alice's rank
rank = lb.get_rank("alice") # 1
# Get players around alice
nearby = lb.get_around_member("alice", count=2)
# Returns 2 players above and below alice
4. Time-Based Leaderboards
Use Redis sorted sets with date-based keys for daily, weekly, or all-time leaderboards:
from datetime import date
class TimeBasedLeaderboard(Leaderboard):
def __init__(self, redis_client: Redis, game_id: str):
self.redis = redis_client
self.game_id = game_id
def _key(self, period: str = "all") -> str:
if period == "daily":
return f"lb:{self.game_id}:daily:{date.today().isoformat()}"
elif period == "weekly":
year, week, _ = date.today().isocalendar()
return f"lb:{self.game_id}:weekly:{year}-W{week}"
return f"lb:{self.game_id}:all"
def add_score(self, member: str, score: float, period: str = "all") -> int:
key = self._key(period)
return self.redis.zadd(key, {member: score})
def increment_score(self, member: str, increment: float, period: str = "all") -> float:
key = self._key(period)
return self.redis.zincrby(key, increment, member)
def get_top_n(self, n: int = 10, period: str = "all") -> list[dict]:
key = self._key(period)
results = self.redis.zrevrange(key, 0, n - 1, withscores=True)
return [
{"member": m, "score": s, "rank": idx}
for idx, (m, s) in enumerate(results)
]
5. Expire Old Leaderboards
Set TTLs on daily/weekly keys so old leaderboards auto-expire:
def add_score_with_expiry(self, member: str, score: float, period: str = "daily") -> int:
key = self._key(period)
result = self.redis.zadd(key, {member: score})
# Set expiry only if the key is new
if result == 1:
if period == "daily":
self.redis.expire(key, 86400 * 2) # 2 days
elif period == "weekly":
self.redis.expire(key, 86400 * 8) # 8 days
return result
How It Works
ZADDadds or updates a member’s score. If the member exists, the score is updated; otherwise, a new entry is created.ZINCRBYatomically increments a score, which is essential for concurrent score updates from multiple game servers.ZREVRANGEreturns members in descending score order (highest first).withscores=Trueincludes scores in the result.ZREVRANKreturns a member’s position in the sorted set, 0-indexed from the top.- Date-based keys (
lb:game:daily:2026-07-02) create separate sorted sets per period, andEXPIREcleans up old keys automatically.
Variants
Leaderboard with Ties
When members can have the same score, use the member’s join timestamp as a tiebreaker:
def add_score_with_tiebreak(self, member: str, score: float, join_time: float) -> int:
# Use a composite score: score * 1e10 + (max_time - join_time)
composite = score * 10_000_000_000 + (10_000_000_000 - join_time)
return self.redis.zadd(self.key, {member: composite})
Percentile Rank
def get_percentile(self, member: str) -> float | None:
"""Get the member's percentile (0-100, higher is better)."""
rank = self.redis.zrevrank(self.key, member)
total = self.redis.zcard(self.key)
if rank is None or total == 0:
return None
return ((total - rank - 1) / total) * 100
Leaderboard with Member Metadata
Store member metadata in a separate hash and join on retrieval:
def get_top_n_with_meta(self, n: int = 10) -> list[dict]:
top = self.get_top_n(n)
pipe = self.redis.pipeline()
for entry in top:
pipe.hgetall(f"user:{entry['member']}")
metas = pipe.execute()
for entry, meta in zip(top, metas):
entry["metadata"] = meta
return top
Best Practices
-
For a deeper guide, see Complete Guide to Redis Caching Strategies.
-
Use
ZINCRBYfor concurrent updates — it is atomic and avoids race conditions -
Set TTLs on time-based keys — daily/weekly leaderboards should expire to free memory
-
Use pipelines for batch reads — fetching metadata for top N members in one round trip
-
Keep member IDs short — sorted sets store the member string; long UUIDs increase memory usage
Common Mistakes
- Using
ZRANGEinstead ofZREVRANGE—ZRANGEreturns lowest-first, which is usually not what you want for leaderboards - Not handling missing members —
zscoreandzrevrankreturnNonefor non-existent members - Storing metadata in the sorted set — sorted sets only store member + score; use a separate hash for metadata
- Not expiring daily keys — without TTL, old leaderboard keys accumulate indefinitely
Frequently Asked Questions
What is the time complexity of sorted set operations?
ZADD and ZINCRBY are O(log N). ZREVRANGE is O(log N + M) where M is the number of elements returned. ZREVRANK is O(log N).
How many members can a sorted set hold?
Up to 2^32 - 1 members. In practice, memory is the limiting factor — each member consumes roughly 80-100 bytes.
Can I use floating-point scores?
Yes. Redis sorted sets accept double-precision floats. Be aware of floating-point comparison issues for exact ties.
How do I migrate a leaderboard to a new key?
Use ZUNIONSTORE to merge: ZUNIONSTORE new_key 1 old_key. Or dump and restore with DUMP/RESTORE.
What happens when two members have the same score?
Redis sorts by score first, then by member name lexicographically. If you need tie-breaking by timestamp, encode it in the score: score = actual_score * 1e10 + (max_timestamp - timestamp).
How do I expire old leaderboard entries automatically?
Sorted sets do not support per-member TTL. Use a separate sorted set as a "last seen" index and periodically remove stale members: ZREMRANGEBYSCORE leaderboard -inf <cutoff_score>. Alternatively, run a scheduled job that removes members whose last_active timestamp is older than your threshold.
What is the memory consumption of a sorted set?
Each member uses approximately 80–100 bytes (member name + score + skiplist pointers). A leaderboard with 1 million members uses roughly 80–100 MB. Monitor with MEMORY USAGE leaderboard_key.
Can I use Redis Cluster with sorted set leaderboards?
Yes, but all operations on a single sorted set must route to the same shard. Since sorted sets are single-key data structures, Redis Cluster handles this automatically via hash slot assignment. Cross-shard operations like ZUNIONSTORE require hash tags: {leaderboard}:daily and {leaderboard}:weekly.
Related Resources
Implement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
RecipeRedis Pub/Sub for Cross-Process Messaging
Use Redis pub/sub channels to broadcast events between processes, handle subscriptions, and implement real-time notifications
RecipeRate Limiting with Redis Token Bucket Algorithm
Implement a distributed token bucket rate limiter using Redis atomic operations for API throttling across multiple server instances