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.
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
Anyor bareOptionalfor required config — defeats the entire point of validation.- Reading
os.environdirectly alongside the model — two sources of truth that drift apart. - Printing the config object in logs — leaks anything not wrapped in
SecretStr. - Catching
ValidationErrorand 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 usesSettingsConfigDict; 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
- Instantiate the settings model in a CI step; a failed construction fails the build.
- Run with
extra="forbid"so unknown variables are caught in review, not production. - Assert
repr(config)contains no secret values as a regression test forSecretStrusage. - Pin the
pydanticandpydantic-settingsversions and test the v1→v2 behaviour explicitly. - 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.