← Back to the section

When a service receives SIGTERM, it must stop cleanly: let requests finish, flush buffers, close database connections. If you close the connection pool too early, a background task that hasn't finished its commit yet will get InterfaceError: connection already closed. Too late, and connections leak and the database keeps them open for no reason.

Let's break down how to do it right.

engine.dispose() is called last

The connection pool is a shared resource used by both HTTP handlers and background tasks. You should close the pool only when everyone using it has already finished their work.

FastAPI manages the lifecycle through lifespan — an async context manager. The shutdown section runs in the order it is written:

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

engine = create_async_engine(
    "postgresql+asyncpg://...",
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup
    yield
    # shutdown — order matters
    await _stop_background_tasks()   # 1. finish background tasks
    await _stop_kafka()              # 2. stop producer/consumer
    await engine.dispose()           # 3. close the pool — last


app = FastAPI(lifespan=lifespan)

With the asyncpg engine, engine.dispose() closes all idle connections and waits for active ones to return to the pool. By this point they should already have been returned — background tasks were finished in step 1. If a connection never comes back, the pool closes with a warning but won't hang.

Don't add a separate signal.signal(SIGTERM, ...) for engine.dispose() — uvicorn already calls the lifespan shutdown when it receives SIGTERM, and duplicating it will only confuse the ordering.

What happens to active transactions on SIGTERM

Different kinds of code finish transactions differently.

HTTP handler

When an HTTP handler is inside a transaction at the moment SIGTERM is received, uvicorn gives it time to finish (the --timeout-graceful-shutdown option, 30 seconds by default). A session opened via async with session_factory() will automatically commit on success or roll back on exception through __aexit__.

async def get_session() -> AsyncSession:
    async with session_factory() as session:
        yield session


@router.post("/orders")
async def create_order(
    body: CreateOrderRequest,
    session: AsyncSession = Depends(get_session),
) -> OrderResponse:
    order = Order(customer_id=body.customer_id, total=body.total)
    session.add(order)
    await session.commit()
    return OrderResponse.model_validate(order)

If the timeout expires, uvicorn breaks the connection and __aexit__ performs a rollback.

Background task

Background tasks finish via a readiness flag. The loop must check this flag to gracefully stop after the current iteration:

async def process_outbox():
    while app_state.is_ready:
        async with session_factory() as session:
            async with session.begin():
                rows = await session.execute(
                    select(OutboxEvent)
                    .where(OutboxEvent.status == "pending")
                    .limit(20)
                    .with_for_update(skip_locked=True)
                )
                batch = rows.scalars().all()
                for event in batch:
                    await publish_event(event)
                    event.status = "published"
        await asyncio.sleep(1)

On SIGTERM app_state.is_ready turns False, the task finishes the current iteration, the transaction commits, and the loop exits. Don't use while True — without a flag the task won't stop.

Forced cancellation with CancelledError

If a task didn't manage to finish on its own, it is cancelled via cancel():

async def _stop_background_tasks():
    for task in _background_tasks:
        task.cancel()
        try:
            await asyncio.wait_for(task, timeout=25.0)
        except (asyncio.CancelledError, asyncio.TimeoutError):
            pass

Inside the task itself, on CancelledError the transaction rolls back automatically through the context manager's __aexit__ — this is fine:

async def sync_product_catalog():
    async with session_factory() as session:
        async with session.begin():
            try:
                products = await fetch_external_catalog()
                session.add_all(products)
            except asyncio.CancelledError:
                # the transaction will roll back through __aexit__, this is fine
                raise

Kafka consumer

If a Kafka consumer processes messages inside a transaction, stopping works like this: consumer.stop() in lifespan waits for the current iteration to finish, the transaction commits, then the offset is committed. If SIGTERM catches it midway, the transaction rolls back, the offset is not committed, and on the next start the message will be processed again. Protection against duplication is provided by the processed-events table:

async def consume_order_events():
    async for msg in consumer:
        async with session_factory() as session:
            async with session.begin():
                await session.execute(
                    insert(ProcessedEvent).values(event_id=msg.key)
                    .on_conflict_do_nothing()
                )
                order = await session.get(Order, msg.value["order_id"])
                order.status = msg.value["status"]
        await consumer.commit()

Alembic runs only at startup

A common misconception: "we need to clean up the schema on exit" or "run alembic downgrade on shutdown." That's not a pattern.

Alembic applies migrations at application startup and after that simply closes its own connections. On shutdown Alembic does nothing — and that's correct:

@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup: apply migrations
    await run_migrations()  # alembic upgrade head
    yield
    # shutdown: only closing resources, no DDL
    await engine.dispose()

Don't add any DDL to the shutdown section.

Common mistakes

engine.dispose() at the start of shutdown. If you call dispose() before cancelling background tasks, the tasks lose access to the database in the middle of a transaction. Dispose always goes last.

signal.signal(SIGTERM, ...) for dispose in the module body. This creates a parallel shutdown channel that bypasses lifespan. uvicorn already handles SIGTERM and calls lifespan — an additional handler is not needed.

while True in a background loop. Without checking the readiness flag the task won't stop on command. Use while app_state.is_ready.

alembic downgrade on shutdown. That's not a pattern. Shutdown is about closing resources, not rolling back the schema.

Logging the pool closing as an error. The message asyncpg pool closed is a normal shutdown event, not an error. Use the INFO level.

In short

  • engine.dispose() is called last in the lifespan shutdown — after background tasks and Kafka.
  • HTTP transactions finish via uvicorn graceful (--timeout-graceful-shutdown).
  • Background tasks stop via a readiness flag (app_state.is_ready), not while True.
  • CancelledError in a task — the transaction rolls back automatically through __aexit__.
  • Alembic works only at startup, no DDL on shutdown.
  • async with session_factory() always commits on success and rolls back on exception — manual management is not needed.
  • HTTP drain — uvicorn graceful, preStop sleep, long endpoints.
  • Background tasks and outbox — asyncio tasks, APScheduler, CancelledError.
  • Kafka shutdown — aiokafka consumer and producer, manual commit.
  • Budgets and observability — breakdown of the 60s budget, shutdown metrics.