Redis is one of those tools that shows up everywhere once you start looking — caching API responses, managing session tokens, powering real-time leaderboards, and brokering messages between services. It’s an in-memory data store that’s fast by design (sub-millisecond reads) and versatile enough to replace several dedicated systems at once. In this guide we’ll cover the three most common use cases: caching with TTL, pub/sub messaging, and a few data structure tricks worth knowing.

Getting Redis Running

The quickest way to get started locally is with Docker:

$ docker run -d -p 6379:6379 --name redis redis:7-alpine
$ docker exec -it redis redis-cli ping
PONG

Or on macOS with Homebrew:

$ brew install redis
$ brew services start redis
$ redis-cli ping
PONG

For Python, install the official client:

$ pip install redis

Connecting in Python

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.ping()  # True

decode_responses=True makes Redis return strings instead of bytes — almost always what you want.

Caching with TTL

The most common Redis pattern: store the result of an expensive operation and expire it automatically after N seconds.

import json
import redis

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

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # Simulate a slow DB query
    user = {"id": user_id, "name": "Alice", "email": "alice@example.com"}

    r.setex(cache_key, 300, json.dumps(user))  # expire in 5 minutes
    return user

setex(key, seconds, value) is shorthand for SET key value EX seconds. You can also set TTL separately:

$ redis-cli SET user:42 '{"name":"Alice"}'
OK
$ redis-cli EXPIRE user:42 300
(integer) 1
$ redis-cli TTL user:42
(integer) 298

Cache invalidation

When the underlying data changes, delete the key explicitly:

def update_user(user_id: int, data: dict):
    # ... update in DB ...
    r.delete(f"user:{user_id}")

Checking if a key exists

if r.exists("user:42"):
    print("cache hit")

Common Data Structures

Redis isn’t just a key-value store — it ships with several data structures that let you avoid writing logic that Redis can handle natively.

Hashes (object-like storage)

Instead of serializing to JSON, store fields individually:

$ redis-cli HSET user:42 name Alice email alice@example.com role admin
(integer) 3
$ redis-cli HGET user:42 name
"Alice"
$ redis-cli HGETALL user:42
1) "name"
2) "Alice"
3) "email"
4) "alice@example.com"
5) "role"
6) "admin"

In Python:

r.hset("user:42", mapping={"name": "Alice", "email": "alice@example.com"})
r.hget("user:42", "name")     # "Alice"
r.hgetall("user:42")          # {"name": "Alice", "email": "alice@example.com"}

Sorted Sets (leaderboards, rate limiting)

# Add scores
r.zadd("leaderboard", {"alice": 1500, "bob": 1200, "carol": 1750})

# Top 3
r.zrevrange("leaderboard", 0, 2, withscores=True)
# [('carol', 1750.0), ('alice', 1500.0), ('bob', 1200.0)]

# Increment score
r.zincrby("leaderboard", 50, "bob")

Lists (queues, recent activity)

r.lpush("recent_logins", "alice", "bob")  # push to head
r.lrange("recent_logins", 0, 9)           # last 10 logins
r.ltrim("recent_logins", 0, 99)           # keep only 100 entries

Pub/Sub Messaging

Redis pub/sub lets processes communicate asynchronously without a dedicated message broker. One process publishes to a channel; any number of subscribers receive the message.

Publisher

import redis
import json
import time

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

for i in range(5):
    message = json.dumps({"event": "order_placed", "order_id": i})
    r.publish("orders", message)
    print(f"Published: {message}")
    time.sleep(1)

Subscriber

import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
pubsub = r.pubsub()
pubsub.subscribe("orders")

print("Waiting for messages...")
for message in pubsub.listen():
    if message["type"] == "message":
        data = json.loads(message["data"])
        print(f"Received order: {data['order_id']}")

Run the subscriber in one terminal and the publisher in another. Messages arrive in real time.

Pattern subscriptions

Subscribe to multiple channels with a glob pattern:

pubsub.psubscribe("orders.*")  # matches orders.new, orders.cancelled, etc.

Limitations to know

Pub/sub in Redis is fire-and-forget — messages sent while a subscriber is offline are lost. If you need durability or replay, use Redis Streams (XADD/XREAD) instead, which behave more like Kafka.

Rate Limiting with Redis

A clean sliding-window rate limiter using a sorted set:

import time
import redis

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

def is_rate_limited(user_id: str, limit: int = 10, window: int = 60) -> bool:
    key = f"rate:{user_id}"
    now = time.time()
    cutoff = now - window

    pipe = r.pipeline()
    pipe.zremrangebyscore(key, 0, cutoff)     # remove old entries
    pipe.zadd(key, {str(now): now})           # add current request
    pipe.zcard(key)                           # count requests in window
    pipe.expire(key, window)
    results = pipe.execute()

    request_count = results[2]
    return request_count > limit

The pipeline batches all four commands into a single round trip, avoiding race conditions for most use cases.

Atomic Operations

Redis executes each command atomically. For multi-step logic use MULTI/EXEC (transactions) or Lua scripts:

$ redis-cli
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 2

In Python: r.execute_command() or use a Pipeline with transaction=True.

Persistence Options

By default Redis is in-memory only — a restart loses all data. You can enable persistence:

  • RDB (snapshots): periodic point-in-time dumps to disk. Fast restarts, risk of data loss between snapshots.
  • AOF (append-only file): logs every write command. Slower but near-zero data loss.

For a pure cache where a cold start is acceptable, persistence is optional. For session storage or queues, enable at least AOF.

# In redis.conf
appendonly yes
appendfsync everysec

Conclusion

Redis shines as a cache, a lightweight message bus, and a home for data structures that databases handle poorly — sorted sets, rate counters, session tokens. The TTL mechanism means expiry is automatic and you never have to write a cleanup job. Pub/sub is useful for decoupling services, though Redis Streams are worth knowing for any use case that requires durability. Once you have it in your stack, you’ll reach for it constantly.