Introduction: os.environ["..."] is production-config debt
Code that reads config with os.environ["DATABASE_URL"] each time looks simple at a glance but always breaks down in production. Four reasons.
- The type is fixed to
str:os.environ["MAX_CONNECTIONS"]is a string. You writeint(...)conversion every time you read it, and forget it and it's a bug. - No existence guarantee: even if you forget to set an environment variable, no one notices until the moment you actually use the value. It dies with
KeyErrorlate at night in production. - Defaults scatter:
os.environ.get("DEBUG", "false")is sprinkled everywhere and you lose track of which is correct (DRY violation). - Secrets leak into logs: the moment you
print/ log the config object, API keys and passwords spill in plaintext.
pydantic-settings structurally solves these four. It consolidates config into a typed single model, auto-loads it from environment variables, .env, secret files, and the cloud's Secret Manager, and stops with a ValidationError at startup if a required item is missing (Fail Fast). This is the standard to realize the 12-factor App principle of "store config in the environment" with both type safety and non-leakage of secrets.
This article, faithful to the official documentation (pydantic-settings 2.14.x) and one notch more understandably, explains from the minimal configuration to production secret management and cloud integration with real code. For the basics of Pydantic itself, see the Pydantic v2 practical guide, and for the validation logic of config values, also see advanced types / custom validators.
1. The minimal config model: Fail-Fast at startup
BaseSettings is a separate package, not the pydantic body (separated in v2).
pip install pydantic-settings
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_")
database_url: str # APP_DATABASE_URL(必須)
debug: bool = False # APP_DEBUG("1"/"true" を bool へ型強制)
max_connections: int = Field(default=10, gt=0) # APP_MAX_CONNECTIONS
# アプリ起動時に一度だけ生成。必須項目が欠けていればここで即座に失敗する
settings = Settings()
⚠️ Note the import source:
BaseSettings/SettingsConfigDictarefrom pydantic_settings import ..., whileSecretStr/Field/AliasChoicesarefrom pydantic import ....from pydantic import BaseSettingsis the v1 way and doesn't work in v2.
Why is this superior?
Call Settings() once at app startup and the app can't start in a state where config isn't complete (Fail Fast). settings.max_connections is statically known as int, and a typo like settings.databse_url is detected by the type checker. debug: bool converts an environment-variable string like "1" / "true" to bool by type coercion. The biggest value is being able to detect a config mistake before deploy, not "noticing it only in production."
2. .env and complex types: read nested config
SettingsConfigDict finely controls loading behavior.
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class RedisConfig(BaseModel):
host: str
port: int = 6379
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", # .env を読む
env_file_encoding="utf-8",
env_prefix="APP_", # APP_ プレフィックスで名前衝突を防ぐ
case_sensitive=False, # 既定。大文字小文字を無視
env_nested_delimiter="__", # ネストを FOO__BAR で表現
extra="ignore", # 余分な環境変数を無視(forbid だと起動失敗)
)
allowed_hosts: list[str] = [] # JSON としてパースされる
redis: RedisConfig
The main options and how to read them from environment variables are as follows.
| Setting | Effect |
|---|---|
env_file | the path of the .env file |
env_prefix | the prefix of environment-variable names (APP_DATABASE_URL, etc.) |
env_nested_delimiter | the delimiter of nested models (APP_REDIS__HOST) |
case_sensitive | whether to distinguish case (default False) |
extra | the handling of unknown environment variables (ignore / forbid / allow) |
Complex types like list / dict / nested models parse the environment variable as JSON.
export APP_ALLOWED_HOSTS='["a.com", "b.com"]' # JSON 配列
export APP_REDIS='{"host": "cache", "port": 6380}' # JSON オブジェクト、または↓
export APP_REDIS__HOST=cache # env_nested_delimiter で個別指定
export APP_REDIS__PORT=6380
💡 JSON and delimiter can be combined: you can pass the whole JSON with
APP_REDISwhile overriding only part withAPP_REDIS__PORT. Don't include the delimiter (__) in a field name (parsing breaks).
3. Protect secrets: SecretStr and secrets_dir
The most accident-prone thing in config management is secret leakage. Hold an API key or password as a plain str and there's a risk of plaintext exposure in all of log output, exception traces, and model_dump(). SecretStr structurally prevents this.
from pydantic import SecretStr
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_key: SecretStr
db_password: SecretStr
settings = Settings()
print(settings.api_key) # ********** ← マスクされる
print(settings.model_dump()) # {'api_key': SecretStr('**********'), ...}
print(settings.api_key.get_secret_value()) # 実際の値(明示的に取り出した時だけ)
A value wrapped with SecretStr is masked as ********** in all of repr / str / serialization, and the actual value is retrievable only when you explicitly call .get_secret_value(). The accident of "accidentally logging it" stops happening at the type level.
H3: Docker / Kubernetes secrets are secrets_dir
In a container environment, the best practice is to mount secrets as files, not environment variables (because environment variables are visible with docker inspect, etc.). secrets_dir reads files with the same name as the field name inside the specified directory.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# /var/run/database_password というファイルの中身が database_password に入る
model_config = SettingsConfigDict(secrets_dir="/var/run")
database_password: str
Mount a Kubernetes Secret under /var/run and you can safely inject the secret without changing the code at all.
⚠️
secrets_dir's behavior: the filename must match the field name (or alias). If there's no such file, it's silently skipped and falls back to the next source (environment variables, etc.). To avoid the accident of a secret being empty in production, make it a required field and have the absence detected at startup.
4. Config priority: grasp the 6-level rule
When the same setting is specified by multiple sources, which wins. The priority the official defines is 6 levels in descending order.
| Priority | Source | Typical use |
|---|---|---|
| 1 (strongest) | CLI args (when cli_parse_args is enabled) | temporary override, operational commands |
| 2 | init args to Settings(...) | explicit injection in tests |
| 3 | environment variables | production/staging config |
| 4 | .env file | local development |
| 5 | files in secrets_dir | container secrets |
| 6 (weakest) | the model's default values | fallback |
Understand this order and operations like "override the local .env with environment variables in production," "override everything with init args in tests" become predictable. The order itself can be reordered with settings_customise_sources in the next chapter.
5. Extend the sources: JSON/TOML/YAML and cloud Secret Manager
Config sources aren't only environment variables. Override settings_customise_sources and you can compose/reorder any source.
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
TomlConfigSettingsSource,
)
class Settings(BaseSettings):
model_config = SettingsConfigDict(toml_file="config.toml")
app_name: str
workers: int
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
# タプルの順 = 優先順位(先頭が最強)。環境変数 → TOML の順で解決する
return (env_settings, TomlConfigSettingsSource(settings_cls))
The built-in source classes are complete: EnvSettingsSource / DotEnvSettingsSource / SecretsSettingsSource / JsonConfigSettingsSource / TomlConfigSettingsSource / YamlConfigSettingsSource / PyprojectTomlConfigSettingsSource / CliSettingsSource.
H3: Read AWS / Azure / GCP Secret Manager directly
pydantic-settings officially bundles integration with the major clouds' Secret Managers.
import os
from pydantic_settings import (
AWSSecretsManagerSettingsSource,
BaseSettings,
PydanticBaseSettingsSource,
)
class Settings(BaseSettings):
db_password: str
api_key: str
@classmethod
def settings_customise_sources(cls, settings_cls, init_settings, env_settings,
dotenv_settings, file_secret_settings):
aws = AWSSecretsManagerSettingsSource(
settings_cls, os.environ["AWS_SECRET_ID"]
)
return (init_settings, env_settings, aws)
| Cloud | Source class | extra |
|---|---|---|
| AWS | AWSSecretsManagerSettingsSource | pydantic-settings[aws-secrets-manager] |
| Azure | AzureKeyVaultSettingsSource | pydantic-settings[azure-key-vault] |
| GCP | GoogleSecretManagerSettingsSource | pydantic-settings[gcp-secret-manager] |
💡 Why this works architecturally: you no longer need to write a "deploy script that copies secrets to environment variables"; the app reads typed directly from the Secret Manager at startup. Secret rotation reflects without app-side code changes, and you can enforce least privilege with IAM. It embodies CLAUDE.md's security principles of "don't hardcode secrets / least privilege" at the config layer. Note that each cloud SDK (
boto3, etc.) is needed, so always install the corresponding extra.
6. CLI: grow a command-line tool from the config model
From the same config model, you can even handle command-line argument parsing. It's handy for batches and operational scripts.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(cli_parse_args=True)
workers: int = 4
verbose: bool = False
# python app.py --workers 8 --verbose で起動できる
settings = Settings()
print(settings.model_dump())
For more structured CLIs (subcommands, etc.), use CliApp.
from pydantic_settings import CliApp
settings = CliApp.run(Settings, cli_args=["--workers", "8"])
CLI args are the top of priority (chapter 4), so they're suited for operations that temporarily override environment variables and defaults. Note the difference that nested fields are specified with dot notation (--redis.port 6380) on the CLI and __ delimiter on environment variables.
7. Tests and best practices
H3: Singleton-ize (lru_cache)
Settings() reads the sources every time. To generate it only once across the app, singleton-izing with lru_cache is the standard (with FastAPI, combine with Depends).
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
H3: Override in tests
For config tests, direct injection by init args is the cleanest (it doesn't pollute the process's environment variables, so it's safe even in parallel tests). Use monkeypatch only when you want to verify the environment-loading path itself.
def test_uses_injected_config():
# 環境に依存せず、値を直接注入して上書き(優先順位2位=環境変数より強い)
settings = Settings(database_url="sqlite://", max_connections=1)
assert settings.max_connections == 1
def test_reads_from_env(monkeypatch):
monkeypatch.setenv("APP_MAX_CONNECTIONS", "20") # プレフィックス付きで設定
assert Settings().max_connections == 20
💡 The manner of per-environment config: stack multiple
.envlikeenv_file=(".env", f".env.{os.environ.get('ENV', 'local')}"), or splitSettingssubclasses per environment. Either way, don't break the principle that "the single source of truth of config is one typed model." Config testability is dug into further in Pydantic testing strategy.
Conclusion: make config "part of the type system"
pydantic-settings lifts the plain but accident-prone area of config management to production quality on three points: type safety, Fail Fast, and secret protection. Restating the key points of this article.
- Consolidate config into a typed model with
BaseSettings(a separate package) and Fail-Fast at startup. - Read flexibly with
.env/env_prefix/env_nested_delimiter, and parse complex types as JSON. - Auto-mask secrets with
SecretStr, and in containers read from files withsecrets_dir. - Priority is 6 levels (CLI > init > env > .env > secrets > default). Reorder/extend with
settings_customise_sources. - Compose JSON/TOML/YAML and cloud Secret Manager as config sources and read secrets directly and safely.
- Singleton-ize config with
lru_cacheand override with direct injection in tests.
The difference between "working code" and "code you can operate for 10 years" appears in where and how you dam config and secrets. pydantic-settings is the best tool to express that boundary by type.
As official primary sources, I recommend re-reading the following from this article's viewpoint.
Consulting on production-durable config / secret management
The author has designed and operated systems where the handling of secrets and config directly ties to business reliability, starting with a serverless payment platform in the environment field. Scattered environment variables, plaintext exposure of secrets, production incidents from config mistakes — these can be structurally prevented just by "consolidating config into a typed model and validating at the boundary." I implement 12-factor-compliant config management, cloud-Secret-Manager integration, and the organization of per-environment config using pydantic-settings, quickly and at high quality with generative AI. Feel free to reach out.