# Practical pydantic-settings guide: realize 12-factor with type-safe configuration management and secret protection

> Faithful to the pydantic-settings official documentation, with real code it explains production-durable configuration management: BaseSettings' typed config model, .env and env_nested_delimiter, secret protection with SecretStr/secrets_dir, the priority of settings, JSON/TOML/cloud (AWS/Azure/GCP) integration via settings_customise_sources, and CLI.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, Pydantic, pydantic-settings, 設定管理, セキュリティ, 型安全
- URL: https://tomodahinata.com/en/blog/pydantic-settings-configuration-management-secrets-guide
- Category: Pydantic & type-safe validation
- Pillar guide: https://tomodahinata.com/en/blog/pydantic-v2-production-validation-type-safety

## Key points

- Reading os.environ directly is a quadruple affliction of 'type fixed to str, no existence guarantee, scattered defaults, log leaks.' pydantic-settings consolidates config into a typed model and Fail-Fasts at startup.
- Wrap secrets with SecretStr and they auto-mask in repr/logs/serialization, retrievable only with .get_secret_value(). Read Docker/k8s file mounts with secrets_dir.
- Value priority is 6 levels by the official spec: CLI args > init args > env vars > .env > secrets_dir > defaults. Reorder/extend with settings_customise_sources.
- You can compose JSON/TOML/YAML files and AWS/Azure/GCP Secret Managers as config sources, consolidating per-environment config type-safely.
- Make config a singleton with lru_cache, and in tests override with direct injection into Settings(...) or monkeypatch. Detect missing required items before deploy.

---

## **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.

1. **The type is fixed to `str`**: `os.environ["MAX_CONNECTIONS"]` is a string. You write `int(...)` conversion every time you read it, and forget it and it's a bug.
2. **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 `KeyError` late at night in production.
3. **Defaults scatter**: `os.environ.get("DEBUG", "false")` is sprinkled everywhere and you lose track of which is correct (DRY violation).
4. **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](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/) (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](/blog/pydantic-v2-production-validation-type-safety), and for the validation logic of config values, also see [advanced types / custom validators](/blog/pydantic-custom-types-annotated-validators-advanced-guide).

---

## **1. The minimal config model: Fail-Fast at startup**

`BaseSettings` is **a separate package, not the `pydantic` body** (separated in v2).

```bash
pip install pydantic-settings
```

```python
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` / `SettingsConfigDict` are `from pydantic_settings import ...`, while `SecretStr` / `Field` / `AliasChoices` are `from pydantic import ...`. `from pydantic import BaseSettings` is 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.

```python
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.**

```bash
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_REDIS` while overriding only part with `APP_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.

```python
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.

```python
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.**

```python
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.

```python
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.

```python
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`.

```python
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`).

```python
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.

```python
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 `.env` like `env_file=(".env", f".env.{os.environ.get('ENV', 'local')}")`, or split `Settings` subclasses 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](/blog/pydantic-testing-polyfactory-hypothesis-strategy-guide).

---

## **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.

1. Consolidate config into a typed model with **`BaseSettings` (a separate package)** and **Fail-Fast** at startup.
2. Read flexibly with **`.env` / `env_prefix` / `env_nested_delimiter`**, and parse complex types as JSON.
3. Auto-mask secrets with **`SecretStr`**, and in containers read from files with **`secrets_dir`.**
4. **Priority is 6 levels** (CLI > init > env > .env > secrets > default). Reorder/extend with `settings_customise_sources`.
5. Compose **JSON/TOML/YAML and cloud Secret Manager** as config sources and read secrets directly and safely.
6. Singleton-ize config with **`lru_cache`** and 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.

- [Settings Management](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/)
- [pydantic-settings API](https://pydantic.dev/docs/validation/latest/api/pydantic_settings/)

---

### **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.
