Why Connection Pools Are Per Process¶
A connection pool lives inside one process and, for async pools, inside one event loop. When an application runs several worker processes — Gunicorn or Uvicorn workers, a multiprocessing pool, Celery workers — each process has its own pool, and the database or upstream sees the sum. Measured against PostgreSQL 17, four forked workers each with an asyncpg pool of 10 opened 40 server connections. Pools cannot be shared across processes either: an asyncpg pool created before fork() and used in the child raised InterfaceError: cannot perform operation: another operation is in progress, and an httpx.AsyncClient used in a child with a new event loop raised RuntimeError: ... is bound to a different event loop. A pool's close() in the parent afterwards hung until asyncpg warned that it had taken over 60 seconds. This guide sizes pools for multi-process deployments and creates them in the right place.
Prerequisites¶
- Python 3.11+; examples use asyncpg and httpx.
- Pool sizing, from sizing async connection pools for throughput.
- Startup hooks, from managing startup and shutdown with ASGI lifespan.
1. Multiply pool size by processes¶
Each worker process creates its own pool, so the connections the server sees are the product of workers and pool size — plus any other processes using the same database:
# 4 Uvicorn workers, each running this at startup:
pool = await asyncpg.create_pool(dsn, min_size=10, max_size=10)
# PostgreSQL then sees:
# select count(*) from pg_stat_activity where application_name = 'api'; -> 40
Measured: 4 forked workers × max_size=10 = 40 connections in pg_stat_activity. Scale to 3 pods with 4 workers each and the same setting needs 120 connections; PostgreSQL's default max_connections is 100, and each connection costs the server several megabytes of memory. Size the per-process pool from the server's budget divided by the total number of processes, not from what one process could use. When the product does not fit, put a server-side pooler such as PgBouncer in front, which multiplexes many client connections onto fewer server connections.
Verify: the sum of max_size across every process that can run at once is below the server's connection limit, with headroom for admin and migration connections.
2. Create pools after the fork¶
Servers with a --preload or "import the app once, then fork" mode run module-level code in the parent. A pool or client created there is copied into every child with its open sockets and its event-loop bindings — neither of which works in the child:
# Wrong: created at import time, before the server forks workers
pool = asyncio.get_event_loop().run_until_complete(asyncpg.create_pool(dsn))
client = httpx.AsyncClient()
# In a child, after fork:
# asyncpg -> InterfaceError: cannot perform operation: another operation is in progress
# httpx -> RuntimeError: <asyncio.locks.Event ...> is bound to a different event loop
Both failures were reproduced: the child inherited objects tied to the parent's event loop and connection state. They are the lucky outcome; the dangerous one is two processes writing to the same inherited socket, interleaving protocol messages on one database connection. Create pools inside each process, after it starts its own event loop — in the ASGI lifespan, a worker's startup hook or the initializer of a process pool:
@asynccontextmanager
async def lifespan(app):
app.state.pool = await asyncpg.create_pool(dsn, min_size=2, max_size=10) # per worker
app.state.http = httpx.AsyncClient(limits=httpx.Limits(max_connections=50))
yield
await app.state.http.aclose()
await app.state.pool.close()
Verify: with --preload or fork start method, each worker's pool connections have distinct client ports in pg_stat_activity, and no module-level code opens connections.
3. Clean up correctly in the parent¶
A parent that created a pool, forked, and then tries to close it can hang: the pool waits for connections it believes are in use. Measured, after a child had touched the inherited pool, the parent's pool.close() printed asyncpg's warning that it was "taking over 60 seconds to complete" and did not finish:
# If the parent must hold connections before forking, close them before the fork
await pool.close()
pid = os.fork()
# Or make the shutdown bounded, and terminate if it does not finish
try:
await asyncio.wait_for(pool.close(), timeout=10)
except TimeoutError:
pool.terminate() # closes every connection immediately, no waiting
The cleanest rule is that a process which forks holds no connections at the moment of the fork. With the spawn or forkserver start methods (forkserver is the default on Linux from Python 3.14), children start from a fresh interpreter and inherit nothing, which removes the problem for multiprocessing and ProcessPoolExecutor entirely.
Verify: shutting down the parent completes within its timeout in every test run.
4. Do not pass connections to process pools¶
ProcessPoolExecutor and multiprocessing pickle arguments to send them to workers. Connections, pools and clients cannot be pickled, and should not be: a worker must open its own:
from concurrent.futures import ProcessPoolExecutor
_conn = None
def init_worker(dsn: str) -> None:
global _conn
import psycopg # sync driver: CPU workers rarely need asyncio
_conn = psycopg.connect(dsn)
def score_batch(ids: list[int]) -> list[float]:
rows = _conn.execute("select features from items where id = any(%s)", (ids,)).fetchall()
return [heavy_model(r[0]) for r in rows]
executor = ProcessPoolExecutor(max_workers=4, initializer=init_worker, initargs=(dsn,))
The initializer runs once per worker process and holds a connection for its lifetime — one connection per worker, which counts toward the server's total like any pool. The initializer pattern in general is covered in initializing process pool workers with expensive state; often it is simpler still to keep database access in the async parent and send only data to the workers.
Verify: process-pool tasks receive and return plain data, and the number of connections stays at one per worker.
5. Budget connections across the whole fleet¶
With the per-process rule established, sizing becomes arithmetic over the deployment:
def per_process_pool(server_limit: int, reserved: int, pods: int, workers_per_pod: int,
other_clients: int = 0) -> int:
"""Largest max_size that keeps the fleet under the server's connection limit."""
budget = server_limit - reserved - other_clients
return max(1, budget // (pods * workers_per_pod))
per_process_pool(server_limit=100, reserved=10, pods=3, workers_per_pod=4) # -> 7
Include everything that connects: API workers, background job workers, cron jobs, migrations, and the extra pods that exist during a rolling deploy — a deploy that briefly runs old and new pods together can double the count. Autoscaling multiplies it again. When the per-process number comes out too small to serve the load, the answer is a server-side pooler, fewer and larger processes, or a bigger database — not a larger client pool.
Verify: at maximum autoscale during a rolling deploy, total connections stay below the server limit.
Verification¶
Pools are set up correctly for multiple processes when:
- No pool or client is created before a fork.
- Each process creates and closes its own pools in startup and shutdown hooks.
- The fleet-wide connection total fits the server's limit, including deploys and autoscaling.
- Process-pool workers open their own connections, or receive only data.
Diagnostic Hook: group the server's connections by client host and process (client_addr, application_name, backend_start in pg_stat_activity). Totals that jump during deploys show the rolling-deploy overlap; connections from one process ID appearing under two client ports point at a pool shared across a fork.
Pitfalls & edge cases¶
- Sizing pools per process in isolation. The server sees the sum: 40 for 4 × 10.
- Creating pools at import time with preload. Children inherit unusable objects.
- Closing an inherited pool in the parent. It can hang; bound it and terminate.
- Forgetting deploy overlap. Old and new pods both hold connections for a while.
Frequently Asked Questions¶
Do Uvicorn or Gunicorn workers share a connection pool?
No. Each worker process has its own pool, so the database sees workers × pool size connections. Four workers with a pool of 10 opened 40 connections in testing.
Can I create an asyncpg pool or httpx client before forking?
No. In testing, a child using an inherited asyncpg pool raised InterfaceError and an inherited httpx AsyncClient raised a different-event-loop RuntimeError. Create them in each process's startup hook.
How big should each worker's database pool be?
Divide the server's connection limit, minus reserved connections, by the maximum number of processes that can run at once across the fleet, including during deploys.
How do process pool workers get a database connection?
Open one per worker in the ProcessPoolExecutor initializer, or keep database access in the async parent and send only data to the workers.
Related¶
- Connection Pooling & Keep-Alive — up to the topic overview.
- Warming connection pools at startup — what each worker should do once its pool exists.
- Network I/O & Protocol Handling — the section overview.