pydantic-settings nested dotenv config with SecretStr masking: working versions (tested 2026-10-15)
Compose nested pydantic-settings models from .env files, mask credentials with SecretStr, and pin down env precedence and JSON parsing offline.
passed6 passed · 0 failed2026-10-15 18:10 UTC- Status
- passed
- Last passed
- 2026-10-15 18:10 UTC
- Tests
- 6 / 6
- 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 38783276983
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pydantic-settings-dotenv-secrets") (install), or see them on GitHub.
pydantic-settings==2.16.0 python-dotenv==1.2.4 pytest==9.1.1
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Database(BaseModel):
host: str = "localhost"
port: int = 5432
user: str
password: SecretStr
class Cache(BaseModel):
url: str = "redis://localhost:6379/0"
ttl: int = 60
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_nested_delimiter="__",
env_file=".env",
extra="ignore",
)
debug: bool = False
allowed_hosts: list[str] = []
database: Database
cache: Cache = Cache()
# Copy to .env. The __ delimiter maps keys onto nested settings. APP_DEBUG=true APP_ALLOWED_HOSTS=["api.example.com"] APP_DATABASE__HOST=db.internal APP_DATABASE__PORT=6543 APP_DATABASE__USER=svc APP_DATABASE__PASSWORD=change-me APP_CACHE__TTL=120
import os
import pytest
from pydantic import SecretStr, ValidationError
from settings import Settings
ENV = """\
# app config loaded from a dotenv file
APP_DEBUG=true
APP_DATABASE__HOST=db.internal
APP_DATABASE__PORT=6543
APP_DATABASE__USER=svc
APP_DATABASE__PASSWORD="p@ss word"
APP_CACHE__TTL=120
"""
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in list(os.environ):
if key.startswith("APP_"):
monkeypatch.delenv(key, raising=False)
@pytest.fixture
def env_file(tmp_path):
path = tmp_path / ".env"
path.write_text(ENV)
return path
def load(env_file):
return Settings(_env_file=env_file)
def test_loads_nested_values(env_file):
s = load(env_file)
assert s.debug is True
assert s.database.host == "db.internal"
assert s.database.port == 6543
assert s.cache.ttl == 120
def test_secret_str_masks_but_keeps_value(env_file):
s = load(env_file)
assert isinstance(s.database.password, SecretStr)
assert "p@ss word" not in repr(s)
assert str(s.database.password) == "**********"
assert s.database.password.get_secret_value() == "p@ss word"
def test_dumps_do_not_leak_secret(env_file):
s = load(env_file)
assert isinstance(s.model_dump()["database"]["password"], SecretStr)
assert s.model_dump(mode="json")["database"]["password"] == "**********"
def test_env_var_overrides_dotenv(env_file, monkeypatch):
monkeypatch.setenv("APP_DATABASE__HOST", "from-env")
assert load(env_file).database.host == "from-env"
def test_missing_required_secret_raises(monkeypatch):
monkeypatch.setenv("APP_DATABASE__USER", "svc")
with pytest.raises(ValidationError) as exc:
Settings(_env_file=None)
assert "database" in str(exc.value)
def test_complex_values_need_json(monkeypatch):
monkeypatch.setenv("APP_DATABASE__USER", "svc")
monkeypatch.setenv("APP_DATABASE__PASSWORD", "y")
monkeypatch.setenv("APP_ALLOWED_HOSTS", '["a.com","b.com"]')
assert Settings(_env_file=None).allowed_hosts == ["a.com", "b.com"]
[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.
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
Pydantic Settings typed config from env and .env
Typed app configuration with pydantic-settings: env prefixes, nested settings, secrets and validation errors.
pydantic-settings v2 config from env, YAML and defaults with SecretStr
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.