Disposing async engines on shutdown and in forked workers

Call await engine.dispose() while the event loop is still running when a process exits, and engine.dispose(close=False) in a freshly forked child so it abandons rather than closes the sockets it inherited — a pooled connection shared across a fork corrupts the protocol stream for both processes. This guide belongs to configuring async engines and connection pools.

Quick Answer

Two disposal calls, for two different situations. Getting them the wrong way round is worse than forgetting them.

One socket, two processes Five steps. The parent process creates the engine and opens a connection, so a socket file descriptor exists. The process forks, and the child inherits a copy of that descriptor pointing at the same TCP connection and the same TLS session. Both processes believe they own it. Two statements interleave on one wire, and PostgreSQL sees a corrupted protocol stream. The symptoms are decryption errors, protocol violations, or results delivered to the wrong process. parent creates the engine a connection is opened one socket fork() the child inherits the descriptor same socket, same TLS session both processes use it statements interleave the stream is corrupted symptoms bad record mac, protocol violation results in the wrong process fix dispose(close=False) in the child TLS makes the corruption obvious; without it the failure can be silent and much worse.

Before — no disposal, and an engine inherited across a fork:

# app.py
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine("postgresql+asyncpg://shop:secret@db/shop")
app = FastAPI()
# $ gunicorn -k uvicorn.workers.UvicornWorker --preload --workers 4 app:app
# Warm-up code that queried the database before the fork leaves four workers
# sharing one socket:
#   asyncpg.exceptions.ProtocolViolationError
#   ssl.SSLError: [SSL] decryption failed or bad record mac

After — disposal on shutdown, and a fresh pool in each worker:

from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://shop:secret@db/shop", pool_size=10)
Session = async_sessionmaker(engine, expire_on_commit=False)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once per worker process, after any fork.
    engine.dispose(close=False)      # abandon anything inherited; do not close it
    yield
    await engine.dispose()           # close this worker's own pool, loop still alive


app = FastAPI(lifespan=lifespan)

engine.dispose(close=False) is synchronous and safe to call before anything has connected — it simply replaces the pool. await engine.dispose() at shutdown closes this worker's connections properly, while the event loop is still running, which is what stops the garbage-collector warnings described in fixing garbage collector non-checked-in connection warnings.

Execution Context & Async Workflow Integration

A pooled connection is a TCP socket, and os.fork() duplicates file descriptors. The child gets a descriptor referring to the same connection: the same sequence numbers, the same TLS session state, the same PostgreSQL backend. Neither process knows the other exists, so two statements can be written to one wire and two readers can consume one response stream.

Two kinds of disposal Left: await engine.dispose() closes every pooled connection properly and is what a shutting-down process should do. Right: engine.dispose(close=False) abandons the pool without closing the connections, which is what a freshly forked child must do — closing them would tear down sockets the parent is still using. await engine.dispose() closes every pooled connection for a process that is exiting in the lifespan shutdown or a script finally block engine.dispose(close=False) abandons without closing for a child after fork() the parent keeps its sockets the child opens fresh ones Calling the wrong one in a forked child closes the parent’s connections underneath it.

The symptoms are memorable. With TLS, the session state diverges immediately and one side fails with decryption failed or bad record mac. Without TLS, asyncpg reports ProtocolViolationError, or — worst of all — one process receives rows belonging to the other's query. None of it is reproducible in a single-process development run, which is why this bug reaches production.

dispose(close=False) exists for exactly this. It discards the pool and replaces it with a fresh one, without closing the connections the old pool held, so the parent's sockets are left alone. The child then opens its own connections on first use. Calling plain dispose() in the child would close descriptors the parent is still using, which breaks the parent instead.

Which process models are affected comes down to one question: had the engine connected before the fork?

Gunicorn with --preload imports the application in the master and forks workers, so a module-level engine is created once and inherited. Nothing is broken until something connects pre-fork — a health check, a warm-up query, a migration check — and then everything is. Calling engine.dispose(close=False) at the start of each worker's lifespan makes it safe regardless.

Gunicorn or uvicorn without preload re-imports per worker, so each gets its own engine and nothing is shared.

Celery's pre-fork pool forks worker processes from a parent that has already imported the application. The worker_process_init signal runs in the child, which is where the engine must be discarded:

from celery.signals import worker_process_init

from shop.db import engine


@worker_process_init.connect
def _reset_engine(**_kwargs) -> None:
    engine.dispose(close=False)

multiprocessing with the fork start method has the same problem for any pool the parent touched; with spawn, the child re-imports and is unaffected.

There is a second, async-specific reason to dispose: an asyncpg connection is bound to the event loop that created it. A process that runs asyncio.run() more than once — a CLI with several commands, a test suite with function-scoped loops — must dispose the engine before the loop closes, or the next loop inherits connections it cannot use. That failure, and the NullPool answer for per-invocation loops, is covered in using NullPool for serverless and AWS Lambda.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
ssl.SSLError: [SSL] decryption failed or bad record macTwo processes sharing one TLS connection after a fork.engine.dispose(close=False) in the child.
asyncpg.exceptions.ProtocolViolationErrorThe same, without TLS.Same fix.
A query's results arrive in the wrong workerThe same shared socket.Same fix — and treat it as data-integrity critical.
OSError: [Errno 9] Bad file descriptor at shutdownThe parent closed a descriptor a child had already closed.Children must use close=False, never plain dispose().
The garbage collector is trying to clean up non-checked-in connection at exitThe engine was never disposed.await engine.dispose() in the lifespan shutdown.
RuntimeError: Event loop is closed during interpreter shutdownDisposal attempted after asyncio.run() returned.Dispose inside the coroutine, in a finally block.
got Future attached to a different loop in testsA session-scoped engine reused across function-scoped event loops.Dispose per loop, or scope the engine to the loop.
Where to create the engine Four tiles. A single async process creates it at import or in the lifespan and disposes it on shutdown. Gunicorn or uvicorn with preload forks after import, so an engine created at import is shared and must be discarded in the child. Uvicorn workers without preload each import fresh, so nothing is shared. And Celery pre-fork workers need the engine discarded in the worker_process_init signal. single async process engine at import dispose in lifespan shutdown gunicorn --preload forked after import discard in post_fork uvicorn --workers each worker imports nothing shared Celery pre-fork forked per worker worker_process_init The question is always the same: was the engine used before the fork?

The script case is worth its own snippet, because putting the disposal after asyncio.run() looks tidier and does not work:

import asyncio

from shop.db import engine
from shop.jobs import rebuild_search_index


async def main() -> None:
    try:
        await rebuild_search_index()
    finally:
        await engine.dispose()      # inside the loop


asyncio.run(main())
# NOT: asyncio.run(main()); asyncio.run(engine.dispose())  — a second loop,
# and the connections belong to the first one.

Tests hit the loop-binding problem most often, because pytest-asyncio's default event loop scope is per function while an engine fixture is usually per session. Two arrangements work: a session-scoped engine with a session-scoped event loop, or a function-scoped engine disposed in its own teardown. Mixing the two produces got Future attached to a different loop on the second test that touches the database:

import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine


@pytest_asyncio.fixture(scope="session")
async def engine(postgres_url):
    engine = create_async_engine(postgres_url)
    yield engine
    await engine.dispose()

With that fixture, the event loop must also be session-scoped — configured with asyncio_default_fixture_loop_scope = "session" in the pytest settings — which is the arrangement described in using pytest-asyncio fixtures with AsyncSession.

Advanced: Shutting Down Cleanly Under a Process Manager

Disposal on shutdown only helps if shutdown actually reaches the code. Under a process manager, that depends on signals, and the common failure is a worker killed before its lifespan shutdown runs — which leaves connections for the server to time out and, worse, transactions to roll back on their own schedule.

Three places to dispose Three bands. On shutdown, so pooled connections are closed while the event loop is alive; forgetting it produces garbage collector warnings and connections the server has to time out. In a forked child, so inherited sockets are abandoned rather than reused; forgetting it corrupts the protocol stream. And in scripts, inside the coroutine rather than after asyncio.run returns, because after the loop closes nothing can be awaited. shutdown: await engine.dispose() closes pooled connections while the loop is still running after fork: engine.dispose(close=False) in the child abandons inherited sockets instead of sharing or closing them scripts: inside the coroutine, in a finally block after asyncio.run() returns, the loop is closed and nothing can be awaited

Three things make the shutdown path reliable.

A grace period long enough for in-flight work. Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds, then sends SIGKILL. Uvicorn and gunicorn translate SIGTERM into a graceful shutdown: stop accepting connections, finish in-flight requests, run the lifespan shutdown. If the grace period is shorter than the longest request, the disposal is skipped. Thirty seconds is a common starting point; the requirement is "longer than your slowest request plus disposal".

Disposal that cannot hang. await engine.dispose() closes each pooled connection, and a connection whose server is unreachable can block. Bounding it keeps shutdown predictable:

import asyncio
import logging

log = logging.getLogger("shutdown")


async def dispose_with_timeout(engine, seconds: float = 5.0) -> None:
    try:
        async with asyncio.timeout(seconds):
            await engine.dispose()
    except TimeoutError:
        log.warning("engine disposal timed out; abandoning the pool")
        engine.dispose(close=False)     # give up on closing, but drop the pool

Background work finished before the engine goes. Anything the application started — a TaskGroup, a queue consumer, a scheduled refresh — is still holding sessions. Disposing the engine underneath it produces a burst of confusing errors in the logs on every deploy. Cancel and await those tasks first, then dispose:

from contextlib import asynccontextmanager

from fastapi import FastAPI

from shop.db import engine


@asynccontextmanager
async def lifespan(app: FastAPI):
    engine.dispose(close=False)
    tasks = start_background_tasks()
    try:
        yield
    finally:
        for task in tasks:
            task.cancel()
        for task in tasks:
            try:
                await task
            except asyncio.CancelledError:
                pass
        await dispose_with_timeout(engine)

The ordering is the point: stop the things that use the engine, then dispose the engine. Reversing it is how a clean deploy produces a page of InterfaceError: connection is closed in the logs, which then gets misdiagnosed as a database problem.

One last check worth having in the shutdown path: assert that nothing is still checked out. engine.sync_engine.pool.checkedout() should be zero before disposal, and logging it when it is not tells you which deploys are leaking sessions — the leak-hunting technique from detecting idle-in-transaction sessions from async code.

A Checklist for Every Entry Point

Engine lifecycle is one of those concerns that is correct in the main application and wrong in the three smaller entry points nobody thought about. A short checklist, applied to each one, is enough.

Import-time engines and preload Left: the engine is created at import and the server preloads the application before forking, so every worker inherits the same pool object; if the parent opened a connection during start-up, the children share the socket. Right: the engine is created in the lifespan, which runs once per worker after the fork, so each worker has its own pool and nothing is inherited. module-level engine + --preload engine created once, in the parent every worker inherits the pool shared sockets if it connected hard to reproduce locally engine created in the lifespan runs per worker, after the fork one pool per process nothing inherited and disposed on that worker’s shutdown A module-level engine is fine as long as nothing connects before the fork — which is hard to guarantee.

The web application. Engine created at import or in the lifespan; dispose(close=False) at the start of the lifespan so a preloading server is safe; await engine.dispose() at the end, after background tasks are cancelled; grace period longer than the slowest request.

Workers. For Celery's pre-fork pool, worker_process_init calls dispose(close=False); for an async worker, the same lifespan pattern as the web application. A worker that processes long jobs also wants a longer statement_timeout and a smaller pool than the API, as in configuring connect_args and server_settings.

Scripts and management commands. Disposal in a finally block inside the coroutine. A script that runs for seconds does not need a pool at all: poolclass=NullPool removes the question.

Tests. One engine per event loop, disposed in the fixture teardown, and an assertion that nothing is left checked out after each test.

Migrations. NullPool and an explicit await connectable.dispose(), which the async env.py template already does.

Two invariants make the checklist auditable rather than aspirational. First, create_async_engine should appear in exactly one module outside tests — a grep that returns one hit means every entry point shares the same construction and the same disposal helper. Second, a process should never fork after connecting; if the deployment uses --preload, the dispose(close=False) call in the lifespan makes that safe whether or not anything connected.

Finally, verify the behaviour rather than trusting it. A test that forks and queries from both processes reproduces the shared-socket bug in a second, and fails loudly if the child does not reset the pool:

import os

import pytest


@pytest.mark.asyncio
async def test_child_process_does_not_share_connections(engine):
    async with engine.connect() as conn:          # ensure the pool has a connection
        await conn.execute(text("SELECT 1"))

    pid = os.fork()
    if pid == 0:                                   # child
        os._exit(0 if _child_can_query(engine) else 1)
    _, status = os.waitpid(pid, 0)
    assert os.waitstatus_to_exitcode(status) == 0

Where _child_can_query calls engine.dispose(close=False), runs its own asyncio.run() with a query, and returns whether it succeeded. Removing the dispose(close=False) line should make that test fail — which is the proof that the guard is doing something.

Frequently Asked Questions

When should I use dispose(close=False)?

In a freshly forked child process, before it uses the engine. It drops the inherited pool without closing the sockets, which the parent is still using. Plain dispose() in a child would close the parent's connections.

Do I need to dispose the engine at shutdown?

Yes, with await engine.dispose() while the event loop is still running. Otherwise pooled connections are finalised by the garbage collector after the loop has closed, which logs warnings and leaves the server to time them out.

Is a module-level engine safe with gunicorn --preload?

Only if nothing connects before the fork, which is hard to guarantee. Calling engine.dispose(close=False) at the start of each worker's lifespan makes it safe either way.

Why does my test suite fail with "Future attached to a different loop"?

A session-scoped engine is being used across function-scoped event loops. Either scope the event loop to the session as well, or create and dispose the engine per test.