Managing Environment Variables with Pydantic Settings in FastAPI
Secure and predictable application configuration is non-negotiable in production. This guide demonstrates how to implement robust Configuration Management using pydantic-settings, ensuring strict type validation, secure secret handling, and seamless environment overrides. By validating at startup, you eliminate runtime crashes caused by malformed or missing variables, enforce explicit contracts for deployment pipelines, and integrate cleanly with FastAPI's dependency injection system.
Installation & Base Settings Architecture
Install the modern v2 package: pip install pydantic-settings>=2.0.0. Inherit directly from BaseSettings and enforce explicit type hints for every field. This approach aligns with established Core Architecture & Routing Patterns by decoupling configuration from business logic and enforcing immutability across your service mesh.
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator
class Settings(BaseSettings):
# Explicit type hints enforce strict validation at startup
app_name: str = "FastAPI App"
database_url: str
debug: bool = False
cors_origins: list[str] = []
@field_validator("database_url")
@classmethod
def validate_db_url(cls, v: str) -> str:
allowed_schemes = ("postgresql://", "postgresql+asyncpg://", "sqlite://")
if not v.startswith(allowed_schemes):
raise ValueError(f"Invalid database URL scheme. Must start with {allowed_schemes}")
return v
# Pydantic v2 replaces the deprecated class Config with model_config
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore" # Silently ignore unexpected OS env vars
)
Environment-Specific Overrides & .env Precedence
Pydantic Settings resolves values using a strict precedence chain: OS Environment Variables > .env file > Class Defaults. This hierarchy prevents accidental local overrides in staging or production pipelines.
To dynamically load environment-specific files (e.g., .env.production, .env.staging), compute the path before instantiation:
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
ENV = os.getenv("APP_ENV", "development")
ENV_FILE = Path(f".env.{ENV}")
class DynamicSettings(BaseSettings):
database_url: str
api_timeout: int = 30
model_config = SettingsConfigDict(
env_file=ENV_FILE if ENV_FILE.exists() else None,
env_file_encoding="utf-8"
)
Production Constraint: Never commit .env.production to version control. Rely on CI/CD secret injection or Kubernetes ConfigMaps to populate OS-level variables, which will always take precedence over local .env files. If your deployment runner injects variables directly into the container environment, env_file becomes a fallback for local development only.
Advanced Validation & Custom Parsers
Environment variables are inherently strings. Complex types like database URLs, CORS origins, or JSON payloads require explicit parsing. Use @field_validator with mode="before" to transform raw strings before type coercion.
import json
from typing import List
from pydantic import field_validator
from pydantic_settings import BaseSettings
class AdvancedSettings(BaseSettings):
redis_hosts: List[str] = []
feature_flags: dict = {}
@field_validator("redis_hosts", mode="before")
@classmethod
def parse_comma_separated(cls, v):
if isinstance(v, str):
return [host.strip() for host in v.split(",") if host.strip()]
return v
@field_validator("feature_flags", mode="before")
@classmethod
def parse_json_flags(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON for feature_flags: {e}")
return v
This pattern guarantees that malformed strings raise descriptive ValidationError exceptions during app initialization, rather than causing silent failures or AttributeError crashes mid-request.
FastAPI Dependency Injection Integration
Avoid global singletons. They break test isolation, complicate hot-reloading, and introduce thread-safety risks. Instead, wire settings into FastAPI using a cached dependency.
from functools import lru_cache
from fastapi import Depends, FastAPI
app = FastAPI()
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""
Caches the Settings instance. Environment variables are parsed exactly once
per worker process, eliminating redundant I/O on every request.
"""
return Settings()
@app.get("/health")
async def health_check(settings: Settings = Depends(get_settings)):
return {"status": "ok", "app": settings.app_name, "debug": settings.debug}
Testing Override Pattern:
from fastapi.testclient import TestClient
def test_health_check():
client = TestClient(app)
# Override the cached dependency for isolated testing
app.dependency_overrides[get_settings] = lambda: Settings(
database_url="sqlite:///test.db",
debug=True,
cors_origins=["http://localhost:3000"]
)
response = client.get("/health")
assert response.json()["debug"] is True
# Clean up to prevent cross-test pollution
app.dependency_overrides.clear()
Debugging & Production Troubleshooting
When configuration fails in live environments, follow this diagnostic workflow:
- Safe State Logging: Use
settings.model_dump()to log non-sensitive configuration states. Never log raw__dict__or secrets.
import logging
logger = logging.getLogger(__name__)
# Excludes sensitive fields explicitly
logger.info(f"Loaded config: {settings.model_dump(exclude={'database_url', 'api_keys'})}")
- Traceback Analysis: Pydantic v2 provides highly structured validation errors. Look for
pydantic_core._pydantic_core.ValidationErrorin your container logs. The traceback explicitly names the missing field, the expected type, and the raw input value. - Docker/Kubernetes Secret Paths: If using mounted secrets (
/run/secrets/db_pass), ensure yourBaseSettingspoints to the correct path viaenv_fileor reads them directly from the OS environment. K8s mounts create files, not env vars, unless explicitly mapped viaenvFrom. Use an entrypoint script orenvsubstto bridge the gap, or read the file directly in a@field_validator.
Common Pitfalls & Anti-Patterns
| Pitfall | Impact | Resolution |
|---|---|---|
| Mutable Globals | Breaks test isolation, leaks secrets to VCS | Enforce BaseSettings instantiation via DI. Treat config as immutable. |
| Silent Type Coercion | "true" vs True, "0" vs False misconfigurations | Use pydantic.StrictBool or explicit validators. Validate complex strings manually. |
| Uncached Dependencies | High latency, memory overhead, redundant .env reads | Always wrap get_settings() with @lru_cache(maxsize=1). |
FAQ
How do I handle missing environment variables in production?
Define required fields without default values. Pydantic will raise a ValidationError at startup, preventing the application from booting with incomplete configuration. This is a fail-fast strategy that protects against partial deployments.
Can I use multiple .env files for different environments?
Yes. Dynamically compute the env_file path in model_config based on an APP_ENV or STAGE variable. Alternatively, pass env_file explicitly during instantiation in your entrypoint script.
Does Pydantic Settings support nested dictionaries from JSON env vars?
Yes. Use pydantic.Json type hints or a @field_validator(mode="before") to parse JSON strings into native Python dictionaries. Ensure your CI/CD pipeline properly escapes quotes when injecting JSON payloads into environment variables.