Skip to main content
Bytes & Beyond

Redis Sorted Sets

Sorted Sets in Redis - leaderboards, rankings, time-series data, and any ordered collection with scoring.

What are Sorted Sets?

A Sorted Set is like a Set (unique items) but each item has a score. Items are automatically ordered by their score. Perfect for rankings, leaderboards, and anything that needs ordering.

Think of it as items with rankings.

Basic Commands

ZADD scores 100 "alice"
ZADD scores 85 "bob" 92 "charlie" 78 "diana"

# Get all members (low to high score)
ZRANGE scores 0 -1
# ["diana", "bob", "charlie", "alice"]

# Get with scores
ZRANGE scores 0 -1 WITHSCORES
# ["diana", 78, "bob", 85, "charlie", 92, "alice", 100]

# Reverse (high to low)
ZREVRANGE scores 0 -1
# ["alice", "charlie", "bob", "diana"]

# Get score of a member
ZSCORE scores "alice"           # 100

# Get rank (position from bottom, 0-indexed)
ZRANK scores "alice"            # 3

# Get rank from top
ZREVRANK scores "alice"         # 0 (alice is #1!)

# Total members
ZCARD scores                    # 4

Game Leaderboard

ZADD leaderboard 500 "player:1" 650 "player:2" 580 "player:3"

# Top 10 players
ZREVRANGE leaderboard 0 9 WITHSCORES
# player:2: 650
# player:3: 580
# player:1: 500

# My rank (I'm player:1)
ZREVRANK leaderboard "player:1"  # 2 (I'm #2!)

# How many players beat me?
ZCOUNT leaderboard 501 999999    # 1 player (player:2)

# Update my score
ZADD leaderboard 620 "player:1"  # Now I'm second

# Get around me (players ranked 2-5)
ZREVRANGE leaderboard 1 4

Node.js (ioredis):

import Redis from 'ioredis';
const redis = new Redis();

// Add or update player score
async function updateScore(playerId, score) {
  await redis.zadd('leaderboard', score, `player:${playerId}`);
}

// Add to score (after winning a game)
async function addPoints(playerId, points) {
  const newScore = await redis.zincrby('leaderboard', points, `player:${playerId}`);
  return parseFloat(newScore);
}

// Get top players
async function getTopPlayers(count = 10) {
  const results = await redis.zrevrange('leaderboard', 0, count - 1, 'WITHSCORES');
  
  const players = [];
  for (let i = 0; i < results.length; i += 2) {
    players.push({
      playerId: results[i].replace('player:', ''),
      score: parseFloat(results[i + 1]),
      rank: i / 2 + 1
    });
  }
  return players;
}

// Get player's rank and score
async function getPlayerRank(playerId) {
  const [rank, score] = await Promise.all([
    redis.zrevrank('leaderboard', `player:${playerId}`),
    redis.zscore('leaderboard', `player:${playerId}`)
  ]);
  
  return {
    rank: rank !== null ? rank + 1 : null,
    score: score ? parseFloat(score) : null
  };
}

// Get players around a specific player
async function getPlayersAround(playerId, range = 5) {
  const rank = await redis.zrevrank('leaderboard', `player:${playerId}`);
  if (rank === null) return [];
  
  const start = Math.max(0, rank - range);
  const end = rank + range;
  
  return await redis.zrevrange('leaderboard', start, end, 'WITHSCORES');
}

Python (redis-py):

import redis

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

def update_score(player_id: str, score: float) -> dict:
    """Set a player's score on the leaderboard."""
    r.zadd('leaderboard', {f'player:{player_id}': score})
    return {'status': 'updated'}

def add_points(player_id: str, points: float) -> dict:
    """Add points to a player's current score."""
    new_score = r.zincrby('leaderboard', points, f'player:{player_id}')
    return {'new_score': float(new_score)}

def get_top_players(count: int = 10) -> list:
    """Get the top players from the leaderboard."""
    results = r.zrevrange('leaderboard', 0, count - 1, withscores=True)
    
    return [
        {'player_id': player.replace('player:', ''), 'score': score, 'rank': i + 1}
        for i, (player, score) in enumerate(results)
    ]

def get_player_rank(player_id: str) -> dict | None:
    """Get a player's rank and score."""
    rank = r.zrevrank('leaderboard', f'player:{player_id}')
    score = r.zscore('leaderboard', f'player:{player_id}')
    
    if rank is None:
        return None  # Player not found
    
    return {'rank': rank + 1, 'score': float(score)}

def get_players_around(player_id: str, range_size: int = 5) -> list | None:
    """Get players ranked around a specific player."""
    rank = r.zrevrank('leaderboard', f'player:{player_id}')
    if rank is None:
        return None  # Player not found
    
    start = max(0, rank - range_size)
    end = rank + range_size
    
    return r.zrevrange('leaderboard', start, end, withscores=True)

# Usage
update_score("player1", 500)
add_points("player1", 50)
print(get_top_players(10))
print(get_player_rank("player1"))

Latest Posts (Time-series)

Use Unix timestamp as the score to order by time:

# Post articles with timestamps as scores
now = 1707300000
ZADD blog:posts now-200 "post:101"
ZADD blog:posts now-100 "post:102"
ZADD blog:posts now "post:103"

# Get newest first
ZREVRANGE blog:posts 0 4      # Latest 5 posts

# Get today's posts (between timestamps)
today_start = 1707300000
today_end = 1707386400
ZRANGEBYSCORE blog:posts today_start today_end

Top Articles by Views

# Score = number of views
ZADD articles:by:views 150 "article:1" 420 "article:2" 89 "article:3"

# Top 10 most viewed
ZREVRANGE articles:by:views 0 9

# Articles with 100+ views
ZRANGEBYSCORE articles:by:views 100 +inf

# Remove low-view articles
ZREMRANGEBYSCORE articles:by:views 0 10  # Delete those with <10 views

Top Users by Reputation

ZADD users:reputation 100 "user:1" 250 "user:2" 180 "user:3"

# Top reputation holders
ZREVRANGE users:reputation 0 9 WITHSCORES

# User rank
rank = ZREVRANK users:reputation "user:2"  # 0 (top user)

# Count users in reputation range
ZCOUNT users:reputation 200 500  # How many between 200-500 rep?

Rate Limiting (Sliding Window)

Track requests and enforce limits:

# Add each request with current timestamp as score
ZADD requests:user:1 1707300000 "req1"
ZADD requests:user:1 1707300001 "req2"
ZADD requests:user:1 1707300002 "req3"

# Remove requests older than 1 hour
one_hour_ago = current_time() - 3600
ZREMRANGEBYSCORE requests:user:1 0 one_hour_ago

# Check if over limit
current_requests = ZCARD requests:user:1
if current_requests > 100:
  REJECT("Rate limit exceeded")

Node.js (ioredis):

// Sliding window rate limiter
async function checkRateLimit(userId, limit = 100, windowSeconds = 3600) {
  const key = `ratelimit:${userId}`;
  const now = Date.now();
  const windowStart = now - (windowSeconds * 1000);
  const requestId = `${now}:${Math.random()}`;
  
  // Start a transaction
  const pipeline = redis.pipeline();
  
  // Remove old requests outside the window
  pipeline.zremrangebyscore(key, 0, windowStart);
  
  // Count current requests
  pipeline.zcard(key);
  
  // Add new request
  pipeline.zadd(key, now, requestId);
  
  // Set expiry on the key
  pipeline.expire(key, windowSeconds);
  
  const results = await pipeline.exec();
  const currentCount = results[1][1];
  
  if (currentCount >= limit) {
    // Remove the request we just added since it's over limit
    await redis.zrem(key, requestId);
    return { allowed: false, remaining: 0, resetIn: windowSeconds };
  }
  
  return {
    allowed: true,
    remaining: limit - currentCount - 1,
    resetIn: windowSeconds
  };
}

// Express middleware
function rateLimitMiddleware(limit = 100, windowSeconds = 3600) {
  return async (req, res, next) => {
    const userId = req.user?.id || req.ip;
    const result = await checkRateLimit(userId, limit, windowSeconds);
    
    res.set('X-RateLimit-Limit', limit);
    res.set('X-RateLimit-Remaining', result.remaining);
    
    if (!result.allowed) {
      return res.status(429).json({ error: 'Rate limit exceeded' });
    }
    
    next();
  };
}

Python (redis-py):

import time
import random
import redis

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

def check_rate_limit(user_id: str, limit: int = 100, window_seconds: int = 3600) -> dict:
    """Check if user is within rate limit using sliding window."""
    key = f"ratelimit:{user_id}"
    now = int(time.time() * 1000)
    window_start = now - (window_seconds * 1000)
    request_id = f"{now}:{random.random()}"
    
    # Pipeline for atomic operations
    pipe = r.pipeline()
    
    # Remove old requests
    pipe.zremrangebyscore(key, 0, window_start)
    
    # Count current requests
    pipe.zcard(key)
    
    # Add new request
    pipe.zadd(key, {request_id: now})
    
    # Set expiry
    pipe.expire(key, window_seconds)
    
    results = pipe.execute()
    current_count = results[1]
    
    if current_count >= limit:
        r.zrem(key, request_id)
        return {'allowed': False, 'remaining': 0}
    
    return {'allowed': True, 'remaining': limit - current_count - 1}

# Usage
result = check_rate_limit("user:123", limit=100, window_seconds=3600)
if result['allowed']:
    print(f"Request allowed. Remaining: {result['remaining']}")
else:
    print("Rate limit exceeded!")

Priority Queue

Use scores as priority (higher = more urgent):

# Add tasks with priority scores
ZADD tasks:queue 1 "low_priority_task"
ZADD tasks:queue 5 "medium_task"
ZADD tasks:queue 10 "urgent_task"

# Process highest priority first
ZPOPMAX tasks:queue      # Remove and return highest: "urgent_task"
ZPOPMAX tasks:queue      # Next: "medium_task"

Node.js (ioredis):

// Add task with priority
async function addTask(taskId, priority, data) {
  const task = JSON.stringify({ id: taskId, data, createdAt: Date.now() });
  await redis.zadd('tasks:queue', priority, task);
}

// Get highest priority task (blocking)
async function getNextTask() {
  // BZPOPMAX blocks until a task is available
  const result = await redis.bzpopmax('tasks:queue', 0);
  
  if (result) {
    const [, taskJson] = result;
    return JSON.parse(taskJson);
  }
  return null;
}

// Process tasks by priority
async function priorityWorker() {
  while (true) {
    const result = await redis.zpopmax('tasks:queue');
    
    if (result && result.length > 0) {
      const [taskJson, priority] = result;
      const task = JSON.parse(taskJson);
      
      console.log(`Processing priority ${priority} task:`, task.id);
      await processTask(task);
    } else {
      await new Promise(r => setTimeout(r, 100));
    }
  }
}

// Usage
await addTask('send-welcome-email', 5, { userId: 1 });
await addTask('process-payment', 10, { orderId: 123 }); // Higher priority
await addTask('cleanup-logs', 1, {}); // Low priority

Python (redis-py):

import json
import time
import redis

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

def add_task(task_id: str, priority: int, data: dict) -> dict:
    """Add a task to the priority queue."""
    task = json.dumps({'id': task_id, 'data': data, 'created_at': time.time()})
    r.zadd('tasks:queue', {task: priority})
    return {'status': 'queued', 'priority': priority}

def get_next_task() -> dict | None:
    """Get the highest priority task from the queue."""
    result = r.zpopmax('tasks:queue')
    
    if result:
        task_json, priority = result[0]
        return {**json.loads(task_json), 'priority': priority}
    return None

def priority_worker():
    """Worker that processes tasks by priority."""
    while True:
        result = r.zpopmax('tasks:queue')
        
        if result:
            task_json, priority = result[0]
            task = json.loads(task_json)
            
            print(f"Processing priority {priority} task: {task['id']}")
            process_task(task)
        else:
            time.sleep(0.1)

# Usage
add_task("payment:123", priority=10, data={'order_id': 123})  # High priority
add_task("email:123", priority=5, data={'order_id': 123})     # Medium priority
add_task("cleanup", priority=1, data={})                      # Low priority

task = get_next_task()  # Returns highest priority task first
print(task)

Track trending items by count:

# Each mention increases score
ZINCRBY trending:today 1 "redis"
ZINCRBY trending:today 1 "redis"
ZINCRBY trending:today 1 "nodejs"

# What's trending (top 10)?
ZREVRANGE trending:today 0 9 WITHSCORES
# redis: 2
# nodejs: 1

Node.js (ioredis):

// Track a topic mention
async function trackTopic(topic) {
  const today = new Date().toISOString().split('T')[0];
  const key = `trending:${today}`;
  
  await redis.zincrby(key, 1, topic.toLowerCase());
  await redis.expire(key, 86400 * 2); // Keep for 2 days
}

// Get trending topics
async function getTrending(count = 10) {
  const today = new Date().toISOString().split('T')[0];
  const results = await redis.zrevrange(`trending:${today}`, 0, count - 1, 'WITHSCORES');
  
  const topics = [];
  for (let i = 0; i < results.length; i += 2) {
    topics.push({
      topic: results[i],
      mentions: parseInt(results[i + 1]),
      rank: i / 2 + 1
    });
  }
  return topics;
}

// Track hashtags from a post
async function trackHashtags(postContent) {
  const hashtags = postContent.match(/#\w+/g) || [];
  
  for (const tag of hashtags) {
    await trackTopic(tag.slice(1)); // Remove # prefix
  }
}

Python (redis-py):

from datetime import date
import re
import redis

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

def track_topic(topic: str) -> dict:
    """Track a topic mention for trending."""
    today = date.today().isoformat()
    key = f"trending:{today}"
    
    r.zincrby(key, 1, topic.lower())
    r.expire(key, 86400 * 2)  # 2 days
    
    return {'status': 'tracked'}

def get_trending(count: int = 10) -> list:
    """Get today's trending topics."""
    today = date.today().isoformat()
    results = r.zrevrange(f"trending:{today}", 0, count - 1, withscores=True)
    
    return [
        {'topic': topic, 'mentions': int(mentions), 'rank': i + 1}
        for i, (topic, mentions) in enumerate(results)
    ]

def track_hashtags_from_post(content: str) -> dict:
    """Extract and track hashtags from post content."""
    hashtags = re.findall(r'#(\w+)', content)
    today = date.today().isoformat()
    key = f"trending:{today}"
    
    for tag in hashtags:
        r.zincrby(key, 1, tag.lower())
    
    r.expire(key, 86400 * 2)
    return {'status': 'posted', 'hashtags_tracked': len(hashtags)}

# Usage
track_topic("redis")
track_topic("python")
track_hashtags_from_post("Learning #redis and #python today!")
print(get_trending(10))

Distance-based Results

Score = distance, get nearest items first:

# Store restaurants with distance as score (in km)
ZADD restaurants:nearby 0.5 "pizza:1" 1.2 "pizza:2" 2.3 "pizza:3"

# Find closest
ZRANGE restaurants:nearby 0 4  # Restaurants within range

# Restaurants within 2km
ZRANGEBYSCORE restaurants:nearby 0 2

Range Queries

Get items between scores:

ZADD prices 9.99 "product:1" 19.99 "product:2" 29.99 "product:3"

# Products between $15 and $30
ZRANGEBYSCORE prices 15 30
# ["product:2", "product:3"]

# Products under $20
ZRANGEBYSCORE prices 0 20
# ["product:1", "product:2"]

# Most expensive first
ZREVRANGEBYSCORE prices 100 0

Removing Items by Rank

ZADD items 1 "a" 2 "b" 3 "c" 4 "d" 5 "e"

# Remove top 2 items (highest scores)
ZREMRANGEBYRANK items -2 -1

# Now: a, b, c remain

Common Patterns

User Scores and Badges

# Track user scores
ZADD user:scores 100 "user:1" 250 "user:2"

# When user gains points
ZINCRBY user:scores 50 "user:1"

# Check if user reached badge threshold (500 points)
user_score = ZSCORE user:scores "user:1"
if user_score >= 500:
  award_badge("gold_member", "user:1")

Job Priority

# Higher priority = higher score
ZADD jobs:queue 5 "send_email" 10 "process_payment" 3 "log_activity"

# Process high-priority jobs first
while jobs_to_process:
  job = ZPOPMAX jobs:queue  # Get highest priority
  execute(job)

Autocomplete with Popularity

# Store words with popularity as score
ZADD autocomplete:words 100 "redis" 200 "react" 150 "router"

# Get popular completions for "re"
ZREVRANGE autocomplete:words 0 9  # Top words
# But filter client-side for "re" prefix