Settings & Config

intermediate fastapi config pydantic production

Hardcoding config is the original sin. Database URLs, API keys, log levels — all of these change between dev, staging, and prod. The twelve-factor app rule: config lives in the environment, never in code.

Python has os.getenv, but it returns strings and zero validation. Enter pydantic-settings — a Pydantic model that reads from env vars, validates types, and gives us autocomplete.

Install

pip install pydantic-settings

A real settings module

# config.py
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    app_name: str = "Gyaan API"
    debug: bool = False
    database_url: str
    redis_url: str = "redis://localhost:6379/0"
    jwt_secret: str = Field(min_length=32)
    jwt_expire_minutes: int = 30
    cors_origins: list[str] = ["http://localhost:3000"]

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

A matching .env:

DATABASE_URL=postgresql://user:pass@localhost:5432/gyaan
JWT_SECRET=this-is-at-least-thirty-two-chars-long
DEBUG=true
CORS_ORIGINS=["http://localhost:3000","https://app.pman47.cc"]

Field names are case-insensitive matched against env vars. database_url reads DATABASE_URL. jwt_secret reads JWT_SECRET. List/dict fields take JSON strings.

Why this matters

Three wins over os.getenv:

  1. Types. debug: bool = False actually parses "true", "1", "yes" into True. Same for ints, floats, lists.
  2. Validation. Missing required field? Settings() raises on import. Bad value? Same. Fail fast at startup, not at 3am when a route happens to read it.
  3. Autocomplete. settings.database_url instead of os.getenv("DATABASE_URL").

Using it as a dependency

from fastapi import Depends, FastAPI
from config import Settings, get_settings

app = FastAPI()

@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
    return {"app": settings.app_name, "debug": settings.debug}

Because get_settings is wrapped in @lru_cache, the Settings() object is created once and reused for every request. No re-parsing the env on every call. No re-reading the .env file.

Why lru_cache?
request 1 → get_settings() → reads env, builds Settings, caches it
request 2 → get_settings() → returns cached instance (no I/O)
request 3 → get_settings() → returns cached instance
...
request N → get_settings() → returns cached instance
Same instance everywhere. Cheap and consistent.

It also makes overriding easy in tests:

from config import get_settings

def fake_settings():
    return Settings(database_url="sqlite:///:memory:", jwt_secret="x" * 32)

app.dependency_overrides[get_settings] = fake_settings

Twelve-factor: why env vars (not config files)

The twelve-factor app says config goes in the environment because:

  • Same code, different deploys. Dev, staging, prod all run the same Docker image. Only env differs.
  • No secrets in git. .env is .gitignored. Real prod secrets live in your secrets manager (AWS Secrets Manager, Doppler, Vault).
  • Language and OS agnostic. Every platform supports env vars. Easy to inject from Docker, Kubernetes, systemd, CI.

.env is a dev convenience — pydantic-settings reads it locally. In prod, the orchestrator (Docker, Kubernetes) sets env vars directly and the .env file simply isn’t there.

Multiple environments

A common pattern: pick the env file based on an ENV var.

import os

env_file = f".env.{os.getenv('ENV', 'dev')}"

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=env_file, extra="ignore")
    ...

Then .env.dev, .env.staging, .env.prod. Or simpler — just use one .env for local dev, and let prod inject real env vars over it.

Nested settings

For grouping related config:

class DBSettings(BaseSettings):
    url: str
    pool_size: int = 5
    model_config = SettingsConfigDict(env_prefix="DB_")

class Settings(BaseSettings):
    db: DBSettings = DBSettings()
    app_name: str = "Gyaan"

Now DB_URL=... populates settings.db.url.

Don’t do these things

  • Don’t store secrets in .env checked into git. Add it to .gitignore. Commit .env.example instead.
  • Don’t call Settings() directly in route code. Use the cached get_settings dependency.
  • Don’t re-read settings on every request. That’s the whole point of lru_cache.
  • Don’t mutate settings at runtime. It’s a snapshot of startup config.

Interview cheat sheet

  • pydantic-settings = typed, validated env-based config.
  • Use BaseSettings + SettingsConfigDict(env_file=".env").
  • @lru_cache on get_settings() so we build it once.
  • Inject as a FastAPI dependency for testability (override in tests).
  • Twelve-factor: config in env, secrets out of git, same image across environments.