Module 1: Redis Fundamentals

Lists, Sets, and Sorted Sets

Overview

You've just mastered strings and hashes — the two most-used data types in caching. This capsule covers the three that are left: lists (queues and stacks for event logs and job queues), sets (unique collections for tags and membership tracking), and sorted sets (rankings ordered by score, the foundation of professional rate limiting and leaderboards).

Of the three, sorted sets is the most important one for this guide. It's where Redis stops being a simple key-value store and becomes an algorithms tool. Sliding window rate limiting (module 3) is built on ZADD + ZRANGEBYSCORE + ZREMRANGEBYSCORE. If those three commands are clear to you here, module 3 will flow naturally. If you rush past them, you'll suffer when you get there.

The previous capsules were comfortable. This one is denser — three data types with different use cases. But it's also where Redis starts to feel like a serious tool, not just a cache. Gaming leaderboards, Celery task queues, tag systems, and professional rate limiting algorithms all live here.


Lists: ordered queues and stacks

A list in Redis is an ordered sequence of strings. It works like a double-ended linked list: you can push and pop elements from both ends efficiently. That makes it ideal for FIFO queues (first-in-first-out), LIFO stacks (last-in-first-out), event logs, and job queues.

LPUSH and RPUSH — adding at the beginning or at the end

127.0.0.1:6379> RPUSH mylist "a"
(integer) 1

127.0.0.1:6379> RPUSH mylist "b"
(integer) 2

127.0.0.1:6379> RPUSH mylist "c"
(integer) 3

127.0.0.1:6379> LRANGE mylist 0 -1
1) "a"
2) "b"
3) "c"

RPUSH (Right Push) adds at the end. LPUSH (Left Push) adds at the beginning:

127.0.0.1:6379> LPUSH mylist "x"
(integer) 4

127.0.0.1:6379> LRANGE mylist 0 -1
1) "x"
2) "a"
3) "b"
4) "c"

Both return the total number of elements in the list after the operation.

Multiple elements in a single operation

127.0.0.1:6379> DEL mylist
(integer) 1

127.0.0.1:6379> RPUSH mylist "one" "two" "three" "four"
(integer) 4

127.0.0.1:6379> LRANGE mylist 0 -1
1) "one"
2) "two"
3) "three"
4) "four"

Far more efficient than 4 separate RPUSHes (1 round-trip instead of 4).

LPOP and RPOP — removing from the beginning or the end

127.0.0.1:6379> RPUSH queue "task-1" "task-2" "task-3"
(integer) 3

127.0.0.1:6379> LPOP queue
"task-1"

127.0.0.1:6379> LPOP queue
"task-2"

127.0.0.1:6379> LRANGE queue 0 -1
1) "task-3"

LPOP removes from the beginning (the head). RPOP removes from the end (the tail).

FIFO vs LIFO

By combining push and pop, you can implement both patterns:

FIFO (a queue — first in, first out):

RPUSH queue "task-1"  # enters from the right
RPUSH queue "task-2"
LPOP queue            # leaves from the left → "task-1" (the first one that entered)

LIFO (a stack — last in, first out):

LPUSH stack "task-1"  # enters from the left
LPUSH stack "task-2"
LPOP stack            # leaves from the left → "task-2" (the last one that entered)

The simple rule: FIFO uses RPUSH + LPOP. LIFO uses LPUSH + LPOP (or RPUSH + RPOP).

LRANGE — reading without removing

127.0.0.1:6379> RPUSH events "login" "click_home" "click_product" "add_to_cart" "checkout"
(integer) 5

# Read them all
127.0.0.1:6379> LRANGE events 0 -1
1) "login"
2) "click_home"
3) "click_product"
4) "add_to_cart"
5) "checkout"

# Read the first 3
127.0.0.1:6379> LRANGE events 0 2
1) "login"
2) "click_home"
3) "click_product"

# Read the last 2 (a negative index counts from the end)
127.0.0.1:6379> LRANGE events -2 -1
1) "add_to_cart"
2) "checkout"

The conventions:

  • 0 = the first element
  • -1 = the last element
  • LRANGE list 0 -1 = every element
  • Indexes are inclusive at both ends (unlike Python slices)

LLEN — how many elements

127.0.0.1:6379> LLEN events
(integer) 5

Ultra fast (an internal counter, it doesn't scan).

LINDEX — reading by position

127.0.0.1:6379> LINDEX events 0
"login"

127.0.0.1:6379> LINDEX events 2
"click_product"

127.0.0.1:6379> LINDEX events -1
"checkout"

LREM — removing specific values

127.0.0.1:6379> RPUSH tasks "task-1" "task-2" "task-1" "task-3" "task-1"
(integer) 5

# Remove every "task-1"
127.0.0.1:6379> LREM tasks 0 "task-1"
(integer) 3

127.0.0.1:6379> LRANGE tasks 0 -1
1) "task-2"
2) "task-3"

Syntax: LREM key count value

  • count > 0: removes N occurrences starting from the beginning
  • count < 0: removes N occurrences starting from the end
  • count = 0: removes ALL occurrences

LTRIM — keeping only a range

Useful for keeping the last N elements of a log (a capped log):

127.0.0.1:6379> RPUSH log "ev-1" "ev-2" "ev-3" "ev-4" "ev-5" "ev-6" "ev-7"
(integer) 7

# Keep only the last 5 elements
127.0.0.1:6379> LTRIM log -5 -1
OK

127.0.0.1:6379> LRANGE log 0 -1
1) "ev-3"
2) "ev-4"
3) "ev-5"
4) "ev-6"
5) "ev-7"

A real use case: a user's activity feed. Keeping only the last 50 events:

def log_activity(user_id, event):
    r.rpush(f"activity:user:{user_id}", event)
    r.ltrim(f"activity:user:{user_id}", -50, -1)  # cap it at 50 events

BLPOP and BRPOP — blocking pop (the consumer pattern)

LPOP returns (nil) if the list is empty. If you want a worker that waits until a message arrives, use BLPOP (Blocking LPOP):

127.0.0.1:6379> BLPOP work_queue 30

The worker waits for up to 30 seconds. If a message arrives, it returns it immediately. If 30 seconds go by with no messages, it returns (nil).

# Terminal 1 (the worker waiting)
127.0.0.1:6379> BLPOP work_queue 30
(waiting...)

# Terminal 2 (the producer)
127.0.0.1:6379> RPUSH work_queue "process-order-42"
(integer) 1

# Terminal 1 (the worker received it):
1) "work_queue"
2) "process-order-42"

This is the classic producer-consumer pattern. It's how Celery workers with a Redis broker work, along with RQ (Redis Queue) and many other task queue libraries.

BLPOP key timeout:

  • timeout = 0: wait indefinitely
  • timeout > 0: wait N seconds

Real use cases for lists

An activity feed (the last 50 events):

r.rpush(f"activity:user:{user_id}", json.dumps(event))
r.ltrim(f"activity:user:{user_id}", -50, -1)

A simple job queue (producer-consumer):

# Producer
r.rpush("job_queue", json.dumps({"type": "send_email", "to": "..."}))

# Worker
while True:
    job = r.blpop("job_queue", timeout=30)
    if job:
        process_job(json.loads(job[1]))

An undo stack in an application:

r.lpush(f"undo:{user_id}", json.dumps(action))
# To undo:
last_action = r.lpop(f"undo:{user_id}")

Sets: unique collections

A set in Redis is an unordered collection of unique strings. If you try to add a value that already exists, nothing happens (no error, it just isn't duplicated).

Sets are perfect for cases where uniqueness matters: tags, categories, tracking "who did what," membership relationships.

SADD and SREM — adding and removing

127.0.0.1:6379> SADD tags "python"
(integer) 1   # It was added (it was new)

127.0.0.1:6379> SADD tags "redis"
(integer) 1

127.0.0.1:6379> SADD tags "python"
(integer) 0   # It was NOT added (it already existed)

127.0.0.1:6379> SADD tags "fastapi" "postgresql" "docker"
(integer) 3

127.0.0.1:6379> SREM tags "docker"
(integer) 1

SADD returns how many NEW elements were added (duplicates count as 0).

SMEMBERS — listing every element

127.0.0.1:6379> SMEMBERS tags
1) "python"
2) "redis"
3) "fastapi"
4) "postgresql"

⚠️ The order is NOT predictable — sets guarantee no ordering. If you need order, use lists or sorted sets.

SISMEMBER — checking membership

127.0.0.1:6379> SISMEMBER tags "python"
(integer) 1   # It IS there

127.0.0.1:6379> SISMEMBER tags "javascript"
(integer) 0   # It is NOT there

An O(1) operation — extremely fast no matter the size of the set. This is what makes sets unbeatable for "checking membership" compared to searching through a list.

SCARD — counting elements

127.0.0.1:6379> SCARD tags
(integer) 4

(SCARD = Set Cardinality — the set's cardinality)

SRANDMEMBER and SPOP — random elements

127.0.0.1:6379> SRANDMEMBER tags
"redis"

127.0.0.1:6379> SRANDMEMBER tags 2
1) "python"
2) "fastapi"

SRANDMEMBER reads random elements without removing them. SPOP removes them:

127.0.0.1:6379> SPOP tags
"postgresql"

127.0.0.1:6379> SCARD tags
(integer) 3

Use case: "pick a random contest winner" or "get a random recommendation."

Operations between sets: SINTER, SUNION, SDIFF

This is the magic of sets. Set-theory operations in O(N).

127.0.0.1:6379> SADD users:online:web "alice" "bob" "charlie"
(integer) 3

127.0.0.1:6379> SADD users:online:mobile "bob" "charlie" "diana"
(integer) 3

# Intersection: users online on BOTH platforms
127.0.0.1:6379> SINTER users:online:web users:online:mobile
1) "bob"
2) "charlie"

# Union: users online on EITHER platform
127.0.0.1:6379> SUNION users:online:web users:online:mobile
1) "alice"
2) "bob"
3) "charlie"
4) "diana"

# Difference: users only on web (not on mobile)
127.0.0.1:6379> SDIFF users:online:web users:online:mobile
1) "alice"

Variants that store the result:

127.0.0.1:6379> SINTERSTORE common_users users:online:web users:online:mobile
(integer) 2

127.0.0.1:6379> SMEMBERS common_users
1) "bob"
2) "charlie"

SINTERSTORE computes the intersection and saves it in a new key. Useful when you're going to use the result several times.

Real use cases for sets

A tag system:

# A product's tags
r.sadd(f"product:{product_id}:tags", "electronics", "laptop", "apple")

# Find products with a tag
products_with_tag = r.smembers(f"tag:laptop:products")

# Products with BOTH tags (intersection)
both_tags = r.sinter("tag:laptop:products", "tag:apple:products")

Tracking online users:

def mark_online(user_id):
    r.sadd("users:online", user_id)
    r.expire("users:online", 300)  # automatic cleanup

def mark_offline(user_id):
    r.srem("users:online", user_id)

def is_online(user_id):
    return bool(r.sismember("users:online", user_id))

def online_count():
    return r.scard("users:online")

A "likes" system with one like per user:

# A user can only like a post once
def like_post(post_id, user_id):
    added = r.sadd(f"likes:post:{post_id}", user_id)
    if added:
        r.incr(f"likes_count:post:{post_id}")
        return True
    return False  # they had already liked it

def unlike_post(post_id, user_id):
    removed = r.srem(f"likes:post:{post_id}", user_id)
    if removed:
        r.decr(f"likes_count:post:{post_id}")

Followers in common (LinkedIn-style):

common_followers = r.sinter(f"followers:user:{user_a}", f"followers:user:{user_b}")

Sorted Sets: rankings with a score

Here's Redis's most powerful data type. A sorted set is like a set (unique elements) but with a numeric score attached to each element. Redis keeps the elements ordered by score automatically.

Use cases:

  • Game leaderboards (score = the player's points)
  • Trending posts (score = popularity)
  • Priority queues (score = priority)
  • Sliding window rate limiting (score = a timestamp) ← the key to module 3
  • Time-series data (score = a timestamp)

ZADD — adding with a score

127.0.0.1:6379> ZADD leaderboard 1500 "alice"
(integer) 1

127.0.0.1:6379> ZADD leaderboard 2300 "bob"
(integer) 1

127.0.0.1:6379> ZADD leaderboard 1800 "charlie"
(integer) 1

127.0.0.1:6379> ZADD leaderboard 950 "diana"
(integer) 1

ZADD key score member adds an element with its score. If the element already exists, it updates the score:

127.0.0.1:6379> ZADD leaderboard 1700 "alice"   # updates the score
(integer) 0   # 0 because it didn't add a new one (it updated)

127.0.0.1:6379> ZADD leaderboard 2500 "alice"
(integer) 0

Multiple elements at once:

127.0.0.1:6379> ZADD trending 100 "post-1" 250 "post-2" 75 "post-3" 500 "post-4"
(integer) 4

ZRANGE — reading elements by position (ascending)

127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "diana"
2) "950"
3) "charlie"
4) "1800"
5) "bob"
6) "2300"
7) "alice"
8) "2500"

By default, ZRANGE returns them in ascending order (lowest → highest score). The WITHSCORES flag includes the scores in the output.

ZREVRANGE — reading in descending order (top scores first)

127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "alice"
2) "2500"
3) "bob"
4) "2300"
5) "charlie"
6) "1800"

The top 3 players. This is what you'd want to show on a leaderboard.

ZRANGEBYSCORE — reading by score range

This is the most important command in the capsule. It's the foundation of sliding window rate limiting.

# Players with a score between 1500 and 2300
127.0.0.1:6379> ZRANGEBYSCORE leaderboard 1500 2300 WITHSCORES
1) "charlie"
2) "1800"
3) "bob"
4) "2300"

Syntax: ZRANGEBYSCORE key min max [WITHSCORES]

Special cases:

  • -inf and +inf for infinite ranges
  • (score to exclude the boundary (with a parenthesis)
# Every player with a score less than or equal to 2000
127.0.0.1:6379> ZRANGEBYSCORE leaderboard -inf 2000
1) "diana"
2) "charlie"

# Every player with a score GREATER than 2000 (exclusive)
127.0.0.1:6379> ZRANGEBYSCORE leaderboard (2000 +inf
1) "bob"
2) "alice"

Sliding window rate limiting (a module 3 preview)

This is where sorted sets shine. You want to limit to 100 requests per minute. The key: use the timestamp as the score.

import time

def is_rate_limited(user_id: str, max_requests=100, window_seconds=60):
    key = f"rate_limit:{user_id}"
    now = time.time()
    window_start = now - window_seconds

    pipeline = r.pipeline()
    # Remove requests older than the window
    pipeline.zremrangebyscore(key, 0, window_start)
    # Count the requests in the current window
    pipeline.zcard(key)
    # Add the current request with the timestamp as the score
    pipeline.zadd(key, {str(now): now})
    # A safety TTL
    pipeline.expire(key, window_seconds)
    _, current_count, _, _ = pipeline.execute()

    return current_count >= max_requests

Visualizing it with redis-cli:

# We simulate 3 requests in the last few seconds
127.0.0.1:6379> ZADD rate_limit:user42 1714069200 "req1"
(integer) 1
127.0.0.1:6379> ZADD rate_limit:user42 1714069210 "req2"
(integer) 1
127.0.0.1:6379> ZADD rate_limit:user42 1714069220 "req3"
(integer) 1

# How many requests in the last 60 seconds?
# If "now" is 1714069230, the window starts at 1714069170
127.0.0.1:6379> ZRANGEBYSCORE rate_limit:user42 1714069170 1714069230
1) "req1"
2) "req2"
3) "req3"

# Clean up the old requests (more than 60 sec old)
# 60 seconds later, "now" is 1714069270, the window starts at 1714069210
127.0.0.1:6379> ZREMRANGEBYSCORE rate_limit:user42 0 1714069210
(integer) 1   # it removed req1 (it was from 1714069200)

127.0.0.1:6379> ZRANGEBYSCORE rate_limit:user42 -inf +inf
1) "req2"
2) "req3"

This is the central idea of module 3. If it's clear to you here, it'll be trivial there.

ZSCORE — getting an element's score

127.0.0.1:6379> ZSCORE leaderboard "alice"
"2500"

127.0.0.1:6379> ZSCORE leaderboard "does_not_exist"
(nil)

ZRANK and ZREVRANK — an element's position

# ZRANK: the ascending position (0-indexed)
127.0.0.1:6379> ZRANK leaderboard "alice"
(integer) 3   # fourth position from the bottom

# ZREVRANK: the descending position
127.0.0.1:6379> ZREVRANK leaderboard "alice"
(integer) 0   # first position from the top (the winner!)

127.0.0.1:6379> ZREVRANK leaderboard "diana"
(integer) 3   # last position

ZREVRANK is what you show the user: "you're in position #1" (not "you're in position #3 from the bottom").

ZCARD — the total number of elements

127.0.0.1:6379> ZCARD leaderboard
(integer) 4

ZCOUNT — how many elements are in a score range

# How many players have a score between 1500 and 2500?
127.0.0.1:6379> ZCOUNT leaderboard 1500 2500
(integer) 3

# How many players have a score >= 2000?
127.0.0.1:6379> ZCOUNT leaderboard 2000 +inf
(integer) 2

ZINCRBY — incrementing an element's score

127.0.0.1:6379> ZSCORE leaderboard "alice"
"2500"

127.0.0.1:6379> ZINCRBY leaderboard 100 "alice"
"2600"

127.0.0.1:6379> ZINCRBY leaderboard -300 "alice"
"2300"

Atomic. Useful for adding points on a leaderboard without race conditions:

# When a user earns points
r.zincrby("leaderboard", points_earned, user_id)

ZREMRANGEBYSCORE — removing by score range

# Remove every element with a score < 1000
127.0.0.1:6379> ZREMRANGEBYSCORE leaderboard -inf 1000
(integer) 1   # it removed "diana"

127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "charlie"
2) "1800"
3) "alice"
4) "2300"
5) "bob"
6) "2500"

This command is CRITICAL for sliding window rate limiting: it cleans out the requests older than the window, keeping the sorted set bounded.

Real use cases for sorted sets

A game leaderboard:

# When a player completes a level
r.zincrby("leaderboard:season1", score, user_id)

# The top 10 players
top_10 = r.zrevrange("leaderboard:season1", 0, 9, withscores=True)

# A specific player's position
my_rank = r.zrevrank("leaderboard:season1", user_id) + 1  # +1 because it's 0-indexed

Trending posts (with time-based decay):

# Score = likes + comments * 2 + shares * 3
def update_trending(post_id, action):
    weights = {"like": 1, "comment": 2, "share": 3}
    r.zincrby("trending:posts", weights[action], post_id)

# The top 20 trending posts
trending = r.zrevrange("trending:posts", 0, 19, withscores=True)

A priority queue for jobs:

# Jobs with priority as the score (lower = higher priority)
r.zadd("job_queue", {"job-123": 1, "job-456": 5, "job-789": 2})

# The worker takes the highest-priority job
job = r.zpopmin("job_queue", count=1)  # extracts the one with the lowest score

An event time-series:

# Store events with a timestamp
r.zadd(f"events:user:{user_id}", {event_json: time.time()})

# Events from the last 5 minutes
import time
five_min_ago = time.time() - 300
recent = r.zrangebyscore(f"events:user:{user_id}", five_min_ago, "+inf")

# Clean up old events (keep only the last hour)
hour_ago = time.time() - 3600
r.zremrangebyscore(f"events:user:{user_id}", 0, hour_ago)

Sliding window rate limiting (module 3 covers this in depth):

# Limit: 100 req/min per user
def check_rate_limit(user_id):
    key = f"rate:{user_id}"
    now = time.time()
    window_start = now - 60

    p = r.pipeline()
    p.zremrangebyscore(key, 0, window_start)  # cleanup
    p.zcard(key)                                # count the requests in the window
    p.zadd(key, {str(now): now})                # add the current request
    p.expire(key, 60)                            # safety TTL
    _, count, _, _ = p.execute()

    return count < 100  # True = allowed, False = rate limited

Summary table of the 5 data types

Data typeExample commandTypical useOrderedUnique
StringSET key "v"Simple caching, counters, locks, flags
HashHSET k field vObjects with fields, sessions, configurationsNoPer field
ListRPUSH k vQueues, logs, activity feeds, job queuesBy insertionNo
SetSADD k vTags, membership tracking, relationshipsNoYes
Sorted SetZADD k score vLeaderboards, rate limiting, time-series, priority queuesBy scoreYes

Quick decision:

  • "I have an opaque value" → string
  • "I have an object with editable fields" → hash
  • "I have a sequence ordered by insertion" → list
  • "I have an unordered collection of unique values" → set
  • "I have elements with a numeric score" → sorted set

Troubleshooting

Problem 1: ZADD doesn't update the score if it already exists

Cause: The element exists and you're using the NX flag (only if not exists).

127.0.0.1:6379> ZADD leaderboard NX 1500 "alice"
(integer) 1   # first time, it adds it

127.0.0.1:6379> ZADD leaderboard NX 2000 "alice"
(integer) 0   # it does NOT update (it exists + the NX flag)

127.0.0.1:6379> ZSCORE leaderboard "alice"
"1500"   # it's still the original

Solution: Use XX (only if exists) or the default (no flags):

# Default: adds OR updates
127.0.0.1:6379> ZADD leaderboard 2000 "alice"
(integer) 0   # 0 because it updated (it didn't add a new one)

# XX: only if it exists
127.0.0.1:6379> ZADD leaderboard XX 2500 "alice"
(integer) 0

Problem 2: LRANGE doesn't return what you expected

Cause: Confusion with the indexes. Remember that LRANGE is inclusive at both ends:

127.0.0.1:6379> RPUSH list "a" "b" "c"
(integer) 3

# In Python: list[0:2] returns ["a", "b"] (b included, c excluded)
# In Redis: LRANGE 0 2 returns ["a", "b", "c"] (all of them included)
127.0.0.1:6379> LRANGE list 0 2
1) "a"
2) "b"
3) "c"

Solution: Adjust the range. If you want "the first 2," use LRANGE 0 1 (not 0 2).

Problem 3: SADD on a list gives a WRONGTYPE error

Cause: The key exists as another type.

127.0.0.1:6379> RPUSH mykey "a"
(integer) 1

127.0.0.1:6379> SADD mykey "b"
(error) WRONGTYPE Operation against a key holding the wrong kind of value

Solution: Check the type and delete it if you need to convert:

127.0.0.1:6379> TYPE mykey
list

127.0.0.1:6379> DEL mykey
(integer) 1

127.0.0.1:6379> SADD mykey "a" "b"
(integer) 2

Problem 4: BLPOP doesn't block (it returns immediately)

Cause: There's data in the list. BLPOP only blocks if the list is empty.

127.0.0.1:6379> RPUSH queue "task-1"
(integer) 1

127.0.0.1:6379> BLPOP queue 30
1) "queue"
2) "task-1"   # immediate return, it didn't wait

Solution: This is correct behavior. BLPOP only waits when the list is empty. If you want to simulate blocking every time, use LPOP directly and call it again.

Problem 5: The sorted set grows without limit

Cause: You're adding with ZADD but never cleaning up with ZREMRANGEBYSCORE.

127.0.0.1:6379> ZCARD rate_limit:user42
(integer) 50000   # it grows and grows

Solution: Combine ZADD with ZREMRANGEBYSCORE or EXPIRE:

# Option 1: clean up by score (a sliding window)
r.zremrangebyscore("rate_limit:user42", 0, time.time() - 3600)

# Option 2: a TTL on the whole key
r.expire("rate_limit:user42", 3600)

# Option 3: cap it with ZREMRANGEBYRANK (keep only the last N)
r.zremrangebyrank("rate_limit:user42", 0, -1001)  # keep only the last 1000

Problem 6: ZRANGEBYSCORE is slow with large ranges

Cause: If the range includes millions of elements, transferring them all is slow.

Solution: Use LIMIT to paginate:

127.0.0.1:6379> ZRANGEBYSCORE leaderboard -inf +inf LIMIT 0 10
1) "diana"
2) "charlie"
3) "alice"
4) "bob"

LIMIT offset count works like it does in SQL — take 10 elements starting from the first one.


Exercises

Exercise 1: An activity feed with a LIST (Easy)

Create an activity feed for a user. Add 10 events with RPUSH. Keep only the last 5 with LTRIM. Verify that LRANGE returns only 5 events.

See solution
127.0.0.1:6379> DEL activity:user42
(integer) 0

127.0.0.1:6379> RPUSH activity:user42 "ev-1" "ev-2" "ev-3" "ev-4" "ev-5" "ev-6" "ev-7" "ev-8" "ev-9" "ev-10"
(integer) 10

127.0.0.1:6379> LLEN activity:user42
(integer) 10

127.0.0.1:6379> LTRIM activity:user42 -5 -1
OK

127.0.0.1:6379> LRANGE activity:user42 0 -1
1) "ev-6"
2) "ev-7"
3) "ev-8"
4) "ev-9"
5) "ev-10"

127.0.0.1:6379> LLEN activity:user42
(integer) 5

Explanation: LTRIM -5 -1 keeps the last 5 elements (indexes -5 through -1 are the last 5). It's the "capped log" pattern — the list never grows beyond N elements and you don't need cleanup jobs.

Exercise 2: A producer-consumer job queue (Medium)

Simulate a job queue. In one terminal, bring up a "worker" with BLPOP work_queue 30. In another terminal, run RPUSH work_queue "send-email-42". Verify the worker receives the message. Repeat with another message. Then let it time out (don't send anything for 30 sec).

See solution

Terminal 1 (the worker):

127.0.0.1:6379> BLPOP work_queue 30
(waiting...)

Terminal 2 (the producer):

127.0.0.1:6379> RPUSH work_queue "send-email-42"
(integer) 1

Terminal 1 (the worker receives it):

1) "work_queue"
2) "send-email-42"
(it returns; it takes less than a millisecond)

Terminal 1 (the worker waits for the next one):

127.0.0.1:6379> BLPOP work_queue 30
(waiting...)

Terminal 2 (the producer):

127.0.0.1:6379> RPUSH work_queue "send-email-43"
(integer) 1

Terminal 1 (it receives the second one):

1) "work_queue"
2) "send-email-43"

The third attempt (with no producer):

127.0.0.1:6379> BLPOP work_queue 30
(waiting 30 seconds...)
(nil)   # timeout, nothing arrived
(30.04s)

Explanation: BLPOP is the foundation of Celery, RQ, and other task queue libraries with a Redis broker. The producer pushes, the worker does a blocking pop. It's efficient: the worker doesn't poll (the client is notified by the server the moment a message arrives).

Exercise 3: Tags with sets and intersection (Medium)

Create sets of "products by tag":

  • tag:laptops:products with the products 100, 101, 102
  • tag:apple:products with 100, 103, 104
  • tag:gaming:products with 101, 105, 106

Find:

  1. Products that are laptops AND apple
  2. Products that are laptops OR gaming
  3. Products that are laptops but NOT apple
See solution
127.0.0.1:6379> SADD tag:laptops:products 100 101 102
(integer) 3

127.0.0.1:6379> SADD tag:apple:products 100 103 104
(integer) 3

127.0.0.1:6379> SADD tag:gaming:products 101 105 106
(integer) 3

# 1. Laptop AND apple (intersection)
127.0.0.1:6379> SINTER tag:laptops:products tag:apple:products
1) "100"

# 2. Laptop OR gaming (union)
127.0.0.1:6379> SUNION tag:laptops:products tag:gaming:products
1) "100"
2) "101"
3) "102"
4) "105"
5) "106"

# 3. Laptop but NOT apple (difference)
127.0.0.1:6379> SDIFF tag:laptops:products tag:apple:products
1) "101"
2) "102"

Explanation: Sets turn "tag" queries that would be heavy in SQL (JOIN + WHERE + GROUP BY) into O(N) set-theory operations. For "products with ALL these tags," it's a SINTER of N sets. For "products with ANY of these tags," it's a SUNION.

A real use case: faceted filters in e-commerce. "Show laptops that are Apple and gaming" = SINTER tag:laptops:products tag:apple:products tag:gaming:products.

Exercise 4: A leaderboard with a sorted set (Medium)

Create a game leaderboard with 5 players and their points:

  • Alice: 1500
  • Bob: 2300
  • Charlie: 1800
  • Diana: 950
  • Eve: 2100

Implement:

  1. The top 3 players
  2. Diana's position (with ZREVRANK)
  3. How many players have ≥ 1500 points
  4. Add 200 points to Charlie
See solution
127.0.0.1:6379> ZADD leaderboard 1500 alice 2300 bob 1800 charlie 950 diana 2100 eve
(integer) 5

# 1. Top 3
127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "bob"
2) "2300"
3) "eve"
4) "2100"
5) "charlie"
6) "1800"

# 2. Diana's position
127.0.0.1:6379> ZREVRANK leaderboard diana
(integer) 4   # fifth position (0-indexed)
# To show the user: "you're in place #5"

# 3. How many have >= 1500
127.0.0.1:6379> ZCOUNT leaderboard 1500 +inf
(integer) 4

# 4. Add 200 to Charlie
127.0.0.1:6379> ZINCRBY leaderboard 200 charlie
"2000"

# Check the new top 3
127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "bob"
2) "2300"
3) "eve"
4) "2100"
5) "charlie"
6) "2000"

Explanation: Charlie moved up to 3rd place after the extra 200 points. This is exactly how real game leaderboards work: every player action adds points with ZINCRBY (atomic, safe under concurrency), and the ranking stays up to date without a re-sort job.

Exercise 5: A rate limiter with a sliding window (Hard)

Implement a rate limiter by hand in redis-cli. Limit to 5 requests per minute. Simulate a user making 7 requests:

  1. Add 5 entries with recent timestamps (you can use EVAL "return redis.call('TIME')[1]" 0 to get the current timestamp, or fixed numbers like 1714069200, 1714069210, ...)
  2. Check the request count in the current window with ZCARD
  3. Try a 6th request → it should be blocked (count >= 5)
  4. Advance the "simulated" time by 70 seconds by cleaning up with ZREMRANGEBYSCORE
  5. Now the 6th request should go through
See solution
# We'll assume simulated timestamps
# Current time = 1714069260, window = 60 sec, so window_start = 1714069200

# 1. Add 5 requests inside the window
127.0.0.1:6379> ZADD rate:user42 1714069210 "req1"
(integer) 1
127.0.0.1:6379> ZADD rate:user42 1714069220 "req2"
(integer) 1
127.0.0.1:6379> ZADD rate:user42 1714069230 "req3"
(integer) 1
127.0.0.1:6379> ZADD rate:user42 1714069240 "req4"
(integer) 1
127.0.0.1:6379> ZADD rate:user42 1714069250 "req5"
(integer) 1

# 2. Clean up the old requests (before 1714069200) and count
127.0.0.1:6379> ZREMRANGEBYSCORE rate:user42 0 1714069200
(integer) 0

127.0.0.1:6379> ZCARD rate:user42
(integer) 5

# 3. Try a 6th request → blocked (5 >= 5)
# In code: if count >= 5, return 429 Too Many Requests

# 4. Advance 70 sec → "now" = 1714069330, window_start = 1714069270
# Clean up requests older than 1714069270
127.0.0.1:6379> ZREMRANGEBYSCORE rate:user42 0 1714069270
(integer) 5   # it removed the 5 old requests

127.0.0.1:6379> ZCARD rate:user42
(integer) 0

# 5. Now the new request goes through
127.0.0.1:6379> ZADD rate:user42 1714069335 "req6"
(integer) 1

127.0.0.1:6379> ZCARD rate:user42
(integer) 1   # under the limit, the request is allowed

Explanation: This IS the sliding window rate limiting algorithm. Its advantage over a fixed window: it doesn't have the "double window" edge case (where someone can make 100 requests at second 59 and another 100 at second 61). A sliding window always keeps a moving window of the last N seconds.

In module 3 you'll implement it in Python with FastAPI middleware. For now, what matters is that you understand the algorithm: add with a timestamp as the score, clean out the old entries, count the remaining ones.

An improvement with LIMIT for top-K:

# If your rate limit is very high (e.g., 10000 req/hr), use LIMIT so you don't transfer everything
127.0.0.1:6379> ZRANGEBYSCORE rate:user42 1714069270 1714069330 LIMIT 0 1
1) "req6"

Exercise 6: Online users with a set + TTL (Medium)

Implement tracking for "users online right now." Every user who opens the app should be added to the set. If 5 minutes go by with no interaction, they should drop out automatically. Hint: use a set + a renewable TTL.

See solution

An implementation with a timestamped set (the better approach):

import time

def mark_user_active(user_id):
    # Instead of a plain set, we use a sorted set with a timestamp
    r.zadd("users:active", {user_id: time.time()})

def get_online_users():
    # Online = active in the last 5 minutes
    five_min_ago = time.time() - 300
    return r.zrangebyscore("users:active", five_min_ago, "+inf")

def cleanup_inactive():
    # A periodic job: remove inactive users
    five_min_ago = time.time() - 300
    r.zremrangebyscore("users:active", 0, five_min_ago)
# Simulating it in redis-cli (with simulated timestamps)
# Current time = 1714069600, threshold = 1714069300 (5 min ago)

127.0.0.1:6379> ZADD users:active 1714069550 "alice" 1714069580 "bob" 1714069200 "charlie"
(integer) 3

# Who's online right now?
127.0.0.1:6379> ZRANGEBYSCORE users:active 1714069300 +inf
1) "alice"
2) "bob"
# charlie doesn't show up (his timestamp 1714069200 < 1714069300)

# Periodic cleanup
127.0.0.1:6379> ZREMRANGEBYSCORE users:active 0 1714069300
(integer) 1   # it removed charlie

A simpler alternative with one key per user + a TTL:

def mark_user_active(user_id):
    r.set(f"online:{user_id}", "1", ex=300)  # 5 min TTL

def is_online(user_id):
    return bool(r.get(f"online:{user_id}"))

def get_online_count():
    # This requires KEYS or SCAN — it doesn't scale well
    return len(r.keys("online:*"))

Comparison:

ApproachProsCons
Sorted set + timestampsA single key, perfect scaling, O(log N) cleanupYou need a periodic cleanup job
One key per user + TTLAutomatic TTL, simpleKEYS * doesn't scale, counting who's online is slow

Explanation: For production with thousands of online users, use the sorted set approach. The single key makes listing online users O(log N + K), not O(N) scanning every key.


Summary

In this capsule you learned:

Lists (ordered sequences):

  • RPUSH/LPUSH: adding at the end/the beginning
  • LPOP/RPOP: removing from the beginning/the end
  • LRANGE/LINDEX/LLEN: reading without removing
  • LTRIM: capped lists for activity feeds
  • BLPOP/BRPOP: blocking pop for workers (producer-consumer)
  • Use cases: job queues, activity feeds, undo stacks

Sets (unique collections):

  • SADD/SREM: adding/removing (duplicates ignored)
  • SISMEMBER: O(1) membership check
  • SCARD: counting elements
  • SINTER/SUNION/SDIFF: set-theory operations
  • Use cases: tags, tracking online users, unique "likes," followers in common

Sorted Sets (rankings with a score):

  • ZADD: adding with a numeric score
  • ZRANGE/ZREVRANGE: reading ordered by position (asc/desc)
  • ZRANGEBYSCORE: reading by score range ← the foundation of sliding window rate limiting
  • ZSCORE/ZRANK/ZREVRANK: getting the score and the position
  • ZINCRBY: incrementing the score atomically
  • ZREMRANGEBYSCORE: cleaning out elements by score ← crucial for keeping sorted sets bounded
  • Use cases: leaderboards, trending posts, priority queues, professional rate limiting, time-series

Key decisions:

  • Lists for sequences by insertion (FIFO/LIFO)
  • Sets for uniqueness without order
  • Sorted sets for order by score (rankings, time-series)

Critical commands for module 3:

  • ZADD key timestamp member to record requests
  • ZRANGEBYSCORE key window_start +inf or ZCARD to count the requests in the window
  • ZREMRANGEBYSCORE key 0 window_start to clean out old requests

If those three are clear to you, sliding window rate limiting will flow in module 3.


Additional resources

  1. Redis Lists Tutorial — Official docs with every list command
  2. Redis Sets Tutorial — Operations between sets in detail
  3. Redis Sorted Sets Tutorial — Sorted sets with the complete syntax
  4. ZRANGEBYSCORE command — Range syntax and LIMIT
  5. Sliding Window Rate Limiting — A deep explanation of the algorithm (a module 3 preview)
  6. Redis as a Queue — Queue patterns with lists, compared to real message brokers

What's next?

In Capsule 05 you leave redis-cli behind and get into Python with redis-py. You'll connect your first Python app to Redis, run all the operations you learned but now in real code, use pipelines for efficient batch operations, and build the Redis Explorer mini-project that exercises all 5 data types.

It's the module's closing capsule. After it, module 2 gets into caching patterns with the whole toolkit ready.

Keep Redis running and get your virtual environment ready:

cd ~/projects/redis-guide/module-01-fundamentals/redis-explorer
source .venv/bin/activate

Let's go.