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
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Build a Real-Time Leaderboard with Redis Sorted Sets
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
FAQ
Q: What is the time complexity of sorted set operations?
A: 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).
Q: How many members can a sorted set hold? A: Up to 2^32 - 1 members. In practice, memory is the limiting factor — each member consumes roughly 80-100 bytes.
Q: Can I use floating-point scores? A: Yes. Redis sorted sets accept double-precision floats. Be aware of floating-point comparison issues for exact ties.
Q: How do I migrate a leaderboard to a new key?
A: Use ZUNIONSTORE to merge: ZUNIONSTORE new_key 1 old_key. Or dump and restore with DUMP/RESTORE.
Q: What happens when two members have the same score?
A: 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).
Q: How do I expire old leaderboard entries automatically?
A: 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.
Q: What is the memory consumption of a sorted set?
A: 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.
Q: Can I use Redis Cluster with sorted set leaderboards?
A: 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.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
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