Validated Settings & Models
Build a tiny but realistic validation layer for a notification service: a typed
Settings object loaded from the environment and a .env file, plus a set
of request/response models that exercise Field constraints, a
@field_validator, a @model_validator, an Annotated reusable validator, a
@computed_field, and a discriminated union of channel payloads. Then you’ll
run it and watch Pydantic raise structured ValidationErrors on bad input —
proof the boundary is actually enforced, not just annotated.
What you’ll practice
Section titled “What you’ll practice”- A
BaseSettingsconfig withenv_prefix,.envloading, nested settings, andFieldconstraints — the 12-factor pattern. - Request models with
Field(...)constraints, anAnnotatedreusable validator, a@field_validator(transform + reject), and a cross-field@model_validator(mode="after"). - A discriminated union (
Field(discriminator=...)) over email/SMS/push payloads. - A response model with a
@computed_field. - Reading a
ValidationErrorand turning it into a clean dict with.errors().
Requirements
Section titled “Requirements”A single uv project. The full solution is below; build it yourself first, then
compare.
Settings(BaseSettings)readingAPP_-prefixed env vars and.env, with a nestedRedisSettings, a requireddatabase_url, a constrainedport, and adefault_senderemail validated by anAnnotatedtype.- A discriminated union
NotificationPayload = EmailPayload | SmsPayload | PushPayloadkeyed on achannelliteral. - A
SendRequestmodel combining constraints, a@field_validator, a@model_validator, and the union. - A
SendResponsemodel with a@computed_field. - A
main.pythat loads settings and runs both valid and invalid inputs, printing the parsed models and the structured errors.
The worked solution
Section titled “The worked solution”A flat, single-package uv project. The whole validation layer is three small
modules.
Directoryvalidated-settings/
- pyproject.toml created by
uv init, deps added withuv add - .env local config (gitignored in real projects)
Directorysrc/
Directoryapp/
- __init__.py
- settings.py BaseSettings config
- models.py request/response models + discriminated union
- main.py demo: valid + invalid runs
- pyproject.toml created by
Project setup
Section titled “Project setup”uv init validated-settingscd validated-settingsuv add "pydantic[email]" pydantic-settingsuv add --dev ruff typydantic[email] pulls in email-validator so the EmailStr type works.
pydantic-settings is a separate package from pydantic in v2 — it was split
out of the core, so you add it explicitly.
Settings: typed config from env + .env
Section titled “Settings: typed config from env + .env”Settings loads APP_-prefixed variables, falls back to .env, and binds the
nested redis block with the __ delimiter (APP_REDIS__PORT). database_url
has no default, so a missing value is a startup error — exactly what you want for
config you can’t run without.
from typing import Annotated
from pydantic import AfterValidator, BaseModel, Fieldfrom pydantic_settings import BaseSettings, SettingsConfigDict
def _looks_like_email(v: str) -> str: if "@" not in v or v.startswith("@") or v.endswith("@"): raise ValueError("must look like an email address") return v.lower()
# A reusable, composable validated type — the rule travels with the type.SenderEmail = Annotated[str, AfterValidator(_looks_like_email)]
class RedisSettings(BaseModel): host: str = "localhost" port: int = Field(default=6379, ge=1, le=65535)
class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_prefix="APP_", env_nested_delimiter="__", # APP_REDIS__PORT -> redis.port extra="ignore", )
database_url: str # required: no default port: int = Field(default=8000, ge=1, le=65535) debug: bool = False # "true"/"1"/"yes" -> True default_sender: SenderEmail = "noreply@example.com" redis: RedisSettings = Field(default_factory=RedisSettings)Models: constraints, validators, discriminated union
Section titled “Models: constraints, validators, discriminated union”The three channel payloads share a channel literal that acts as the
discriminator. SendRequest ties it together with field constraints, a
normalizing @field_validator, and a cross-field @model_validator that rejects
scheduling a send in the past. SendResponse derives delivered with a
@computed_field.
from datetime import datetime, timezonefrom typing import Annotated, Literalfrom uuid import UUID, uuid4
from pydantic import ( BaseModel, BeforeValidator, EmailStr, Field, computed_field, field_validator, model_validator,)
def _strip(v: object) -> object: return v.strip() if isinstance(v, str) else v
NonEmptyStr = Annotated[str, BeforeValidator(_strip), Field(min_length=1, max_length=280)]
# --- Discriminated union over the delivery channel -------------------------class EmailPayload(BaseModel): channel: Literal["email"] to: EmailStr subject: str = Field(..., min_length=1, max_length=120) body: NonEmptyStr
class SmsPayload(BaseModel): channel: Literal["sms"] to: str = Field(..., pattern=r"^\+[1-9]\d{6,14}$") # E.164 body: NonEmptyStr
class PushPayload(BaseModel): channel: Literal["push"] device_token: str = Field(..., min_length=10) body: NonEmptyStr
NotificationPayload = Annotated[ EmailPayload | SmsPayload | PushPayload, Field(discriminator="channel"),]
# --- Request / response models ---------------------------------------------class SendRequest(BaseModel): request_id: UUID = Field(default_factory=uuid4) priority: int = Field(default=5, ge=1, le=10) send_after: datetime | None = None payload: NotificationPayload
@field_validator("send_after") @classmethod def must_be_aware(cls, v: datetime | None) -> datetime | None: # normalize naive datetimes to UTC so comparisons are well-defined if v is not None and v.tzinfo is None: return v.replace(tzinfo=timezone.utc) return v
@model_validator(mode="after") def not_in_past(self) -> "SendRequest": if self.send_after is not None and self.send_after < datetime.now(timezone.utc): raise ValueError("send_after must not be in the past") return self
class SendResponse(BaseModel): request_id: UUID channel: str status: Literal["queued", "sent", "failed"]
@computed_field # serialized in model_dump(), not an input field @property def delivered(self) -> bool: return self.status == "sent"The demo: valid and invalid runs
Section titled “The demo: valid and invalid runs”main.py loads settings, sends one valid request per channel, then feeds three
deliberately broken inputs and prints the structured errors via
ValidationError.errors().
from pydantic import ValidationError
from app.models import SendRequest, SendResponsefrom app.settings import Settings
def show_errors(label: str, exc: ValidationError) -> None: print(f"\n[INVALID] {label} -> {exc.error_count()} error(s):") for err in exc.errors(): loc = ".".join(str(p) for p in err["loc"]) print(f" - {loc}: {err['msg']} (type={err['type']})")
def main() -> None: settings = Settings() print("=== Settings ===") print(settings.model_dump()) print(f"redis -> {settings.redis.host}:{settings.redis.port}")
print("\n=== Valid requests ===") email = SendRequest.model_validate( {"payload": {"channel": "email", "to": "a@b.com", "subject": "Hi", "body": " hello "}} ) print("email body (stripped):", repr(email.payload.body))
sms = SendRequest.model_validate( {"priority": 1, "payload": {"channel": "sms", "to": "+14155550123", "body": "pong"}} ) print("sms ok, priority:", sms.priority)
resp = SendResponse(request_id=email.request_id, channel="email", status="sent") print("response:", resp.model_dump()) # note computed `delivered` field
print("\n=== Invalid requests ===") try: SendRequest.model_validate( {"priority": 99, "payload": {"channel": "email", "to": "not-an-email", "subject": "", "body": ""}} ) except ValidationError as exc: show_errors("bad email + priority + empty fields", exc)
try: SendRequest.model_validate({"payload": {"channel": "carrier-pigeon", "body": "x"}}) except ValidationError as exc: show_errors("unknown channel (discriminator)", exc)
try: SendRequest.model_validate( {"send_after": "2000-01-01T00:00:00Z", "payload": {"channel": "sms", "to": "+14155550123", "body": "late"}} ) except ValidationError as exc: show_errors("send_after in the past (model validator)", exc)
if __name__ == "__main__": main()Create a .env so database_url resolves (it has no default):
APP_DATABASE_URL=postgresql+asyncpg://dev:dev@localhost:5432/appAPP_DEBUG=trueAPP_REDIS__PORT=6380Run it
Section titled “Run it”-
Sync dependencies (creates the venv and installs everything):
Terminal window uv sync -
Run the demo.
uv runaddssrc/to the path via the project config; if you used a flat layout, run from the project root:Terminal window uv run python -m app.mainYou’ll see settings load (note
redis.portis6380fromAPP_REDIS__PORT, anddebugcoerced toTrue), the valid requests parse (the email body is stripped to'hello'), the response includes the computeddeliveredfield, and three structured error blocks like:[INVALID] bad email + priority + empty fields -> 3 error(s):- priority: Input should be less than or equal to 10 (type=less_than_equal)- payload.email.to: value is not a valid email address... (type=value_error)- payload.email.subject: String should have at least 1 character (type=string_too_short) -
Confirm the unset-required-config failure mode — comment out
APP_DATABASE_URLin.env(or run without it) and rerun:Settings()raises aValidationErrorfor the missingdatabase_urlat startup, before any business logic runs. -
Lint and type-check (every project in this guide uses both):
Terminal window uv run ruff check .uv run ty check