pydantic-settings v2 config from env, YAML and defaults with SecretStr: working versions (tested 2026-10-15)
Nested pydantic-settings models loaded with precedence init > env > YAML > defaults, with env_nested_delimiter, SecretStr masking, and offline tests using tmp_path and monkeypatch.
passed5 passed · 0 failed2026-10-15 13:32 UTC- Status
- passed
- Last passed
- 2026-10-15 13:32 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pydantic-settings-yaml-secrets") (install), or see them on GitHub.
pydantic-settings==2.16.0 pydantic==2.14.1 python-dotenv==1.2.2 pyyaml==6.0.3 pytest==9.1.1
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
YamlConfigSettingsSource,
)
class DbSettings(BaseModel):
url: str = "sqlite:///app.db"
password: SecretStr = SecretStr("")
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_", env_nested_delimiter="__", extra="ignore",
)
debug: bool = False
api_key: SecretStr = SecretStr("dev-key")
# default_factory avoids sharing one mutable default instance across objects
db: DbSettings = Field(default_factory=DbSettings)
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
) -> tuple[PydanticBaseSettingsSource, ...]:
# first source wins: init > env > yaml > field defaults
return (init_settings, env_settings, YamlConfigSettingsSource(settings_cls))
def load_settings(yaml_file: Path | None = None, **init: Any) -> AppSettings:
# a subclass per call lets each test point the YAML source at its own file
cls = type(
"LoadedSettings",
(AppSettings,),
{"model_config": AppSettings.model_config | {"yaml_file": yaml_file}},
)
return cls(**init)
import os
import pytest
from settings import load_settings
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
# stop APP_* variables from the developer's shell leaking into tests
for key in list(os.environ):
if key.startswith("APP_"):
monkeypatch.delenv(key)
@pytest.fixture
def yaml_file(tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"debug: true\n"
"api_key: yaml-key\n"
"db:\n"
" url: postgresql://yaml/db\n"
" password: yaml-pass\n"
)
return p
def test_defaults_when_nothing_set():
s = load_settings()
assert s.debug is False
assert s.db.url == "sqlite:///app.db"
def test_yaml_overrides_defaults(yaml_file):
s = load_settings(yaml_file)
assert s.debug is True
assert s.db.url == "postgresql://yaml/db"
def test_env_beats_yaml_and_merges_nested(yaml_file, monkeypatch):
monkeypatch.setenv("APP_DB__URL", "sqlite:///env.db")
s = load_settings(yaml_file)
assert s.db.url == "sqlite:///env.db"
# sibling nested key from yaml survives: sources are deep-merged
assert s.db.password.get_secret_value() == "yaml-pass"
def test_init_beats_env(yaml_file, monkeypatch):
monkeypatch.setenv("APP_DEBUG", "true")
s = load_settings(yaml_file, debug=False)
assert s.debug is False
def test_secrets_are_masked(yaml_file):
s = load_settings(yaml_file)
assert s.api_key.get_secret_value() == "yaml-key"
assert "yaml-pass" not in repr(s)
assert "yaml-pass" not in str(s.model_dump(mode="json"))
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Loading .env files with python-dotenv and pydantic-settings precedence rules
Typed app settings from .env files with pydantic-settings, covering source precedence, layered env files, extra-key rules and python-dotenv override behavior, tested offline with tmp_path files.
Pydantic Settings typed config from env and .env
Typed app configuration with pydantic-settings: env prefixes, nested settings, secrets and validation errors.
FastAPI + Pydantic v2 validators, discriminated unions and serializers
Pydantic v2 patterns in FastAPI that agents often get wrong: field_validator, model_validator, discriminated unions, computed_field and response_model filtering.
FastAPI background tasks + dependency overrides
FastAPI BackgroundTasks for post-response work, with dependency_overrides to swap services in tests.
All Pydantic Settings recipesAll Pydantic recipesAll pyyaml recipes