From a238fb4dc366958a330384cecefa58fbb1e571cb Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:29:05 -0500 Subject: [PATCH] example of 2 database backends --- docs/skills/pydantic-settings/SKILL.md | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/docs/skills/pydantic-settings/SKILL.md b/docs/skills/pydantic-settings/SKILL.md index 8bd1e60..2d6aa11 100644 --- a/docs/skills/pydantic-settings/SKILL.md +++ b/docs/skills/pydantic-settings/SKILL.md @@ -203,11 +203,124 @@ Use independent `BaseSettings` classes when the objects have genuinely independe Construct independent objects explicitly at the composition root and inject each dependency. Do not nest one `BaseSettings` class inside another merely to reuse its fields. Extract a shared `BaseModel` schema when models need common structure. +### Alternative Database Backends + +When one application can run against one of several database backends, model the selected backend as a [discriminated union](https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions). Pydantic validates only the variant selected by `driver`, so required PostgreSQL values do not make a SQLite configuration fail, and vice versa. + +```python +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class SqliteSettings(BaseModel): + driver: Literal["sqlite"] = "sqlite" + path: str = "app.db" + + +class PostgresSettings(BaseModel): + driver: Literal["postgres"] = "postgres" + host: str + port: int = 5432 + database: str + user: str + password: SecretStr + + +DatabaseSettings = Annotated[ + SqliteSettings | PostgresSettings, + Field(discriminator="driver"), +] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="APP_", + env_nested_delimiter="__", + env_file=".env", + extra="ignore", + frozen=True, + ) + + database: DatabaseSettings +``` + +Choose one configuration. A SQLite deployment requires no PostgreSQL variables: + +```dotenv +APP_DATABASE__DRIVER=sqlite +APP_DATABASE__PATH=./data/app.db +``` + +A PostgreSQL deployment requires only the PostgreSQL branch: + +```dotenv +APP_DATABASE__DRIVER=postgres +APP_DATABASE__HOST=db.internal +APP_DATABASE__PORT=5432 +APP_DATABASE__DATABASE=app +APP_DATABASE__USER=app_user +APP_DATABASE__PASSWORD=provided-by-the-runtime +``` + +After settings validation, select an async SQLAlchemy driver URL. This is a pure configuration step; create the engine, session factory, and sessions in their own lifecycle-managed providers: + +```python +from functools import cache + +from sqlalchemy import URL +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + + +def get_database_url( + settings: Settings, +) -> str: + match settings.database: + case SqliteSettings(path=path): + url = URL.create( + drivername="sqlite+aiosqlite", + database=path, + ) + case PostgresSettings() as database: + url = URL.create( + drivername="postgresql+asyncpg", + host=database.host, + port=database.port, + database=database.database, + user=database.user, + password=database.password.get_secret_value(), + ) + return url.render_as_string(hide_password=False) + + +@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() +``` + +At the composition boundary, resolve the URL once with `get_database_url(settings)` and use it to retrieve the cached engine. In FastAPI, expose that engine through lifespan and build one `async_sessionmaker` from it; each request or unit of work then creates its own `AsyncSession`. Do not call `aiosqlite.connect()` or `asyncpg.create_pool()` directly: `aiosqlite` and `asyncpg` are selected as SQLAlchemy drivers by the URL, while SQLAlchemy owns pooling, disposal, and session integration. + +The nested variants remain `BaseModel` classes. `Settings` is the only `BaseSettings` model and therefore the only object that reads environment variables, dotenv files, or secrets. This keeps one source policy and validated configuration snapshot while keeping the engine, session factory, and sessions in their distinct lifecycles. See the [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html), the [engine lifecycle guidance](../async-fastapi-sqlmodel/references/engine.md), and the [session lifecycle guidance](../async-fastapi-sqlmodel/references/session.md). + Quality gate: 1. Nested sections share one source policy and lifecycle. 2. Independent settings have distinct owners, prefixes, or lifecycles. 3. The application does not repeatedly scan the same sources through accidental nested `BaseSettings` construction. +4. Each backend configuration validates without values required only by another backend. +5. One cached `AsyncEngine` exists per configured driver URL, while each request or unit of work receives a new `AsyncSession`. ### 7. Own The Settings Lifecycle