python yaml 114 lines · 4 tabs

Layered Pydantic Settings: Env Vars Over a YAML Defaults File

Shared by codesnips Aug 2026
4 tabs
from typing import Literal, Tuple, Type

from pydantic import BaseModel, PostgresDsn, RedisDsn
from pydantic_settings import (
    BaseSettings,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
)

from .yaml_source import YamlConfigSettingsSource


class DatabaseSettings(BaseModel):
    dsn: PostgresDsn
    pool_size: int = 5
    echo: bool = False
    password: str = ""


class RedisSettings(BaseModel):
    dsn: RedisDsn
    ttl_seconds: int = 300


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="APP_",
        env_nested_delimiter="__",
        env_file=".env",
        extra="ignore",
    )

    environment: Literal["local", "staging", "production"] = "local"
    debug: bool = False
    database: DatabaseSettings
    redis: RedisSettings

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: Type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> Tuple[PydanticBaseSettingsSource, ...]:
        yaml_config = YamlConfigSettingsSource(settings_cls)
        # First source wins: env vars override the YAML defaults.
        return (
            init_settings,
            env_settings,
            dotenv_settings,
            file_secret_settings,
            yaml_config,
        )
4 files · python, yaml Explain with highlit

This snippet builds a typed configuration system where values come from a YAML defaults file and are then overridden by environment variables, which is the classic 12-factor layering: sane defaults committed to the repo, secrets and per-environment overrides injected at runtime. It uses pydantic-settings, whose BaseSettings machinery makes source precedence explicit and gives every field real type coercion and validation.

In settings.py, the schema is split into nested models (DatabaseSettings, RedisSettings) grouped under a top-level AppSettings. Nesting keeps related keys together and, combined with the env_nested_delimiter="__", lets an env var like APP_DATABASE__POOL_SIZE map cleanly onto settings.database.pool_size. The env_prefix="APP_" namespaces every variable so the app never collides with unrelated environment state. Fields declare precise types (PostgresDsn, int, bool, Literal), so a malformed value fails loudly at startup instead of surfacing as a confusing runtime error later.

The layering itself lives in settings_customise_sources, an override hook pydantic-settings calls to let a project reorder its config sources. Sources are returned highest-precedence-first, so env_settings and dotenv_settings sit ahead of the custom yaml_config source, meaning any environment variable wins over the file. The yaml_config callable is a small PydanticBaseSettingsSource subclass in yaml_source.py that reads and caches the parsed YAML, exposing it as the lowest-priority dictionary.

Because AppSettings() validates on construction, get_settings in config.py wraps it in functools.lru_cache so the file is read and validated exactly once per process, turning settings into a cheap, shareable singleton that is easy to inject. The defaults.yaml tab shows the committed baseline; note it holds no secrets — the database password is expected to arrive via APP_DATABASE__PASSWORD.

The main trade-off is that env vars are stringly-typed, so nested overrides depend entirely on the delimiter convention being consistent; a typo in a variable name silently falls back to the default rather than erroring. Reaching for this pattern makes sense whenever an application must run identically across local, CI, and production with only environment differences.


Related snips

Share this code

Here's the card — post it anywhere.

Layered Pydantic Settings: Env Vars Over a YAML Defaults File — share card
Link copied