| name | redis |
| description | Use when choosing a Redis data structure for a use case, implementing caching or rate limiting, building pub/sub or Streams-based real-time messaging, or writing atomic operations like distributed locks. |
Redis Patterns
Redis data structures, caching, pub/sub, and streams for Python async apps.
When to Activate
- Choosing the right Redis data structure for a use case
- Implementing caching (cache-aside, write-through, TTL eviction)
- Using pub/sub or Redis Streams for real-time messaging / SSE
- Building a job queue with SKIP LOCKED semantics
- Writing atomic operations (counters, rate limits, distributed locks)
- Debugging slow Redis commands or memory bloat
Connection (async redis-py)
import redis.asyncio as redis
client = await redis.from_url("redis://localhost:6379", decode_responses=True)
pool = redis.ConnectionPool.from_url(
"redis://localhost:6379",
decode_responses=True,
max_connections=20,
)
client = redis.Redis(connection_pool=pool)
await client.aclose()
decode_responses=True returns str instead of bytes — use it unless you store binary data.
Data Structure Decision Table
| Structure | Use For | Avoid When |
|---|
| String | Single values, JSON blobs, counters, distributed locks | Frequently updating one field of many |
| Hash | Objects with multiple fields; partial field reads/writes | >100 fields or deeply nested — use String+JSON instead |
| List | FIFO queues, activity feeds, bounded history | Random access by index — use Sorted Set |
| Set | Unique membership, tag intersections/unions, "online users" | Need ordering or score — use Sorted Set |
| Sorted Set | Leaderboards, priority queues, time-ordered events, rate limiting | Cardinality >10M — memory gets expensive |
| Stream | Durable pub/sub, consumer groups, event log | Simple fire-and-forget — use pub/sub |
| HyperLogLog | Approx unique count (±0.81% error, capped at 12 KB) | Exact count required |
| Bitmap | Per-user boolean flags, daily active user tracking | More than 512 MB of bits |
Data Structures
Strings — single values, counters, JSON blobs
await client.set("user:123:name", "Alice")
await client.get("user:123:name")
await client.set("session:abc", token, ex=3600)
await client.setex("session:abc", 3600, token)
acquired = await client.set("lock:job:42", "worker-1", nx=True, ex=30)
await client.incr("page:views")
await client.incrby("page:views", 5)
await client.decr("inventory:product:99")
await client.getex("session:abc", ex=3600)
import json
await client.set("user:123", json.dumps(user_dict))
user = json.loads(await client.get("user:123"))
Hashes — objects / partial updates
await client.hset("user:123", mapping={
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
})
user = await client.hgetall("user:123")
name = await client.hget("user:123", "name")
await client.hset("user:123", "role", "user")
exists = await client.hexists("user:123", "email")
await client.hdel("user:123", "temp_token")
fields = await client.hkeys("user:123")
values = await client.hvals("user:123")
Use hashes for objects with many fields where you update individual fields often. Cheaper than JSON string for partial reads.
Lists — queues, activity feeds
await client.rpush("queue:emails", "msg-1", "msg-2")
job = await client.lpop("queue:emails")
job = await client.blpop("queue:emails", timeout=30)
await client.rpush("stack", "item")
item = await client.rpop("stack")
items = await client.lrange("queue:emails", 0, -1)
recent = await client.lrange("activity:user:1", 0, 9)
await client.ltrim("activity:user:1", -100, -1)
length = await client.llen("queue:emails")
Sets — unique membership, tags
await client.sadd("online_users", "user-1", "user-2")
await client.srem("online_users", "user-2")
is_online = await client.sismember("online_users", "user-1")
members = await client.smembers("online_users")
count = await client.scard("online_users")
common = await client.sinter("user:1:friends", "user:2:friends")
all_ = await client.sunion("tag:python", "tag:async")
diff = await client.sdiff("all_users", "banned_users")
Sorted Sets — leaderboards, priority queues, rate limiting
await client.zadd("leaderboard", {"alice": 1500, "bob": 1200, "carol": 1800})
top3 = await client.zrevrange("leaderboard", 0, 2, withscores=True)
rank = await client.zrevrank("leaderboard", "alice")
await client.zincrby("leaderboard", 50, "bob")
members = await client.zrangebyscore("leaderboard", 1400, 2000)
await client.zrem("leaderboard", "bob")
TTL and Expiration
await client.expire("session:abc", 3600)
await client.expireat("session:abc", timestamp)
await client.pexpire("key", 500)
ttl = await client.ttl("session:abc")
pttl = await client.pttl("session:abc")
await client.persist("key")
Pub/Sub
async def publish_event(client, channel: str, data: dict):
await client.publish(channel, json.dumps(data))
async def subscribe_to_events(client, channel: str):
async with client.pubsub() as pubsub:
await pubsub.subscribe(channel)
async for message in pubsub.listen():
if message["type"] == "message":
data = json.loads(message["data"])
yield data
async with client.pubsub() as pubsub:
await pubsub.psubscribe("tasks:*")
async for message in pubsub.listen():
if message["type"] == "pmessage":
process(message["channel"], message["data"])
Limitation: pub/sub messages are fire-and-forget. Subscribers that miss a message while offline don't receive it. Use Streams for durable delivery.
Redis Streams (durable pub/sub)
Streams persist messages — consumers can read from any position, including past messages.
msg_id = await client.xadd(
"task:updates",
{"task_id": "t-123", "status": "running", "content": "Processing..."},
maxlen=10000,
)
messages = await client.xread({"task:updates": "0-0"}, count=100)
last_id = "0-0"
messages = await client.xread({"task:updates": last_id}, count=10, block=5000)
for stream, entries in messages:
for msg_id, fields in entries:
process(fields)
last_id = msg_id
await client.xgroup_create("task:updates", "workers", id="0", mkstream=True)
msgs = await client.xreadgroup("workers", "worker-1", {"task:updates": ">"}, count=1)
for stream, entries in msgs:
for msg_id, fields in entries:
process(fields)
await client.xack("task:updates", "workers", msg_id)
await client.xtrim("task:updates", maxlen=5000, approximate=True)
SSE streaming pattern (used in Agentex frontend):
await client.xadd(f"task:{task_id}:stream", {"delta": chunk})
async def stream_task(task_id: str):
last_id = "0-0"
while True:
messages = await client.xread({f"task:{task_id}:stream": last_id}, block=5000)
for _, entries in messages:
for msg_id, fields in entries:
yield f"data: {fields['delta']}\n\n"
last_id = msg_id
Caching Patterns
Cache-aside (lazy loading)
async def get_user(user_id: str) -> User:
key = f"user:{user_id}"
cached = await client.get(key)
if cached:
return User(**json.loads(cached))
user = await db.fetch_user(user_id)
await client.set(key, user.model_dump_json(), ex=300)
return user
async def invalidate_user(user_id: str):
await client.delete(f"user:{user_id}")
Write-through
async def update_user(user_id: str, data: dict) -> User:
user = await db.update_user(user_id, data)
await client.set(f"user:{user_id}", user.model_dump_json(), ex=300)
return user
Atomic Operations
Distributed lock
import uuid
async def with_lock(client, resource: str, ttl: int = 30):
lock_key = f"lock:{resource}"
lock_val = str(uuid.uuid4())
acquired = await client.set(lock_key, lock_val, nx=True, ex=ttl)
if not acquired:
raise RuntimeError(f"Could not acquire lock on {resource}")
try:
yield
finally:
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
await client.eval(script, 1, lock_key, lock_val)
Rate limiting (sliding window)
async def is_rate_limited(client, user_id: str, limit: int = 100, window: int = 60) -> bool:
key = f"rate:{user_id}:{int(time.time()) // window}"
count = await client.incr(key)
if count == 1:
await client.expire(key, window)
return count > limit
Pipeline (batch commands — reduce round trips)
async with client.pipeline(transaction=False) as pipe:
pipe.hset("user:1", mapping=data)
pipe.expire("user:1", 3600)
pipe.zadd("leaderboard", {"user-1": score})
results = await pipe.execute()
async with client.pipeline(transaction=True) as pipe:
await pipe.watch("inventory:42")
quantity = int(await pipe.get("inventory:42"))
if quantity < 1:
raise Exception("Out of stock")
pipe.multi()
pipe.decr("inventory:42")
await pipe.execute()
SCAN — Non-Blocking Key Iteration
Never use KEYS * in production. Use SCAN with a cursor instead:
async def scan_keys(client, pattern: str) -> list[str]:
keys = []
cursor = 0
while True:
cursor, batch = await client.scan(cursor, match=pattern, count=100)
keys.extend(batch)
if cursor == 0:
break
return keys
cursor = 0
while True:
cursor, fields = await client.hscan("user:123", cursor, count=50)
for field, value in fields.items():
process(field, value)
if cursor == 0:
break
cursor = 0
while True:
cursor, members = await client.zscan("leaderboard", cursor, count=100)
for member, score in members:
process(member, score)
if cursor == 0:
break
Eviction Policies
Set maxmemory and maxmemory-policy in redis.conf or via CONFIG SET:
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
| Policy | Evicts | Use When |
|---|
noeviction | Nothing — returns error on write | Data must never be lost (primary store) |
allkeys-lru | Least-recently-used key (any key) | General cache — you can't control which keys have TTL |
volatile-lru | LRU among keys with TTL | Mix of persistent + cache keys in one instance |
allkeys-lfu | Least-frequently-used key (any key) | Hotspot skew — some keys accessed far more |
volatile-ttl | Key with shortest remaining TTL | Prefer expiring the soonest-to-expire keys |
allkeys-random | Random key | Uniform access patterns, lowest overhead |
Production default for caches: allkeys-lru
Never use noeviction for a cache — the first write after memory is full raises an error.
info = await client.config_get("maxmemory-policy")
stats = await client.info("stats")
evicted = stats["evicted_keys"]
TypeScript Patterns (node-redis)
import { createClient } from "redis";
const client = createClient({
url: "redis://localhost:6379",
socket: { reconnectStrategy: (retries) => Math.min(retries * 50, 2000) },
});
await client.connect();
await client.set("user:123", JSON.stringify(user), { EX: 300 });
const raw = await client.get("user:123");
const user = raw ? JSON.parse(raw) : null;
await client.hSet("user:123", { name: "Alice", role: "admin" });
const data = await client.hGetAll("user:123");
await client.zAdd("leaderboard", [{ score: 1500, value: "alice" }]);
const top = await client.zRangeWithScores("leaderboard", 0, 9, { REV: true });
const pipeline = client.multi();
pipeline.set("a", "1");
pipeline.expire("a", 60);
pipeline.incr("counter");
const [, , count] = await pipeline.exec();
const acquired = await client.set("lock:job:42", workerId, { NX: true, EX: 30 });
if (!acquired) throw new Error("Lock unavailable");
const sub = client.duplicate();
await sub.connect();
await sub.subscribe("events", (message) => {
const data = JSON.parse(message);
handle(data);
});
Sentinel & Cluster Connections
from redis.sentinel import Sentinel
sentinel = Sentinel(
[("sentinel-1", 26379), ("sentinel-2", 26379), ("sentinel-3", 26379)],
socket_timeout=0.5,
)
master = sentinel.master_for("mymaster", decode_responses=True)
replica = sentinel.slave_for("mymaster", decode_responses=True)
from redis.asyncio.cluster import RedisCluster
cluster = RedisCluster.from_url("redis://node-1:7000", decode_responses=True)
await cluster.set("key", "value")
Red Flags
- No TTL on cache or session keys — keys without expiry accumulate forever and evict randomly under memory pressure; set
ex= on every set() call for cached data and sessions
- Using pub/sub for reliable delivery — pub/sub is fire-and-forget; subscribers that are offline when a message is published never receive it; use Redis Streams with consumer groups for any message that must not be lost
- Single connection instead of a pool — a single
await redis.from_url(...) connection serializes all commands and blocks under concurrent load; use ConnectionPool with max_connections sized to your concurrency
KEYS * in production — KEYS is O(n) and blocks the Redis event loop while it scans every key; use SCAN with a cursor to iterate non-blocking, or redesign to avoid key enumeration entirely
- Distributed lock without a unique value — a lock released by any caller using only the key (not the unique lock value) can accidentally release another owner's lock; always store a UUID as the value and use a Lua script to compare-then-delete atomically
- Unbounded stream growth —
xadd without maxlen lets the stream grow indefinitely; always set maxlen=N (with approximate=True for efficiency) or run periodic xtrim
- Sending multiple independent commands one at a time — each
await client.set(...) is a network round trip; batch three or more independent commands in a pipeline (async with client.pipeline()) to cut round-trip overhead significantly
Checklist