Closing Connection Pools Cleanly on Shutdown¶
Shutting down an async service is a sequence: stop accepting work, let work in flight finish, then close the pools that work was using. Getting the order wrong turns every deploy into a burst of errors. Measured on Python 3.14 with uvicorn 0.54, Starlette 1.7 and an asyncpg 0.31 pool of 20, sending SIGTERM while 20 requests were each running a 1-second PostgreSQL query: closing the pool in the ASGI lifespan let all 20 requests return 200, refused new connections, and exited 0.75 s after the signal. Closing the pool from a SIGTERM handler failed all 20 with ConnectionDoesNotExistError and HTTP 500. With --timeout-graceful-shutdown 1 and 3-second queries, uvicorn cancelled all 20 at the deadline. And the pool's own methods differ: pool.close() waited 2.82 s for a running query to finish, while pool.terminate() returned at once — leaving the query still running on the server. This guide builds a shutdown sequence that finishes work, bounds the wait, and closes everything.
Prerequisites¶
- An ASGI app with a lifespan, from managing startup and shutdown with ASGI lifespan.
- Draining requests, from draining in-flight requests before shutdown.
- The topic overview, Connection Pooling & Keep-Alive.
1. Close pools in the lifespan, after the server drains¶
ASGI servers run the lifespan's shutdown half only after they have stopped accepting connections and waited for requests in flight. That makes the code after yield the right place to close pools:
@asynccontextmanager
async def lifespan(app):
app.state.pool = await asyncpg.create_pool(DSN, min_size=5, max_size=20)
yield
await app.state.pool.close() # runs after in-flight requests have finished
app = Starlette(routes=routes, lifespan=lifespan)
Measured with 20 requests in flight, each in a 1-second query, and SIGTERM sent 0.3 s after they started: all 20 returned 200, a new request made after the signal was refused at the TCP level, and the process exited 0.75 s after the signal — the remaining query time plus a 0.05 s pool close. Nothing in the application had to know about signals.
Verify: in a test that sends SIGTERM during requests, every in-flight request completes and the pool closes after the last one.
2. Do not close pools from a signal handler¶
A tempting shortcut is to close resources the moment the signal arrives. Measured with a SIGTERM handler that called pool.terminate() and then let the server stop:
# Anti-pattern: tears the pool down while requests are still using it
signal.signal(signal.SIGTERM, lambda *a: app.state.pool.terminate())
All 20 in-flight requests failed with asyncpg.exceptions.ConnectionDoesNotExistError: connection was closed in the middle of operation and returned 500, 0.15 s after the signal. The signal handler runs before the server has drained anything, so the pool disappears under requests that the server still intends to finish. Let the signal stop the server, and let the server's shutdown sequence reach the lifespan.
Verify: no signal handler in the application touches pools, clients or sessions.
3. Bound the time pools may take to close¶
The server's drain and the pool's close can both wait on slow work. uvicorn's --timeout-graceful-shutdown bounds the drain; after it, uvicorn cancels the remaining request tasks — measured with 3-second queries and a 1-second timeout, all 20 requests were cancelled and returned 500. The pool close needs its own bound:
async def close_pool(pool, grace: float = 5.0):
try:
async with asyncio.timeout(grace):
await pool.close() # waits for connections to be released
except TimeoutError:
pool.terminate() # drop the rest immediately
Measured outside a server, with one 3-second query running: pool.close() returned after 2.82 s, once the query finished, and the query completed normally. pool.terminate() returned immediately and the query's caller got ConnectionDoesNotExistError. With close() under a 0.5 s timeout and terminate() as the fallback, shutdown took 0.50 s. Choose the grace period from the slowest operation you are willing to wait for, and keep the sum of the server's drain timeout and the pool grace below the orchestrator's kill deadline.
Verify: shutdown time has an upper bound — drain timeout plus pool grace — that is below the platform's termination grace period.
4. Remember that terminate does not stop the server's work¶
terminate() closes the client's sockets; it does not tell PostgreSQL to stop. Measured: after terminate() during a 3-second pg_sleep, pg_stat_activity still showed the query active 0.3 s later. The same happened when close() timed out and fell back to terminate(). A long write abandoned this way keeps its locks until the server notices or finishes. Bound statements on the server, so an abandoned query ends on its own:
app.state.pool = await asyncpg.create_pool(
DSN, min_size=5, max_size=20,
server_settings={"statement_timeout": "10s"}, # server ends any statement after 10 s
)
Measured with statement_timeout at 1 s and the same terminated 3-second query: the query was still active 0.5 s after it started and gone by 1.5 s, ended by the server rather than running its full 3 seconds. With a statement timeout below the pool's close grace, a query cut off by shutdown ends within a known time. The same applies to other databases: MySQL's MAX_EXECUTION_TIME hint is measured in using MySQL from asyncio.
Verify: after a forced shutdown during a long query, the server shows the query ending within the statement timeout.
5. Close every client, in reverse order of creation¶
A service usually holds several pools — a database pool, a Redis client, an HTTP client. Close them in reverse order of creation, so anything that depends on another closes first, and close each even if an earlier one fails:
@asynccontextmanager
async def lifespan(app):
async with AsyncExitStack() as stack:
app.state.pg = await asyncpg.create_pool(DSN)
stack.push_async_callback(close_pool, app.state.pg)
app.state.redis_pool = redis.asyncio.BlockingConnectionPool.from_url(REDIS_URL, max_connections=20)
stack.push_async_callback(app.state.redis_pool.aclose) # the client won't close it
app.state.redis = redis.asyncio.Redis(connection_pool=app.state.redis_pool)
stack.push_async_callback(app.state.redis.aclose)
app.state.http = await stack.enter_async_context(httpx.AsyncClient(timeout=10))
yield
AsyncExitStack runs the callbacks last-in, first-out, and continues to the next one if a callback raises. Two clients need care: a redis-py client does not close a pool it was given, which left 30 connections open in pooling Redis connections in asyncio; and an unclosed aiohttp.ClientSession printed Unclosed client session at exit in this test, while an unclosed httpx client exited silently — so silence is not evidence of a clean shutdown.
Verify: after shutdown, each backing service shows no connections from the process, checked with pg_stat_activity, CLIENT LIST or connection counters.
Verification¶
Pools close cleanly when:
- Pools close in the lifespan's shutdown, after the server has drained requests.
- No signal handler closes resources.
- Each close is bounded —
close()with a grace period, thenterminate()— and the total fits the platform's kill deadline. - Server-side statement timeouts end any query cut off by a forced close.
Diagnostic Hook: when deploys produce a burst of ConnectionDoesNotExistError or "connection was closed in the middle of operation", find where the pool is closed. If it happens on the signal rather than after the drain — as in the handler variant here, which failed all 20 requests — move it into the lifespan.
Pitfalls & edge cases¶
- Closing pools on the signal. Measured: 20 of 20 requests failed.
- Unbounded
pool.close(). It waits for the slowest running query. terminate()as cancellation. The server kept running the query.- Assuming a client closes a pool it was given. redis-py does not.
Frequently Asked Questions¶
Where should I close database pools in a FastAPI or Starlette app?
After yield in the lifespan. The server runs it after draining requests: all 20 in-flight requests completed and the pool closed in 0.05 s.
What is the difference between asyncpg pool.close() and terminate()?
close() waits for connections to be released: 2.82 s with a 3 s query running. terminate() closes sockets at once, failing the query's caller and leaving it running on the server.
Why do requests fail with connection closed during deploys?
The pool was closed before requests finished, typically from a signal handler. Closing in the lifespan instead let all 20 in-flight requests succeed.
How long should shutdown wait for pools?
Long enough for normal requests, bounded: close() under a timeout with terminate() as fallback, and a server-side statement timeout so abandoned queries end too.
Related¶
- Connection Pooling & Keep-Alive — up to the topic overview.
- Health-checking pooled connections before use — the other end of a connection's life.
- Network I/O & Protocol Handling — the section overview.