Type-Safe Validation with Pydantic Settings

Configuration that loads is not the same as configuration that is correct. pydantic-settings turns the loose strings coming from the environment into a validated, typed object that either constructs cleanly at startup or refuses to start at all. This section covers how to build that object, control its coercion behaviour, extend it with domain rules, and evolve its schema without breaking running services.

pydantic-settings validation flow Raw environment and .env values enter a BaseSettings model where field types, strict mode, and custom validators either produce a validated config or raise ValidationError at startup. Raw env / .env strings BaseSettings model Field types & constraints strict mode / coercion rules @field_validator domain rules Validated config ValidationError at startup
Every value is typed and checked once; the model constructs cleanly or the process refuses to start.

What this section covers

Topic Why it matters Go deeper
Settings fundamentals The BaseSettings model, sources, and SettingsConfigDict Pydantic Settings Fundamentals
Strict mode & coercion Controlling when "1" becomes 1 and when it must not Strict Mode & Type Coercion
Custom validators Domain rules — ARNs, URLs, key formats — beyond basic types Custom Validators & Constraints
Schema evolution Renaming and removing fields without breaking deployments Schema Evolution & Versioning
AWS Parameter Store Sourcing settings from a managed parameter store Settings from AWS Parameter Store
Multi-environment overrides Layering dev/staging/prod values on one schema Multi-Environment Settings Override

One settings object, one entry point

Pick a single BaseSettings subclass that owns every environment read. Centralizing prevents the configuration sprawl that makes apps untestable, and it gives you one place to enforce the security invariants.

# config/base.py
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class AppConfig(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_nested_delimiter="__",  # CACHE__HOST -> cache.host
        extra="forbid",             # reject unknown vars instead of ignoring them
    )
    database_url: str
    api_key: SecretStr              # never appears in repr() or model_dump()
    log_level: str = "INFO"

config = AppConfig()  # raises ValidationError if anything is missing or malformed

Key rule: use extra="forbid" so a typo’d DATABSE_URL fails loudly instead of being silently ignored. Initialization details are in Pydantic Settings Fundamentals.

Control coercion before it surprises you

By default Pydantic coerces "8080" to 8080 and "true" to True. That is convenient for environment variables (which are always strings) but dangerous when you actually need a strict type boundary.

# config/strict.py
from pydantic import Field
from pydantic_settings import BaseSettings

class ServerConfig(BaseSettings):
    port: int = Field(ge=1, le=65535)   # range-checked, not just typed
    debug: bool = False

Key rule: know exactly which fields coerce and which are strict — the rules are in Strict Mode & Type Coercion.

Encode domain knowledge as validators

Basic types reject "abc" as a port, but only a domain validator rejects a malformed ARN or a non-TLS database URL. Push that knowledge into the model so it runs everywhere the config loads.

# config/validators.py
from pydantic import field_validator
from pydantic_settings import BaseSettings

class DataConfig(BaseSettings):
    database_url: str

    @field_validator("database_url")
    @classmethod
    def require_tls(cls, v: str) -> str:
        if not v.startswith(("postgresql+psycopg://", "postgresql://")):
            raise ValueError("database_url must be a PostgreSQL DSN")
        return v

Key rule: validators run at construction, so a bad value can never reach business logic. ARN and URL recipes are in Custom Validators & Constraints.

Evolve the schema without downtime

Configuration schemas change. Renaming a field is a breaking change unless you accept both names during a migration window using validation_alias, covered in Schema Evolution & Versioning.

Anti-patterns & common mistakes

  • Any or bare Optional for required config — defeats the entire point of validation.
  • Reading os.environ directly alongside the model — two sources of truth that drift apart.
  • Printing the config object in logs — leaks anything not wrapped in SecretStr.
  • Catching ValidationError and continuing — start-up errors must be fatal, not swallowed.
  • Per-environment subclasses with diverging fields — keep one schema; vary only the values via multi-environment overrides.
  • Legacy inner class Config — pydantic v2 uses SettingsConfigDict; the old style raises deprecation warnings.

Decision flow: which validation tool?

Does the value have a fixed shape (port, count, flag)?
├── Yes → a typed field with Field(ge=..., le=...) constraints.
└── No → Does it follow a domain format (ARN, URL, key prefix)?
        ├── Yes → a @field_validator with an explicit error message.
        └── No → Is it a secret?
                ├── Yes → wrap in SecretStr and source from a secret manager.
                └── No → a plain typed field with a sensible default.

CI/CD integration checklist

  1. Instantiate the settings model in a CI step; a failed construction fails the build.
  2. Run with extra="forbid" so unknown variables are caught in review, not production.
  3. Assert repr(config) contains no secret values as a regression test for SecretStr usage.
  4. Pin the pydantic and pydantic-settings versions and test the v1→v2 behaviour explicitly.
  5. Snapshot config.model_dump() (with secrets excluded) and diff it across environments.

Bringing it together

A single BaseSettings model is the contract between the raw configuration sources and your application. Control coercion, encode domain rules, evolve the schema safely, and feed secrets in from enterprise secret managers as SecretStr. The result is a process that proves its configuration is correct before it accepts a single request.