Chapter 9: pydantic-settings. Typed config from env vars, .env files, secrets, with the same Pydantic ergonomics.

Setup

uv add pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    log_level: str = "INFO"
    
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        env_prefix="MYAPP_",
        case_sensitive=False,
    )

settings = Settings()

Reads MYAPP_DATABASE_URL, MYAPP_SECRET_KEY, etc.

.env files

# .env
MYAPP_DATABASE_URL=postgresql://...
MYAPP_SECRET_KEY=...

Loaded automatically. Don’t commit production .env; do commit .env.example.

Nested settings

class DBSettings(BaseSettings):
    url: str
    pool_size: int = 10
    
    model_config = SettingsConfigDict(env_prefix="MYAPP_DB_")

class Settings(BaseSettings):
    db: DBSettings = DBSettings()
    secret_key: str
    
    model_config = SettingsConfigDict(env_prefix="MYAPP_")

Access: settings.db.url.

For deeper nesting: __ separator:

MYAPP_DB__URL=postgresql://...
class Settings(BaseSettings):
    db: DBSettings
    
    model_config = SettingsConfigDict(env_nested_delimiter="__")

Secrets

For Docker secrets (/run/secrets/...):

class Settings(BaseSettings):
    secret_key: str
    
    model_config = SettingsConfigDict(secrets_dir="/run/secrets")

Looks for /run/secrets/secret_key (lowercase by default).

Multiple .env files

model_config = SettingsConfigDict(env_file=(".env", ".env.local"))

Later files override earlier. .env.local for dev overrides; .env for shared defaults.

Sources priority

Default priority (highest first):

  1. CLI args (with cli_parse_args).
  2. Init kwargs.
  3. Environment vars.
  4. Dotenv file.
  5. Secrets directory.
  6. Default values.

Customizable:

class Settings(BaseSettings):
    @classmethod
    def settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings):
        return env_settings, init_settings, dotenv_settings, file_secret_settings
        # custom order

CLI integration

class Settings(BaseSettings):
    database_url: str
    debug: bool = False
    
    model_config = SettingsConfigDict(cli_parse_args=True)

# python myapp.py --database-url=... --debug

CLI flags from field names. Underscores → hyphens.

For argparse-like control:

model_config = SettingsConfigDict(
    cli_parse_args=True,
    cli_prog_name="myapp",
    cli_use_class_docs_for_groups=True,
)

TOML / YAML / JSON

from pydantic_settings import BaseSettings, TomlConfigSettingsSource

class Settings(BaseSettings):
    model_config = SettingsConfigDict(toml_file="config.toml")
    
    @classmethod
    def settings_customise_sources(cls, settings_cls, *args):
        return (TomlConfigSettingsSource(settings_cls),) + args

Similar for YAML / JSON. Useful for non-env configurations.

With FastAPI

from functools import lru_cache
from fastapi import Depends

@lru_cache
def get_settings() -> Settings:
    return Settings()

@app.get("/info")
async def info(settings: Settings = Depends(get_settings)):
    return {"db": settings.database_url[:20] + "..."}

lru_cache: instantiate once. See FastAPI DI chapter .

Validation on startup

try:
    settings = Settings()
except ValidationError as e:
    print(e)
    sys.exit(1)

Fail fast. Don’t run with bad config.

For multiple errors: pydantic shows them all at once.

Sensitive fields

from pydantic import SecretStr

class Settings(BaseSettings):
    api_key: SecretStr
    db_password: SecretStr

print(settings)  # api_key=SecretStr('**********')
print(settings.api_key.get_secret_value())  # actual

SecretStr prevents accidental logging. Repr shows asterisks.

Frozen settings

class Settings(BaseSettings):
    model_config = SettingsConfigDict(frozen=True)

Immutable after creation.

Per-environment configs

class Settings(BaseSettings):
    env: Literal["dev", "staging", "prod"] = "dev"
    database_url: str
    
    @model_validator(mode="after")
    def validate_prod(self):
        if self.env == "prod" and "localhost" in self.database_url:
            raise ValueError("prod can't use localhost DB")
        return self

Common mistakes

1. Settings() without lru_cache

Reads .env every call; instantiates Settings. Slow.

2. Plaintext secrets in .env in git

Add .env to .gitignore. Commit .env.example.

3. Validation errors at runtime

Settings only validate on instantiation. Make sure to instantiate at startup.

4. Mixing env_prefix conventions

MYAPP_DB_URL vs MYAPP__DB_URL vs MYAPP_DB__URL. Pick a convention.

5. SecretStr leaking

json.dumps(settings.dict()) doesn’t redact. Use model_dump(mode="python") and check.

What’s next

Chapter 10: Performance, FastAPI integration, alternatives.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .