Managing aioboto3 Clients Without Leaking Connections¶
An aioboto3 client is not a lightweight handle. It owns an aiohttp connector with a connection pool, resolved credentials, and the service's endpoint and model data, and it must be entered and exited as an async context manager. Getting its lifecycle wrong shows up as latency, hangs or warnings rather than errors. Tested with aioboto3 15.5 against a local S3-compatible server: creating and closing a client for each request cost 25.9 ms per get_object, against 2.1 ms with one shared client. The default max_pool_connections of 10 allowed about 10 response bodies open at once (peak 11), and 50 concurrent 4 MiB downloads took 0.78 s; with a pool of 50 they took 0.58 s with 50 open. With a pool of 2, two response bodies left unread made a third request wait until its 3.0 s timeout; closing them let it finish in 35 ms. A client never exited produced aiohttp's "Unclosed connector" warning. This guide sets up the lifecycle and the pool correctly.
Prerequisites¶
- Python 3.11+,
pip install aioboto3; measured with aioboto3 15.5 / aiobotocore 2.25. - Application lifespan, from managing startup and shutdown with ASGI lifespan.
- Per-process pools, from why connection pools are per process.
1. Create one client per process, in the lifespan¶
A Session is cheap and holds configuration; a client is expensive and holds connections. Enter the client once at startup and keep it for the life of the process:
import aioboto3
from aiobotocore.config import AioConfig
session = aioboto3.Session() # configuration only
@asynccontextmanager
async def lifespan(app):
config = AioConfig(max_pool_connections=50, connect_timeout=5, read_timeout=30,
retries={"mode": "adaptive", "max_attempts": 5})
async with session.client("s3", config=config) as s3:
app.state.s3 = s3 # shared by every request
yield # exiting closes the pool cleanly
async def get_report(request):
s3 = request.app.state.s3
...
Measured: 25.9 ms per operation with a client per request versus 2.1 ms shared — client creation, credential resolution and a fresh TCP (and in production TLS) connection, every time. A client created in a request handler and not exited also leaks its connector: tested, dropping a client without exiting it produced aiohttp's "Unclosed connector" warning listing the open connection. In worker-process servers, each worker creates its own client in its own lifespan.
Verify: the number of clients created over a load test equals the number of worker processes.
2. Size max_pool_connections to your concurrency¶
The pool caps simultaneous requests. Requests beyond it wait inside the client for a connection — silently, and counted against their timeouts:
config = AioConfig(max_pool_connections=64) # default is 10
slots = asyncio.Semaphore(64) # same number at the application level
async def fetch(s3, bucket: str, key: str) -> bytes:
async with slots: # wait here, visibly, not inside the pool
response = await s3.get_object(Bucket=bucket, Key=key)
async with response["Body"] as body:
return await body.read()
Measured with 50 concurrent 4 MiB downloads read by a slow consumer: the default pool kept about 10 bodies open at once (peak 11) and took 0.78 s; a pool of 50 kept 50 open and took 0.58 s. The gap grows with latency, since each waiting request sits idle for a full round trip. A semaphore with the same size makes the waiting explicit and measurable, and keeps queued requests from consuming their timeout budget inside the pool.
Verify: under load, requests in flight approach the pool size, and time spent waiting on the semaphore is exported as a metric.
3. Read or close every response body¶
A response body holds its connection until it has been read to the end or closed. Bodies that are dropped, partially read, or stored for later keep connections out of the pool:
async def read_small_leaky(s3, bucket: str, key: str) -> bytes | None:
response = await s3.get_object(Bucket=bucket, Key=key)
if response["ContentLength"] > LIMIT:
return None # leak: body never read or closed
return await response["Body"].read()
async def read_small(s3, bucket: str, key: str) -> bytes | None:
response = await s3.get_object(Bucket=bucket, Key=key)
async with response["Body"] as body: # releases the connection on every path
if response["ContentLength"] > LIMIT:
return None
return await body.read()
Tested with max_pool_connections=2: after two get_object calls whose 4 MiB bodies were neither read nor closed, a third call waited until its 3.0 s timeout; after closing the two bodies, the same call completed in 35 ms. Small bodies hide the problem, because aiohttp often reads them completely into its buffer and frees the connection early — tests with small objects pass while production with large ones hangs. Use head_object when you only need metadata.
Verify: a test with a pool of 2 performs many get-and-skip operations in a row without stalling.
4. Use paginators and resources inside the client's lifetime¶
Paginators and streaming bodies borrow the client's connections, so they must be consumed before the client exits:
async def list_keys(s3, bucket: str, prefix: str) -> list[str]:
keys = []
paginator = s3.get_paginator("list_objects_v2")
async for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
keys.extend(obj["Key"] for obj in page.get("Contents", []))
return keys
# Resources (the higher-level API) are context managers too
async with session.resource("s3") as s3r:
bucket = await s3r.Bucket("reports")
async for obj in bucket.objects.filter(Prefix="2026/"):
print(obj.key)
Returning a paginator, an unread body or a resource object out of an async with block and using it after the client has closed fails with errors about a closed session. Background tasks that use the shared client must be finished or cancelled before the lifespan exits; order the shutdown so they stop first, as in Graceful Shutdown & Signals.
Verify: shutting down the application during active transfers produces no "Unclosed connector" or "session is closed" errors.
5. Use separate clients for separate failure domains¶
One client per process is the default; more than one is right when workloads need different settings or must not starve each other:
@asynccontextmanager
async def lifespan(app):
async with AsyncExitStack() as stack:
app.state.s3_api = await stack.enter_async_context(
session.client("s3", config=AioConfig(max_pool_connections=32, read_timeout=5)))
app.state.s3_batch = await stack.enter_async_context(
session.client("s3", config=AioConfig(max_pool_connections=16, read_timeout=120)))
app.state.s3_eu = await stack.enter_async_context(
session.client("s3", region_name="eu-west-1"))
yield
A separate client for batch transfers keeps a large export from occupying the connections that user-facing requests need — a bulkhead, as in bulkhead isolation with per-dependency semaphores. Clients are per region and per service, so a multi-region application holds one per region. AsyncExitStack closes them all in reverse order at shutdown.
Verify: a long batch transfer running alongside a load test does not raise the user-facing requests' pool wait.
Verification¶
aioboto3 clients are managed well when:
- Clients are created once per process in the lifespan and exited at shutdown.
max_pool_connectionsmatches intended concurrency, with a semaphore of the same size.- Every response body is read or closed, using its context manager.
- Separate workloads get separate clients where they must not starve each other.
Diagnostic Hook: export the number of open connections per client and the time spent waiting for the application semaphore. Open connections pinned at the pool size with idle CPU means the pool or unread bodies are the limit; "Unclosed connector" warnings in logs mean a client path that is never exited.
Pitfalls & edge cases¶
- A client per request. Measured: 25.9 ms of overhead per operation.
- The default pool of 10. Concurrency silently capped around 10.
- Unread bodies. Tested: a pool of 2 stalled until timeout.
- Using a client's objects after it exits. Paginators and bodies fail with closed-session errors.
Frequently Asked Questions¶
Should I create a new aioboto3 client for each request?
No. Create one client per process at startup and share it. In testing, a client per request cost 25.9 ms per operation against 2.1 ms with a shared client.
What is the default connection pool size in aioboto3?
max_pool_connections defaults to 10, which caps concurrent requests at about 10. Raise it with AioConfig(max_pool_connections=...) to match your concurrency.
Why do my aioboto3 requests hang?
Often all pooled connections are held by response bodies that were never read or closed. Use async with response["Body"] as body so the connection is released on every path.
How do I fix 'Unclosed connector' warnings from aioboto3?
Enter clients with async with (or an AsyncExitStack) and exit them at shutdown, and do not create clients in request handlers.
Related¶
- Cloud SDKs & Object Storage — up to the topic overview.
- Downloading many S3 objects concurrently — putting the sized pool to work.
- Network I/O & Protocol Handling — the section overview.