# Async SQLAlchemy Engine !!! info "Primary sources" - [Python `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) - [SQLAlchemy connections](https://docs.sqlalchemy.org/en/21/core/connections.html) - [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html) - [SQLAlchemy pooling and multiprocessing](https://docs.sqlalchemy.org/en/21/core/pooling.html#pooling-multiprocessing) - [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/) --- ## Engine Ownership Model Create one async engine per process per database URL and keep engine construction independent from FastAPI. - SQLAlchemy guidance: the engine is intended as a long-lived, concurrent registry over pooled DB connections, not a per-request object. - A cached function provides stable process-local engine identity without making framework state the only way to obtain it. - FastAPI lifespan starts and stops that independently defined resource; it does not contain the construction policy. !!! tip "Practical rule" - Exactly one `create_async_engine(...)` call in the cached engine factory. - Zero `create_async_engine(...)` calls in request handlers. - Zero calls to the cached factory from repository code. --- ## Cached Engine Factory Use [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) on a synchronous factory. Creating an `AsyncEngine` configures the dialect and pool; it does not need to await a database connection. ```python from functools import cache from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine @cache def get_engine(database_url: str) -> AsyncEngine: return create_async_engine( database_url, pool_pre_ping=True, ) async def dispose_engine(database_url: str) -> None: engine = get_engine(database_url) try: await engine.dispose() finally: get_engine.cache_clear() async def refresh_engine(database_url: str) -> AsyncEngine: await dispose_engine(database_url) return get_engine(database_url) ``` The database URL is an explicit, hashable cache key. Calls with the same URL return the same engine; a different URL receives a different engine. If engine options vary at runtime, make them explicit hashable arguments too. Resolve settings at the composition boundary and call `get_engine(settings.database_url)`. Do not hide settings lookup or engine creation inside feature code. ## Thin FastAPI Lifespan Wrapper The lifespan context manager only connects the cached resource to FastAPI ownership: ```python from collections.abc import AsyncGeneratorr from contextlib import asynccontextmanager from fastapi import FastAPI @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: database_url = app.state.settings.database_url engine = get_engine(database_url) app.state.engine = engine try: yield finally: await dispose_engine(database_url) app = FastAPI(lifespan=lifespan) ``` `dispose()` closes checked-in connections and replaces the pool, but it does not remove the Python object from `functools.cache`. `dispose_engine()` clears the cache even if driver cleanup raises, preventing a later lifespan run or test from retrieving that engine instance. This simple cleanup assumes one configured database URL per process. If a process intentionally owns several cached engines, use a small registry with per-key removal instead of clearing the whole cache. For a fixed engine, `try/finally` is sufficient; use `AsyncExitStack` when lifespan composes multiple conditional or dynamically acquired resources. When directly testing engine construction or lifespan behavior: - Call `get_engine.cache_clear()` before the test to remove process-local state. - Dispose any engine the test creates. - Clear the cache again during teardown, even when the test fails. --- ## Driver URLs (Project Requirement: asyncpg + aiosqlite) Use SQLAlchemy async driver URLs: - PostgreSQL: `postgresql+asyncpg://user:pass@host:5432/dbname` - SQLite: `sqlite+aiosqlite:///./app.db` !!! warning "Driver compatibility" - 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. - Call `get_engine()` from repositories instead of injecting their engine or session dependency. - Keep engine creation as a hidden side effect of import-time module globals. - Dispose a cached engine without clearing the cache during final teardown. - Use deprecated FastAPI startup/shutdown events together with lifespan. --- ## Engine Design Checklist - One engine per process per DB URL. - Engine created by one cached, framework-independent factory. - Lifespan only retrieves, exposes, disposes, and uncaches the engine. - Async driver URL matches backend (`asyncpg` or `aiosqlite`). - Pooling strategy is explicit for non-default needs. - No request-path engine creation. - Tests dispose engines and clear cached state deterministically.