Your FastAPI app gets config-related bugs in three common ways:
- You hardcode API keys or base URLs “just for now”. Then you forget.
- You sprinkle
os.getenvcalls inside routes, modules, and random helpers. - You don’t notice missing values until production does something weird.
This is where Pydantic Settings FastAPI helps. It gives you one place to define configuration, validate it, and parse the types you actually want—without turning your codebase into a scavenger hunt for environment variables.
The naive approach (it works… until it doesn’t)
Here’s a typical pattern I’ve seen over and over:
# app/external_client.py
import os
import requests
API_KEY = os.getenv("EXTERNAL_API_KEY") # read at import time
def call_external_api():
base_url = os.getenv("EXTERNAL_BASE_URL") # read inside function
return requests.get(
f"{base_url}/v1/items",
headers={"Authorization": f"Bearer {API_KEY}"}
)
And then you discover that:
EXTERNAL_API_KEYis missing in one environment. You don’t fail fast. You just sendBearer None.- You need booleans and lists, so someone starts doing
os.getenv("FEATURE_FLAGS")and parsing with string splits (different in three places). - Your behavior differs between local runs and deployed runs because values are loaded at different times (import time vs request time).
This is exactly the kind of “scattered configuration” problem 12-factor apps warn about: config belongs outside the code, but it should also be centralized and predictable. See: https://12factor.net/config.
The fix: centralize config with Pydantic Settings
Pydantic Settings lets you define a configuration class that reads from environment variables (and optionally a .env file), then validates and coerces types.
The core idea is simple:
- Define a single
Settingsmodel. - Let it parse types (bools, lists, ints) from strings.
- Fail fast at startup when required config is missing.
1) Define a settings class
# app/settings.py
from typing import List, Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Required secrets: fail fast if missing
external_api_key: str
# Optional stuff with defaults
external_base_url: str = "https://api.example.com"
# Parse booleans from env strings like "true"/"false"/"1"/"0"
debug_mode: bool = False
# Parse lists from strings; one common approach is comma-separated
allowed_countries: List[str] = ["US", "CA"]
# Example: you might want an optional DSN
sentry_dsn: Optional[str] = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
# you can also control case sensitivity / prefixing here if you need it
)
Note the separation of concerns: routes don’t care where values came from. They just use typed settings.
2) Provide a .env for local development
Create .env at the project root:
EXTERNAL_API_KEY=your-local-secret
EXTERNAL_BASE_URL=https://sandbox-api.example.com
DEBUG_MODE=true
ALLOWED_COUNTRIES=US,GB,IN
SENTRY_DSN=
One thing to be careful about: Pydantic Settings maps env var names to fields. By default, it uses a field name to env var name conversion. To avoid surprises, stick to a clear naming convention and confirm how it maps in your setup (field name vs env var name). If you prefer strict control, you can add explicit env aliases via Pydantic field metadata.
3) Instantiate settings once and reuse it
FastAPI apps typically load settings once at startup. You can then inject the settings into endpoints.
# app/main.py
from fastapi import FastAPI, Depends
from app.settings import Settings
app = FastAPI()
def get_settings() -> Settings:
# This is the simplest approach. For most apps, creating Settings once at import time
# is fine too. The key is: fail fast during startup.
return Settings()
@app.get("/health")
def health(settings: Settings = Depends(get_settings)):
return {
"debug_mode": settings.debug_mode,
"countries": settings.allowed_countries,
}
If a required variable like EXTERNAL_API_KEY is missing, the app should fail immediately. That’s the point. You don’t want “it runs” while your secrets are None.
How the improvement actually helps
Validation and fail-fast behavior
Without Settings, missing env vars often lead to late surprises (requests failing, 401s everywhere, or worse—requests going out with incorrect headers).
With Pydantic Settings, missing required fields raise errors during settings creation. That’s your signal to fix deployment configuration, not to debug runtime behavior.
Type safety (and less string parsing glue)
Environment variables are strings. Your app probably wants:
boolfor flagsintfor ports and limitsList[str]for allowlists
Pydantic handles coercion for you (with the rules of Pydantic). You stop writing ad-hoc parsing in three places and then “fixing” it differently in production.
Defaults become explicit
This matters in practice. When you write:
external_base_url: str = "https://api.example.com"
you’ve documented the default in code, and you get that default consistently across environments. If you don’t set the variable in production, you still get predictable behavior.
Real-world edge cases you’ll hit
Missing required variables
If external_api_key is required (no default), missing it should crash settings creation. That’s good. It moves failure earlier.
If you have “required in prod but not in local”, you still need a policy. Either:
- Make it optional (
Optional[str]) and fail when the code path needs it, or - Use different environment variables per environment and keep the required value truly required.
The second option usually keeps you honest.
Booleans and lists parsed from strings
People often break this part. For example:
DEBUG_MODEset to"false"still being treated as truthy because someone used a rawif os.getenv(...).ALLOWED_COUNTRIESexpected to be["US","GB"]but code gets a single string like"US,GB".
Pydantic Settings gives you a consistent parsing story. You just need to choose the representation that matches your env var format (common is comma-separated for list values).
Different config for local vs production
Local development often uses a .env file FastAPI and sometimes different endpoints (sandbox vs production). Production typically uses real environment variables injected by your platform.
Don’t rely on developers forgetting to create .env to “test” your system. Put required checks in startup so missing config fails quickly.
Risk of committing secrets to version control
The classic mistake: a .env file with real keys gets committed.
At minimum:
- Put
.env(and any real secret files) in.gitignore. - Use example files like
.env.examplethat contain placeholders.
FastAPI won’t save you from this. Pydantic won’t save you from it either. Your repo hygiene has to.
Practical: verify configuration at startup
Don’t wait for the first request to discover bad config. Do a simple startup check.
One clean pattern is to create the settings once when the app starts. If it fails, the process fails.
# app/main.py
from fastapi import FastAPI
from app.settings import Settings
app = FastAPI()
settings = Settings() # will raise immediately if required vars are missing
@app.get("/health")
def health():
return {"debug_mode": settings.debug_mode}
Or if you want a small test that fails fast:
# tests/test_settings.py
import pytest
from app.settings import Settings
def test_settings_required_fields_present(monkeypatch):
monkeypatch.delenv("EXTERNAL_API_KEY", raising=False)
with pytest.raises(Exception):
Settings()
It’s boring. That’s the point. You want config problems to be loud and immediate, not mysterious and delayed.
Once you’ve lived through a “works locally, breaks in prod” incident caused by missing env vars, you stop treating configuration as incidental. You treat it like code.