Home/Learn/FastAPI/Settings & Configuration — pydantic-settings and .env

Settings & Configuration — pydantic-settings and .env

Intermediate
Routing & Structure

BaseSettings reads typed config from environment variables and .env files — validated at startup, injected via a cached get_settings dependency, with secrets kept out of git entirely.

Overview

Hard-coded config is how staging code talks to production databases. The twelve-factor rule is config-from-environment, and pydantic-settings makes it typed: a Settings class declares every knob (DATABASE_URL, JWT_SECRET, DEBUG) with types and defaults; values load from real environment variables first, then a .env file for local development. Validation runs at import — a missing DATABASE_URL crashes the app at startup with a clear message instead of at 2 a.m. on the first request. Expose settings through an @lru_cache-ed get_settings() dependency: endpoints declare what they need, and tests can override the whole configuration without touching the environment. The .env file itself never enters git — .env.example with dummy values documents the contract.

Typed Settings from Environment + .env

Declare once, validate at startup. Environment variables beat .env values, which beat defaults — exactly the precedence deployment needs (Fly/Render/K8s secrets override local files).

Types + required fields = misconfiguration caught at boot
# pip install pydantic-settings

# ── app/core/config.py ──────────────────────
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    app_name: str = "Placement API"
    env: str = "dev"                       # dev | staging | prod
    debug: bool = False
    database_url: str                      # REQUIRED — no default, crash if absent
    jwt_secret: str                        # REQUIRED
    jwt_ttl_minutes: int = 30
    razorpay_key_id: str | None = None     # optional integration
    allowed_origins: list[str] = ["http://localhost:3000"]

# ── .env (local dev ONLY — in .gitignore) ───
# DATABASE_URL=postgresql://app:app@localhost:5432/placement
# JWT_SECRET=dev-only-not-for-prod
# DEBUG=true
# ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:3001"]

# ── .env.example (committed — the contract, dummy values) ──
# DATABASE_URL=postgresql://user:pass@host:5432/dbname
# JWT_SECRET=change-me

# Missing DATABASE_URL at startup →
#   pydantic ValidationError: database_url: Field required
# The app refuses to boot half-configured. That is a feature.

get_settings() — Cached and Injectable

A cached factory makes settings a dependency like any other: endpoints stay testable, and lru_cache means the .env parse happens once, not per request. Branch behaviour on settings.env, never on hostname guessing.

lru_cache once; Depends everywhere; override in tests
from functools import lru_cache
from fastapi import Depends, FastAPI
from app.core.config import Settings

@lru_cache                                 # parse env/.env exactly once
def get_settings() -> Settings:
    return Settings()

app = FastAPI()

@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
    return {
        "app": settings.app_name,
        "env": settings.env,
        # NEVER echo jwt_secret / database_url in any endpoint
    }

# Environment-dependent behaviour, the honest way:
def make_app() -> FastAPI:
    s = get_settings()
    return FastAPI(
        title=s.app_name,
        docs_url=None if s.env == "prod" else "/docs",   # docs off in prod
        debug=s.debug,
    )

# Tests: swap the ENTIRE config without touching os.environ —
# app.dependency_overrides[get_settings] = lambda: Settings(
#     database_url="sqlite:///test.db", jwt_secret="test", env="test"
# )

# Production secrets live in the platform, not in files:
#   fly secrets set JWT_SECRET=...     (or K8s Secrets / AWS SSM)
# Rotation = update the secret + restart. No commit, no redeploy of code.

Key Points to Remember

  • 1BaseSettings: typed, validated config from env vars + .env (env wins)
  • 2Required fields without defaults make missing config a startup crash
  • 3@lru_cache get_settings() + Depends = injectable, test-overridable config
  • 4.env is gitignored; .env.example documents; prod secrets live in the platform

Interview Questions

Sign in to ask Aria
1

How do you manage config differences between dev, staging, and prod in FastAPI?

MediumGroww
2

Why should the app crash at startup if DATABASE_URL is missing rather than default to localhost?

MediumZerodha
3

A developer committed .env with production credentials — walk through the incident response.

HardPhonePe

Ask Aria about Settings & Configuration — pydantic-settings and .env

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…