Loading .env files with python-dotenv and pydantic-settings precedence rules: working versions (tested 2026-10-15)
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.
passed5 passed · 0 failed2026-10-15 13:25 UTC- Status
- passed
- Last passed
- 2026-10-15 13:25 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 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("python-dotenv-pydantic-settings-env") (install), or see them on GitHub.
python-dotenv==1.2.4 pydantic-settings==2.16.0 pydantic==2.14.1 pytest==9.1.1
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
# Source priority (highest first): init kwargs, real env vars, env_file(s), defaults.
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")
database_url: str = "sqlite:///:memory:"
debug: bool = False
api_key: SecretStr | None = None
workers: int = Field(default=1, ge=1)
import os
import pytest
from dotenv import dotenv_values, load_dotenv
from pydantic import ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
from settings import AppSettings
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in [k for k in os.environ if k.startswith("APP_")]:
monkeypatch.delenv(key)
yield
# load_dotenv writes to os.environ directly, which monkeypatch does not undo
for key in [k for k in os.environ if k.startswith("APP_")]:
os.environ.pop(key)
def write(tmp_path, name, text):
path = tmp_path / name
path.write_text(text)
return path
def test_precedence_kwargs_over_env_over_dotenv(tmp_path, monkeypatch):
env = write(tmp_path, ".env", "APP_DEBUG=false\n")
assert AppSettings(_env_file=env).debug is False
monkeypatch.setenv("APP_DEBUG", "true") # real env var beats the file
assert AppSettings(_env_file=env).debug is True
assert AppSettings(_env_file=env, debug=False).debug is False # init kwargs beat all
def test_later_env_file_wins(tmp_path):
base = write(tmp_path, "base.env", "APP_WORKERS=2\nAPP_DATABASE_URL=sqlite:///base.db\n")
local = write(tmp_path, "local.env", "APP_WORKERS=8\n")
s = AppSettings(_env_file=(base, local))
assert s.workers == 8
assert s.database_url == "sqlite:///base.db"
def test_invalid_value_raises(tmp_path):
env = write(tmp_path, ".env", "APP_WORKERS=0\n")
with pytest.raises(ValidationError):
AppSettings(_env_file=env)
class Strict(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", extra="forbid")
debug: bool = False
def test_typo_key_rejected_when_extra_forbidden(tmp_path):
env = write(tmp_path, ".env", "APP_DEGUB=true\n")
with pytest.raises(ValidationError):
Strict(_env_file=env)
def test_load_dotenv_does_not_override_existing_env(tmp_path, monkeypatch):
env = write(tmp_path, ".env", "APP_DEBUG=false\nAPP_NEW=1\n")
monkeypatch.setenv("APP_DEBUG", "true")
load_dotenv(env) # override=False by default
assert os.environ["APP_DEBUG"] == "true"
assert os.environ["APP_NEW"] == "1"
assert dotenv_values(env)["APP_DEBUG"] == "false" # parsed, not exported
[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
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.
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 python-dotenv recipesAll Pydantic Settings recipesAll Pydantic recipes