Pooling Redis Connections in asyncio¶
redis-py's asyncio client opens one connection per in-flight command, from a pool whose size and behaviour on exhaustion depend on which pool class you use. Under real concurrency both built-in options misbehave in different ways. Measured on Python 3.14 with redis-py 8.1.0 against Redis in Docker, with 500 tasks each running a SET and GET in a loop for 3 seconds: the default client's pool allowed 100 connections and raised MaxConnectionsError: Too many connections 141,756 times. BlockingConnectionPool(max_connections=20) raised nothing and served 11,085 requests per second, but its p99 was 2,999 ms — some tasks waited for the whole run while others took connections repeatedly — and a FIFO queue class did not change that. An asyncio.Semaphore(20) in front of the same pool brought the maximum to 50 ms and throughput to 12,500 per second. Pipelining 10 commands per round trip raised throughput from 26,854 to 83,085 commands per second. And closing a client that was given a pool left all 30 of its connections open. This guide configures the pool to avoid each problem.
Prerequisites¶
- redis-py 5 or later (
redis.asyncio), and a Redis server. - Pool sizing, from sizing async connection pools for throughput.
- The topic overview, Connection Pooling & Keep-Alive.
1. Know the default pool's limit and failure mode¶
redis.asyncio.Redis.from_url(url) creates a ConnectionPool. Check its limit, and what happens beyond it:
client = redis.asyncio.Redis.from_url("redis://cache:6379/0")
print(client.connection_pool.max_connections) # 100 in redis-py 8.1
Measured with 500 concurrent tasks: the pool opened 100 server connections, served 7,691 requests per second, and raised MaxConnectionsError 141,756 times — every command issued while all 100 connections were busy failed immediately instead of waiting. Lowering max_connections to 20 made it worse: 2,980 requests per second and 306,437 errors. A plain ConnectionPool is a limit that fails, not a limit that queues, so it is safe only when concurrency is bounded elsewhere below max_connections.
Verify: for the service's peak concurrency, either max_connections is above it, or concurrency is bounded in front of the client.
2. Use a blocking pool, and see its tail¶
BlockingConnectionPool waits for a free connection, up to timeout seconds (20 by default), instead of raising:
pool = redis.asyncio.BlockingConnectionPool.from_url(
"redis://cache:6379/0", max_connections=20, timeout=5,
)
client = redis.asyncio.Redis(connection_pool=pool)
Measured: no errors, 11,085 requests per second over 20 connections — and a median of 1.8 ms with a p99 of 2,999 ms. A few tasks waited for almost the entire 3-second run. Changing the pool's internal queue from its default LifoQueue to a FIFO asyncio.Queue did not help, 2,997 ms; a pool of 100 did not help either, 2,971 ms. The queue orders idle connections, not waiting tasks; the results are consistent with a task that releases a connection and immediately asks again getting it back before a long-waiting task is woken. The low median hides this; a service with a latency objective would miss it on a small fraction of requests every time the pool is saturated.
Verify: under saturation, the maximum latency of Redis calls is checked, not just the median.
3. Put a FIFO semaphore in front of the pool¶
asyncio.Semaphore wakes waiters in the order they arrived. Sized to the pool, it ensures that a task only asks the pool for a connection when one is free, so the pool never has waiters of its own:
class BoundedRedis:
def __init__(self, url: str, size: int = 20):
self.pool = redis.asyncio.BlockingConnectionPool.from_url(url, max_connections=size)
self.client = redis.asyncio.Redis(connection_pool=self.pool)
self.slots = asyncio.Semaphore(size)
async def get(self, key):
async with self.slots:
return await self.client.get(key)
async def set(self, key, value, **kw):
async with self.slots:
return await self.client.set(key, value, **kw)
async def aclose(self):
await self.client.aclose()
await self.pool.aclose()
Measured: 12,500 requests per second, a median of 40.0 ms, a p99 of 48.1 ms and a maximum of 50 ms. The median rose because the queueing that was hidden in a few starved tasks is now shared evenly by all 500; the maximum fell by a factor of 60, and throughput rose slightly. The same pattern fixed the same problem in MongoDB's driver, measured in using MongoDB with PyMongo's async API.
Verify: with more tasks than connections, the maximum latency is within a small multiple of the p99.
4. Pipeline commands that belong together¶
Each command holds a connection for one round trip. When a task sends several commands, a pipeline sends them in one round trip on one connection:
async def save_profile(r, user_id: str, fields: dict[str, str]):
async with r.slots:
async with r.client.pipeline(transaction=False) as p:
for name, value in fields.items():
p.hset(f"user:{user_id}", name, value)
p.expire(f"user:{user_id}", 3600)
await p.execute()
Measured with 2,000 tasks each setting 10 keys through 20 connections: one command at a time took 0.74 s, 26,854 commands per second; a pipeline per task took 0.24 s, 83,085 commands per second. transaction=False sends the commands without MULTI/EXEC; use transaction=True only when the commands must be applied atomically, since it adds two commands per batch. A pipeline holds its connection until execute() returns, so keep pipelines short — hundreds of commands, not millions.
Verify: code paths that send several commands per request use a pipeline, and the number of round trips per request is known.
5. Close the pool you created¶
Redis.aclose() closes the client's pool only if the client created it. When you pass a pool in, you own it:
@asynccontextmanager
async def lifespan(app):
pool = redis.asyncio.BlockingConnectionPool.from_url(settings.redis_url, max_connections=20)
app.state.redis = redis.asyncio.Redis(connection_pool=pool)
yield
await app.state.redis.aclose()
await pool.aclose() # the client does not close a pool it was given
Measured: a client from from_url closed all its connections on aclose(). A client constructed with connection_pool=pool and used by 30 concurrent commands left 30 connections open after aclose(); pool.aclose() closed them. In tests that create a client per test, or services that rebuild clients on configuration reload, the leftover connections accumulate on the Redis server until its maxclients is reached. For shutdown order across several pools, see closing pools cleanly on shutdown.
Verify: after the application's shutdown, CLIENT LIST on the Redis server shows none of its connections.
Verification¶
The Redis pool is configured well when:
- Concurrency is bounded in front of the client by a semaphore sized to the pool.
- The pool is a
BlockingConnectionPoolwith atimeoutshorter than the request deadline. - Multi-command operations use pipelines.
- The pool is closed explicitly when it was passed to the client.
Diagnostic Hook: when a service logs MaxConnectionsError: Too many connections under load, the default ConnectionPool is at its limit of 100 and failing instead of waiting. When it logs nothing but a few requests take seconds, check for a BlockingConnectionPool without a semaphore in front.
Pitfalls & edge cases¶
- The default pool under high concurrency. Measured: 141,756 errors in 3 seconds.
BlockingConnectionPoolalone. Measured: p99 of 2,999 ms.- Expecting
queue_class=asyncio.Queueto make it fair. Measured: no change. aclose()on a client with a passed-in pool. Measured: 30 connections left open.
Frequently Asked Questions¶
What is redis-py's default max_connections for asyncio?
100 in redis-py 8.1. Beyond it, the default ConnectionPool raises MaxConnectionsError immediately: 141,756 times in 3 seconds with 500 tasks.
Should I use BlockingConnectionPool in redis-py?
Yes, with an asyncio.Semaphore of the same size in front. Alone, it avoided errors but some tasks waited 3 s; with the semaphore, the maximum was 50 ms.
Does closing a redis-py client close its connection pool?
Only if the client created the pool. With connection_pool= passed in, aclose() left 30 connections open; call pool.aclose() as well.
How much do Redis pipelines help?
For 10 commands per task, a pipeline raised throughput from 26,854 to 83,085 commands per second by using one round trip instead of ten.
Related¶
- Connection Pooling & Keep-Alive — up to the topic overview.
- Health-checking pooled connections before use — what to do about connections that died while idle.
- Network I/O & Protocol Handling — the section overview.