Redis — Python Client (redis-py)¶
redis-py (package redis) is the official Python client. It has a sync API (redis.Redis) and an asyncio API (redis.asyncio.Redis) with the same method names, plus RedisCluster and Sentinel clients. It works with Valkey too.
uv add redis # pure Python
uv add "redis[hiredis]" # optional C parser, faster on large replies
uv run python -c "import importlib.metadata as m; print(m.version('redis'))"
Examples below were run with redis-py 8.1.0 against Redis 8.10.2. Default values mentioned on this page are from that version — they have changed between major releases, so check yours with redis.Redis().connection_pool.connection_kwargs.
Connecting¶
import redis
r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
r = redis.Redis.from_url("redis://localhost:6379/0", decode_responses=True)
r = redis.Redis.from_url("redis://app:[email protected]:6379/2") # ACL user + password, db 2
r = redis.Redis.from_url("rediss://cache.example.com:6380/0") # rediss = TLS
r = redis.Redis(unix_socket_path="/run/redis/redis.sock")
r.ping() # True, or raises ConnectionError
The database in the URL wins
Redis.from_url("redis://localhost:6379/0", db=3) connects to database 0 — the path of the URL overrides the db= argument. Build the URL with the database you need, or leave the path out.
Bytes or str: decode_responses¶
raw = redis.Redis() # decode_responses=False (default)
raw.set("myapp:name", "Ann")
raw.get("myapp:name") # b'Ann'
text = redis.Redis(decode_responses=True)
text.get("myapp:name") # 'Ann'
text.hgetall("user:42") # {'name': 'Ann', 'visits': '1'} — numbers come back as str
- Use
decode_responses=Truefor text data — tests read better and you do not compareb'...'with'...'. - Keep a separate client without decoding for binary values (pickles, compressed blobs, images): decoding them as UTF-8 fails.
- Values you send can be
str,bytes,intorfloat.None,bool,dictandlistraiseDataError— serialize them yourself:
import json
r.set("myapp:user:1", json.dumps({"id": 1, "tags": ["qa"]}), ex=60)
json.loads(r.get("myapp:user:1")) # {'id': 1, 'tags': ['qa']}
r.set("myapp:bad", {"id": 1}) # DataError: ... must be str, int, float or bytes
Connection Pools¶
Each Redis object owns a ConnectionPool; a connection is taken from the pool for every command and returned after it. A Redis client can be shared between threads; PubSub and Pipeline objects cannot — create one per thread.
pool = redis.ConnectionPool.from_url("redis://localhost:6379/0", max_connections=20, decode_responses=True)
r1 = redis.Redis(connection_pool=pool)
r2 = redis.Redis(connection_pool=pool) # shares the 20 connections
blocking = redis.BlockingConnectionPool.from_url(
"redis://localhost:6379/0", max_connections=20, timeout=5, # wait up to 5 s for a free connection
)
| Rule | Why |
|---|---|
| Create one client (or pool) per process and reuse it | A new client per request opens a new TCP connection every time |
| Know what happens when the pool is full | ConnectionPool raises MaxConnectionsError: Too many connections; BlockingConnectionPool waits timeout seconds, then raises ConnectionError |
| Close clients you created | r.close() / await r.aclose() — in tests too, otherwise you get ResourceWarnings and leaked sockets |
Timeouts & Retries¶
from redis.backoff import ExponentialWithJitterBackoff
from redis.exceptions import ConnectionError, TimeoutError
from redis.retry import Retry
r = redis.Redis(
host="localhost",
port=6379,
decode_responses=True,
socket_connect_timeout=2, # TCP connect
socket_timeout=2, # waiting for a reply to any command
retry=Retry(ExponentialWithJitterBackoff(base=0.05, cap=1.0), retries=3),
health_check_interval=30, # PING a connection idle for 30 s before using it
client_name="orders-api", # shows up in CLIENT LIST
)
In redis-py 8.1 the defaults are socket_timeout=5, socket_connect_timeout=5 and a Retry with 10 retries (ExponentialWithJitterBackoff, base 10 ms, cap 1 s) on ConnectionError and TimeoutError. Consequences worth testing:
- With the server down, a default client needed about 4.5 s to raise
ConnectionErrorfor oneping(). For a cache in the request path that is too long: set short timeouts and fewer retries, then fall back to the database. - Blocking commands must fit inside
socket_timeout: with defaults,r.blpop("q", timeout=6)raisedTimeoutError: Timeout reading from socketafter about 60 s (5 s timeout, retried). For workers usesocket_timeout=Noneor a value above the block time. - Retrying is safe for reads and for idempotent writes (
SET). A retriedINCRorRPUSHmay apply twice if the first attempt reached the server and only the reply was lost.
def get_cached(r: redis.Redis, key: str) -> str | None:
try:
return r.get(key)
except (ConnectionError, TimeoutError):
return None # treat Redis outage as a cache miss; log / count it
Exception tree: everything inherits from redis.RedisError. ConnectionError, TimeoutError, ResponseError (server error such as WRONGTYPE), OutOfMemoryError (subclass of ResponseError), DataError, WatchError, LockError / LockNotOwnedError, NoScriptError.
Commands in Python¶
Method names follow commands; options are keyword arguments.
r.set("myapp:session:abc", "{}", ex=1800) # SET ... EX 1800
r.set("myapp:lock:job", "t1", nx=True, px=30_000) # True, or None if the key exists
r.set("myapp:k", "v", keepttl=True)
r.expire("myapp:k", 60, nx=True)
r.getex("myapp:session:abc", ex=1800)
r.hset("myapp:user:42", mapping={"name": "Ann", "visits": 0})
r.hincrby("myapp:user:42", "visits", 1)
r.zadd("myapp:lb", {"ann": 100, "bob": 250})
r.zrange("myapp:lb", 0, 9, desc=True, withscores=True) # [('bob', 250.0), ('ann', 100.0)]
for key in r.scan_iter(match="myapp:user:*", count=500): # SCAN, not KEYS
...
r.execute_command("OBJECT", "ENCODING", "myapp:lb") # any command without a helper
Pipelines & Transactions¶
with r.pipeline() as pipe: # MULTI/EXEC
pipe.incr("myapp:orders:count")
pipe.expire("myapp:orders:count", 3600)
count, _ = pipe.execute()
with r.pipeline(transaction=False) as pipe: # batching only
for i in range(1000):
pipe.get(f"myapp:item:{i}")
values = pipe.execute()
Commands in a pipeline return the pipeline, not a result — results come only from execute(). WATCH and Lua are covered in 03.
Pub/Sub¶
import time
def next_message(p, timeout: float = 1.0) -> dict | None:
deadline = time.monotonic() + timeout
while (left := deadline - time.monotonic()) > 0:
msg = p.get_message(ignore_subscribe_messages=True, timeout=left)
if msg is not None:
return msg
return None
p = r.pubsub()
p.subscribe("myapp:events")
r.publish("myapp:events", '{"id": 1}')
next_message(p) # {'type': 'message', 'pattern': None, 'channel': 'myapp:events', 'data': '{"id": 1}'}
p.close()
A single get_message(timeout=1) right after subscribe() returned None: it read the subscribe confirmation, skipped it and returned. Loop until the deadline, as above — the same applies in tests.
Streams: a Worker Loop¶
import redis
r = redis.Redis(decode_responses=True, socket_timeout=None) # BLOCK longer than the default socket timeout
STREAM, GROUP, CONSUMER = "myapp:orders", "billing", "worker-1"
try:
r.xgroup_create(STREAM, GROUP, id="0", mkstream=True)
except redis.ResponseError as exc:
if "BUSYGROUP" not in str(exc): # group already exists
raise
while True:
# 1. new entries (">" = never delivered to this group)
for stream, entries in r.xreadgroup(GROUP, CONSUMER, {STREAM: ">"}, count=10, block=5000) or []:
for entry_id, fields in entries:
handle(fields) # must be idempotent
r.xack(STREAM, GROUP, entry_id)
# 2. entries delivered earlier but never acked (a crashed consumer, or this one before a restart)
_, claimed, _ = r.xautoclaim(STREAM, GROUP, CONSUMER, min_idle_time=60_000, count=10)
for entry_id, fields in claimed:
handle(fields)
r.xack(STREAM, GROUP, entry_id)
Locks¶
with r.lock("myapp:lock:import", timeout=30, blocking_timeout=5):
run_import()
Details and caveats: 03 — Distributed Locks.
asyncio¶
import asyncio
import redis.asyncio as aioredis
async def main() -> None:
pool = aioredis.BlockingConnectionPool.from_url(
"redis://localhost:6379/0", max_connections=10, timeout=5, decode_responses=True,
)
r = aioredis.Redis.from_pool(pool) # the client owns the pool and closes it
try:
await r.set("myapp:a", "1", ex=60)
hits = await asyncio.gather(*(r.incr("myapp:hits") for _ in range(100)))
print(max(hits)) # 100
async with r.pipeline(transaction=True) as pipe:
pipe.incr("myapp:n")
pipe.expire("myapp:n", 60)
await pipe.execute() # [1, True]
async with r.pubsub() as ps:
await ps.subscribe("myapp:ch")
await r.publish("myapp:ch", "hi")
async with r.lock("myapp:lock:a", timeout=10):
...
finally:
await r.aclose()
asyncio.run(main())
gather()of 100 commands onaioredis.Redis.from_url(..., max_connections=10)failed withMaxConnectionsError: Too many connections— the plain async pool does not wait. UseBlockingConnectionPoolor limit concurrency with a semaphore.Redis(connection_pool=pool)does not close a pool you passed in;Redis.from_pool(pool)does.- Create async clients inside the running event loop (app startup, an async fixture), not at import time — a client bound to a closed loop fails in the next test.
RESP3, Cluster and Sentinel¶
r3 = redis.Redis(protocol=3, decode_responses=True) # RESP3 protocol: typed replies (maps, sets, push messages)
from redis.sentinel import Sentinel
sentinel = Sentinel([("sentinel-1", 26379), ("sentinel-2", 26379)], socket_timeout=1)
primary = sentinel.master_for("mymaster", decode_responses=True) # follows failover
replica = sentinel.slave_for("mymaster", decode_responses=True) # read-only traffic
from redis.cluster import RedisCluster
rc = RedisCluster.from_url("redis://node-1:6379", decode_responses=True) # discovers the other nodes
Tracing: opentelemetry-instrumentation-redis creates a client span per command — see OpenTelemetry — Auto-Instrumentation.
See also¶
- Redis — Overview
- Redis — Patterns
- Redis — Testing Setup & Isolation
- FastAPI — Modern Async Web Framework
- SQLAlchemy — Python ORM & SQL Toolkit