Files
prompts/docs/skills/fastapi-async-sqlalchemy-modernization/references/engine.md
T
John Lancaster e78383be1f move
2026-06-18 22:06:40 -05:00

4.4 KiB

Async SQLAlchemy Engine

Source:


Engine Ownership Model

Create one async engine per process per database URL and keep it for the app lifetime.

  • SQLAlchemy guidance: the engine is intended as a long-lived, concurrent registry over pooled DB connections, not a per-request object.
  • In FastAPI, app startup and shutdown ownership belongs in lifespan.
  • Use FastAPI(lifespan=...) (not startup/shutdown events) for modern lifecycle wiring.

Practical rule:

  • Exactly one create_async_engine(...) call in app bootstrap code.
  • Zero create_async_engine(...) calls in request handlers.

Canonical Lifespan Pattern (AsyncExitStack)

Use @asynccontextmanager + AsyncExitStack to make teardown deterministic and composable.

from contextlib import AsyncExitStack, asynccontextmanager

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


@asynccontextmanager
async def lifespan(app: FastAPI):
    async with AsyncExitStack() as stack:
        engine: AsyncEngine = create_async_engine(
            app.state.settings.database_url,
            pool_pre_ping=True,
            # Optional examples:
            # echo=app.state.settings.sql_echo,
            # pool_size=10,
            # max_overflow=20,
        )
        app.state.engine = engine

        # Ensure engine disposal always runs at shutdown.
        stack.push_async_callback(engine.dispose)

        yield


app = FastAPI(lifespan=lifespan)

Why this pattern:

  • FastAPI executes code before yield at startup and after yield at shutdown.
  • AsyncExitStack lets you register multiple async cleanups in one place while preserving order.
  • Explicit disposal (directly awaited or via AsyncExitStack callback) avoids event-loop-closed warnings when objects fall out of scope.

Driver URLs (Project Requirement: asyncpg + aiosqlite)

Use SQLAlchemy async driver URLs:

  • PostgreSQL: postgresql+asyncpg://user:pass@host:5432/dbname
  • SQLite: sqlite+aiosqlite:///./app.db

Notes:

  • Do not mix sync drivers (for example psycopg2) with create_async_engine().
  • Keep URL construction centralized in settings/config, not in feature modules.

Pooling Defaults and Tuning

Default behavior is usually correct first:

  • Async engines use async-compatible pooling (AsyncAdaptedQueuePool) by default.
  • Start with defaults, then tune from observed load (pool_size, max_overflow, pool_timeout, pool_recycle).
  • Enable pool_pre_ping=True for safer stale-connection handling in long-running services.

When to switch pool strategy:

  • NullPool if you explicitly need no pooling (special environments, some tests, or strict cross-loop constraints).
  • Keep in mind this increases connect/disconnect churn.

Disposal Semantics

engine.dispose() replaces/disposes the pool, but only checked-in connections are immediately closed.

Rules:

  • Dispose when the app is shutting down.
  • Dispose before reusing an engine across event loops.
  • In forked child-process initialization, use engine.dispose(close=False) (sync API guidance) so child processes do not touch parent-held connections.

Avoid relying on garbage collection for engine cleanup in async code.


Event Loop and Process Boundaries

Do not share pooled connections across boundaries:

  • Multiple event loops: do not reuse the same pooled async engine across loops unless you intentionally disable pooling (NullPool) or dispose before handoff.
  • Multiprocessing/fork: pooled connections must not be inherited for active use across process boundaries.

This prevents broken socket state and cross-process connection corruption.


What Not to Do

  • Create an engine inside every request dependency.
  • Create/dispose engines inside repository methods.
  • Keep engine creation as a hidden side effect of import-time module globals.
  • Use deprecated FastAPI startup/shutdown events together with lifespan.

Engine Design Checklist

  • One engine per process per DB URL.
  • Engine created in lifespan startup.
  • Engine disposed in lifespan shutdown.
  • Async driver URL matches backend (asyncpg or aiosqlite).
  • Pooling strategy is explicit for non-default needs.
  • No request-path engine creation.