Skip to Content
BackendPydantic-Settings Validation

Pydantic-Settings Validation

What

A BaseSettings subclass that loads typed configuration from environment variables + .env, with Field(...) range constraints, @field_validator hooks for cross-field rules (production-only secret checks, JSON-string parsing), and @property computed shortcuts (database_url_sync).

Project Context

In full_project_context_updated.txt -> app/config.py, every runtime knob — DB pool size, Groq model, JWT algorithm, secret key, Langfuse keys — is defined inside a single Settings class. The class loads at import time (settings = Settings()), so reading any settings.* later in the code is a typed lookup. Validators fire automatically on load; e.g. starting in production with the default secret_key raises ValueError before the FastAPI app can begin serving requests.

How

Class with ranges, validators, and computed properties

class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) environment: Literal["development", "staging", "production", "test"] = "development" database_pool_size: int = Field(default=5, ge=1, le=20) database_max_overflow: int = Field(default=10, ge=0, le=50) secret_key: str = Field( default="your-secret-key-change-in-production", min_length=32, ) groq_temperature: float = Field(default=0.7, ge=0.0, le=1.0) groq_max_tokens: int = Field(default=1024, ge=1, le=32768) cors_origins: List[str] = Field(default_factory=lambda: [ "http://localhost:3000", "http://localhost:8000", ]) @field_validator("cors_origins", mode="before") @classmethod def parse_cors_origins(cls, v): if isinstance(v, str): try: return json.loads(v) except json.JSONDecodeError: return [v] return v @field_validator("secret_key") @classmethod def validate_secret_key(cls, v, info): if info.data.get("environment") == "production" and \ v == "your-secret-key-change-in-production": raise ValueError( "CRITICAL SECURITY: Must change SECRET_KEY in production!" ) return v @property def database_url_sync(self) -> str: return self.database_url.replace("+asyncpg", "") settings = Settings()
  • Field(ge=, le=) bounds are enforced at construction — assigning database_pool_size=999 raises before the app starts.
  • field_validator with mode="before" runs on raw input (e.g. an env-var string before Pydantic tries to coerce it to List[str]); the default mode="after" runs on the coerced type.
  • info.data (in Pydantic v2) carries the previously-validated fields — it lets cross-field rules like secret_key only in production reference environment without a second pass.
  • database_url_sync is a @property so callers asking for the sync URL never need to know the async vs sync driver suffix — the property hides the implementation detail.

Top-to-Bottom Code Walkthrough (app/config.py)

app/config.py has exactly one job: turn a scatter of .env strings into a typed, validated Python object the rest of the app can trust. Here is every section, top to bottom, in plain language.

The module imports BaseSettings and SettingsConfigDict from pydantic_settings plus Field and field_validator from pydantic. typing.List, typing.Literal, and json are pulled in because CORS origins commonly arrive as JSON-array strings when they cross the container boundary, and Literal is what tells Pydantic the only acceptable values for a field are a small set of strings. None of these imports are decorative — every one of them is used within the next 30 lines.

The Settings(BaseSettings) class is the centerpiece. Pydantic auto-discovers each annotated attribute and turns it into a typed setting that reads from .env, environment variables, or the optional secrets backend. The model_config = SettingsConfigDict(...) block controls loading: env_file=".env" looks in .env first; env_file_encoding="utf-8" keeps emoji-laden values intact; case_sensitive=False means DATABASE_URL and database_url resolve to the same field; extra="ignore" means pasting 200 unknown env vars won’t crash the app. Without extra="ignore", every extra var would raise a validation error on startup.

The application-level fields (app_name, app_version, environment, debug) are per-deployment tunables. environment: Literal["development", "staging", "production", "test"] literally rejects typos like "prod" — Pydantic raises a ValidationError if you mistype. Booleans like debug coerce from "true", "1", "True", etc., that arrive as shell strings.

The database block is the most heavily constrained because pool sizing has production consequences. database_pool_size: int = Field(default=5, ge=1, le=20) says “between 1 and 20, please” — if someone sets DATABASE_POOL_SIZE=100 in .env, the app refuses to boot before serving a request. database_max_overflow: int = Field(default=10, ge=0, le=50) allows spike headroom above the pool. Both are imported by app/database.py and getting them wrong here breaks the engine config downstream.

redis_url and redis_enabled form the Redis cluster. redis_enabled=False is the headline flag — when it’s off, CacheService becomes a no-op (its if not self.client guard short-circuits every call) instead of crashing on a missing Redis.

The groq_* group is the LLM dial board. groq_api_key defaults to an empty string so the app can still boot without a key; the validate_groq_key validator below prints a warning instead of raising. groq_temperature is ge=0.0, le=1.0 — you can’t accidentally pass -0.5 and crash the API. groq_max_tokens is bounded to 32768 because the model family this project uses caps there; sending higher errors at runtime. groq_timeout is in seconds, bounded 5..300.

The security section has a vivid teachable moment. secret_key: str = Field(default="...", min_length=32) enforces a 32-character floor even in dev — but the real guard is validate_secret_key down below, which inspects info.data['environment'] and refuses to boot if you’re in production with the literal placeholder string. This is a two-layer defense (length floor + content check) and is mandatory for anything touching JWT.

The optional lakera_guard_api_key and pii_redaction_enabled flags wire into the security pipeline — see presidio-pii-and-lakera-injection-defense. Leaving lakera_guard_api_key=None skips the external Lakera API and falls back to local Presidio.

The circuit-breaker section (circuit_breaker_threshold, circuit_breaker_recovery_timeout) feeds BaseAgent’s SimpleCircuitBreaker (see base-agent-circuit-breaker-tenacity). The defaults (5 failures, 60-second recovery) are conservative; in production these are tuned via Settings rather than edits to the agent class.

The logging fields feed setup_logging() in app/logger.py. log_format="json" is the prod default because structured logs flow into Datadog/Loki. app/logger.py’s setup_logging calls Path(log_file).parent.mkdir(parents=True, exist_ok=True) so a missing directory is auto-created — no setup needed before first launch.

max_body_size (default 10 MB, max 50 MB) protects the upload endpoint from runaway POSTs that would otherwise consume memory unbounded.

The observability fields flow into is_observability_enabled, the single switch agents check before opening a Langfuse span. langfuse_host defaults to https://cloud.langfuse.com but can be pointed at a self-hosted instance — useful for BFSI compliance where trace data must stay within a region.

The three @field_validator methods deserve careful reading. parse_cors_origins runs in mode="before" because env vars arrive as strings ('["http://localhost:3000"]'); the before mode lets us json.loads(...) before Pydantic sees the value. The fallback path (except json.JSONDecodeError: return [v]) handles the simple case where someone pastes a single origin without brackets. validate_secret_key runs in default mode ("after") because it depends on the validated environment field — info.data contains every field’s parsed value at that moment. validate_groq_key is deliberately non-blocking: it logs but doesn’t raise because the app must stay alive for /health even when the LLM key is missing — operators fix it without restarting the cluster.

The @property decorators are wrapped getters kept invisible to Pydantic (no Field involved). is_production and is_development are boolean conveniences — readability win in stack traces. database_url_sync is the critical Alembic-friendly variant: Alembic doesn’t load +asyncpg, so this strips the driver suffix to produce postgresql://. get_log_config() returns a complete logging.config.dictConfig-shaped dict — present for backward-compat with code paths that still expect a dict. is_observability_enabled is the practical gate: bool(self.langfuse_public_key and self.langfuse_secret_key) — if either is missing, agents skip Langfuse entirely rather than crash on import.

Outside the class, settings = Settings() is the global singleton. It runs once at import time — every from app.config import settings after the first time pulls the same instance, since Python’s module cache is consulted first. That is why the display_settings() debug helper only ever sees one consistent view of the world.

display_settings() is a developer aid that prints loaded values to stdout, with password-masking in the DB URL (postgresql+asyncpg://user:****@host:port/db) and API-key prefix-only display (gsk_xxxx****). It’s gated behind if __name__ == "__main__": so it never executes when imported normally — try python -m app.config to see what your runtime specs are.

Common Pitfalls

Validating after Pydantic already coerced — for List[str] fields, JSON-string env vars will already be coerced before mode="after". Use mode="before" if you need to read the raw string.

Reading settings inside a worker pool’s fork() — fork inherits the parent’s loaded Settings and can leak secrets. Re-load Settings() after workers spawn if security matters.

Real-World Interview Prep

Q1: When does Pydantic-Settings actually read the environment?

A: At module import time, when settings = Settings() first runs. There is no lazy proxy; if a value is missing the constructor raises immediately. This is the right time to crash — fail-fast at startup is better than a mid-request 500. The lookup precedence is: (1) CLI args passed via parse_args (rare); (2) environment variables, case-insensitive; (3) values from .env file specified in model_config.env_file; (4) field defaults. Higher-precedence sources always win. To reload at runtime, call Settings() again inside a worker pool’s init hook (or use a lru_cache-less singleton refreshed manually).

A: Use a model_validator(mode="after") decorator on the Settings class — it sees ALL fields already parsed. Example: assert settings.jwt_issuer is set whenever settings.secret_key is non-default; raise ValueError otherwise. The advantage of mode="after" over multiple field_validators: validators can reference each other unambiguously. Combine with @property for derived fields (like database_url_sync) so callers never know whether a value comes from env or from computation.

Q3: What’s the cost of using field_validator(mode="before") over mode="after" for List[str]?

A: mode="before" runs on the raw input (env-var string like '["a","b"]' or 'a,b') and lets you parse it yourself before Pydantic sees the type. mode="after" runs only AFTER Pydantic coerces. For list fields parsed from JSON strings in env vars, only mode="before" can catch the malformed-JSON case a single time; otherwise Pydantic complains with a confusing type error. Use mode="before" for: JSON-string lists, comma-separated strings, integers-as-floats (e.g., 0.1 cast to 0 silently if mode="after"). Use mode="after" for: cross-field rules referencing validated siblings.

Last updated on