asyncio vs Sync redis-py Clients

redis-py ships two client families from the same package — the synchronous redis.Redis and the coroutine-based redis.asyncio.Redis — and picking the wrong one costs you either throughput or a week of debugging event-loop errors. The synchronous client blocks the calling thread on every round trip, so its concurrency is capped by how many threads you are willing to run. The async client multiplexes thousands of in-flight commands over a single thread, but only pays off when your service is genuinely I/O-bound and written async end to end. This page gives you a concrete procedure for choosing between them for a Redis 7.x workload: model where your time actually goes, understand what each client does under load, and avoid the pool-per-loop trap that turns a fast async client into a source of RuntimeError: got Future attached to a different loop. It sits alongside the pool-sizing mechanics in Tuning redis-py Connection Pools for High Concurrency and the broader theme of Connection Pooling and Client Resilience.

Prerequisites

  • Redis 7.x reachable from your application host.
  • redis-py 5.x on Python 3.10+ (pip install "redis>=5,<6"). Both clients live in the one package.
  • A clear picture of your host runtime: a WSGI server (Gunicorn sync workers), an ASGI server (Uvicorn), or a standalone worker.
  • A rough split of where a typical request spends time — waiting on Redis and other network calls versus doing CPU work in Python.
  • One representative endpoint or job to benchmark rather than a synthetic loop.

Step-by-Step Decision Procedure

1. Model your workload before choosing a client

The deciding factor is the ratio of I/O wait to CPU work. A request that fires several Redis and database calls and does little computation is I/O-bound and benefits from async concurrency; a request that deserializes a large payload and runs heavy Python is CPU-bound, where async buys nothing because the single thread is busy computing, not waiting. Measure the split before deciding.

import time

def profile_split(fn, *args):
    """Rough wall vs CPU split; a wide gap means I/O-bound (async helps)."""
    wall_start, cpu_start = time.perf_counter(), time.process_time()
    result = fn(*args)
    wall = time.perf_counter() - wall_start
    cpu = time.process_time() - cpu_start
    print(f"wall={wall*1000:.1f}ms cpu={cpu*1000:.1f}ms io_wait={(wall-cpu)*1000:.1f}ms")
    return result

2. Use redis.Redis for a synchronous, thread-per-request service

If your app already runs under a synchronous WSGI server and each request is handled by a blocking thread, the synchronous client is the honest fit. Every command blocks the thread until Redis replies; concurrency comes from running many worker threads or processes. This is simple, debuggable, and perfectly adequate for a cache-aside read that dominates most caching code.

import redis

# Blocking client: the calling thread waits for each reply.
client = redis.Redis(host="redis-primary", port=6379, decode_responses=True)

def get_profile(user_id: str) -> str | None:
    # This call blocks the worker thread for one network round trip.
    return client.get(f"user:profile:{user_id}")

3. Use redis.asyncio.Redis for an event-loop service

Under an ASGI server, a single thread runs an event loop that switches to other tasks whenever one awaits on the network. The async client lets one worker keep thousands of Redis commands in flight, because time spent waiting for a reply is time the loop spends progressing other requests — not a blocked thread.

import asyncio
import redis.asyncio as redis

client = redis.Redis(host="redis-primary", port=6379, decode_responses=True)

async def get_many(user_ids: list[str]) -> list[str | None]:
    # gather issues all GETs concurrently; the loop overlaps their network waits.
    return await asyncio.gather(*(client.get(f"user:profile:{u}") for u in user_ids))

4. Respect the pool-per-loop caveat

An async connection pool is bound to the event loop that created it. Building the client at import time — before any loop exists — or sharing one client across loops (a common mistake with asyncio.run called repeatedly, or a library that spins its own loop) produces got Future attached to a different loop. Create the client inside the running loop, typically in a startup hook, and close it on shutdown.

import contextlib
import redis.asyncio as redis

@contextlib.asynccontextmanager
async def redis_client():
    # Built inside the running loop; the pool binds to THIS loop only.
    client = redis.Redis(host="redis-primary", port=6379, decode_responses=True)
    try:
        yield client
    finally:
        await client.aclose()   # release the pool with the loop that owns it

5. Batch round trips with pipelining regardless of client

Neither client makes the network faster; the biggest win is often sending many commands in one round trip. Both families expose a pipeline that buffers commands and flushes them together, collapsing N round trips into one — the single highest-leverage change for a chatty path.

async def warm_cache(client, items: dict[str, str]) -> None:
    # One round trip for the whole batch instead of one per SET.
    async with client.pipeline(transaction=False) as pipe:
        for key, value in items.items():
            pipe.set(f"cache:{key}", value, ex=300)   # SET ... EX for a bounded TTL
        await pipe.execute()

6. Recognize when sync is the right answer

Async is not a universal upgrade. If your service is CPU-bound, uses blocking libraries with no async equivalent, runs as a short-lived script, or is a Celery/RQ worker whose framework is synchronous, the sync client is simpler and just as fast — the loop cannot overlap waits that a CPU-bound path never performs. Reads served from a replica pool are frequently fine synchronously when a modest thread count already saturates the link.

# A batch/CLI script: no event loop to justify, sync is clearer and correct.
import redis

client = redis.Redis(host="redis-primary", port=6379, decode_responses=True)
for key in client.scan_iter(match="session:*", count=500):
    client.unlink(key)   # SCAN + UNLINK, no async machinery needed
Sync thread-blocking timeline versus async event-loop timeline Left panel, synchronous: three worker threads each show a short compute block followed by a long blocked-on-network segment during which the thread does nothing, so concurrency is limited by how many threads run. Right panel, asynchronous: one event loop issues three commands in quick succession and their network waits overlap, with each reply handled as it returns, so a single thread keeps many commands in flight. Sync — thread per request each thread blocks for its round trip T1 T2 T3 time concurrency = thread count idle during I/O · memory per thread Async — one event loop awaits overlap on a single thread loop r1 r2 r3 time many commands in flight, one thread wall time bounded by the slowest wait Python / compute blocked on network I/O reply handled Same three commands: sync serializes the waits across threads; async overlaps them on one loop.

Failure Modes

Future attached to a different loop. An async client built at import time or reused across asyncio.run calls binds its pool to a loop that no longer exists, and every command raises RuntimeError: got Future attached to a different loop. Diagnose by checking where the client is constructed relative to the running loop:

grep -rn "redis.asyncio\|asyncio.Redis" your_app/ | grep -v "async def\|await"
# a client created at module top level (not inside a coroutine) is the usual culprit

Fix with Step 4: construct the client inside the running loop via a startup hook and close it on shutdown, one client per loop.

Blocking call stalls the event loop. A synchronous library call — a blocking DB driver, time.sleep, or the sync redis.Redis used by accident inside a coroutine — freezes the entire loop, so every concurrent request stalls, not just the offending one. Diagnose by watching event-loop lag and tail latency spikes that correlate with a specific code path:

redis-cli --latency -h redis-primary   # rules Redis out; if Redis is fast, the stall is in your loop

Fix by moving blocking work to asyncio.to_thread or replacing the library with an async one, and never instantiate the synchronous client inside async code.

Thread pool exhaustion under the sync client. A synchronous service handling a spike opens one blocked thread per in-flight request; past a few hundred threads, context-switching and memory overwhelm the host before Redis is the bottleneck. Diagnose:

redis-cli INFO clients | grep connected_clients   # climbs with threads, plateaus at your worker cap

Fix by capping worker threads and pool size together (see the sibling page on pool tuning), or by moving the hot path to the async client if the workload is genuinely I/O-bound.

Verification

Confirm an async path actually overlaps its round trips rather than serializing them — batched gather of N independent gets should finish in roughly one round trip, not N:

import asyncio, time
async def check(client, keys):
    start = time.perf_counter()
    await asyncio.gather(*(client.get(k) for k in keys))
    print(f"{len(keys)} gets in {(time.perf_counter()-start)*1000:.1f}ms")  # ~1 RTT, not N

Confirm the loop is never blocked by measuring scheduling lag under load; if a supposedly async request shows latency that tracks a blocking call, a synchronous dependency has slipped in. Cross-check that Redis itself is fast so the stall is attributed correctly:

redis-cli --intrinsic-latency 5     # baseline server latency, isolates client-side stalls

FAQ

Is the async client faster than the sync client?

Not per command — a single GET costs the same one round trip either way. Async wins on aggregate throughput for I/O-bound workloads because one thread keeps many commands in flight while others wait on the network, whereas the sync client needs a thread per concurrent command. For a CPU-bound path, or one command at a time, they perform the same and sync is simpler. Choose based on your concurrency model, not a belief that async is inherently faster.

Can I mix sync and async redis-py clients in one service?

You can run both, but never call the synchronous redis.Redis from inside a coroutine — it blocks the event loop and stalls every concurrent request. A common valid split is an async client for the request path and a synchronous client in a separate Celery worker process. If you must call blocking code from async, wrap it in asyncio.to_thread so it runs off the loop.

How do gevent or eventlet fit in?

gevent and eventlet monkey-patch the standard library so blocking socket calls yield cooperatively, which lets the synchronous redis.Redis behave concurrently under a greenlet server without async/await syntax. It works, but it applies process-wide patching that interacts badly with C extensions and native async code, so treat it as a way to scale an existing sync codebase rather than a design choice for new services — prefer redis.asyncio when starting fresh.

Are redis-py clients thread-safe?

The synchronous client and its connection pool are thread-safe: many threads can share one redis.Redis instance and check out connections independently. The async client is not thread-safe in the same way — it is loop-affine, and safety comes from staying on one event loop rather than from locking. Share one sync client across threads; give each event loop its own async client, as described in the pool-per-loop step above.


Up: Connection Pooling and Client Resilience