🎓 What You Will Learn
- Configuration Management: Why hardcoding settings is dangerous
- Pydantic Settings: Using BaseSettings for configuration
- Environment Variables: Loading from .env files
- Validation: Type checking and validation of settings
- Secrets: Managing sensitive data securely
- Multiple Environments: Development, staging, production configs
1Why Configuration Management Matters
Hardcoding configuration (database URLs, API keys, credentials) is dangerous. It exposes secrets. It also stops you from running the same code in different environments. Pydantic Settings gives you a clean, type-safe way to manage configuration.
Configuration should never be hardcoded. Different environments (development, staging, production) need different settings. Use environment variables to inject configuration at runtime.
❌ Hardcoded (Bad)
2Pydantic Settings Basics
Pydantic Settings loads configuration from environment variables and .env files. Install it first.
pip install pydantic-settingsfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env") # Load from .env file
# Required settings (no default value)
database_url: str
api_key: str
# Optional settings with defaults
debug: bool = False
log_level: str = "INFO"
port: int = 8000
# Create global settings instance
settings = Settings()
Pydantic v2 note: Old tutorials use class Config: or from pydantic import BaseSettings. Those are Pydantic v1 style. In v2, import from pydantic_settings and use model_config = SettingsConfigDict(...).
3Loading from .env Files
Create a .env file in your project root with environment variables. Pydantic Settings loads them automatically.
DATABASE_URL=postgresql://user:password@localhost/mydb
API_KEY=sk_live_1234567890
DEBUG=false
LOG_LEVEL=INFO
PORT=8000
SECRET_KEY=your-secret-key-here
Security Warning: Never commit .env to version control. Add it to .gitignore.
# Ignore environment files
.env
.env.local
.env.*.local
.env.prod
# Ignore IDE
.vscode/
.idea/
# Ignore Python
__pycache__/
*.pyc
.venv/
venv/
4Type Validation & Defaults
Pydantic validates types automatically. If you set port: int = 8000, Pydantic ensures the value is an integer.
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
# Required string
database_url: str
# Optional string
redis_url: Optional[str] = None
# Integer with default
port: int = 8000
# Boolean with default
debug: bool = False
# List of strings
allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
settings = Settings()
# Usage
print(settings.port) # 8000 (int)
print(settings.debug) # False (bool)
print(settings.allowed_hosts) # ["localhost", "127.0.0.1"] (list)
Type Hints: Use type hints for automatic validation. Optional[str] allows None, str requires a value.
5Integrating with FastAPI
Create a settings module and use it throughout your FastAPI application.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
app_name: str = "My API"
database_url: str
api_key: str
debug: bool = False
log_level: str = "INFO"
settings = Settings()
from fastapi import FastAPI
from app.config import settings
from sqlalchemy import create_engine
# Use settings in your FastAPI app
app = FastAPI(title=settings.app_name, debug=settings.debug)
# Create database engine with configured URL
engine = create_engine(settings.database_url)
@app.get("/")
async def root():
return {
"app_name": settings.app_name,
"debug": settings.debug,
"log_level": settings.log_level
}
@app.get("/config")
async def get_config():
# Never return secrets like database_url or api_key
return {
"app_name": settings.app_name,
"debug": settings.debug,
"log_level": settings.log_level
}
Do not expose secrets in endpoints. Even a "masked" database URL can leak the username or password. Return only safe, non-secret values.
6Environment-Specific Settings
Support multiple environments (development, staging, production) with one ENV variable. It picks which .env file to load. Derived values like debug become simple properties.
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
# Read ENV first so we know which .env file to load
ENV = os.getenv("ENV", "development")
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=f".env.{ENV}")
environment: str = ENV
database_url: str
api_key: str
# Database settings
db_pool_size: int = 5
db_max_overflow: int = 10
# Security
cors_origins: list[str] = ["http://localhost:3000"]
# Derived values (computed from environment)
@property
def debug(self) -> bool:
return self.environment == "development"
@property
def log_level(self) -> str:
return "DEBUG" if self.debug else "INFO"
settings = Settings()
ENV=development
DATABASE_URL=postgresql://user:password@localhost/mydb_dev
API_KEY=dev_key_1234567890
DB_POOL_SIZE=5
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]
ENV=production
DATABASE_URL=postgresql://user:[email protected]/mydb
API_KEY=sk_live_actual_key_here
DB_POOL_SIZE=20
CORS_ORIGINS=["https://example.com", "https://www.example.com"]
How to switch: Run ENV=production uvicorn app.main:app to load .env.production. No ENV set? It loads .env.development by default.
7Custom Validation
Validate settings using field validators or custom logic.
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str
api_key: str
port: int = 8000
@field_validator("database_url")
@classmethod
def validate_database_url(cls, v):
if not v.startswith(("postgresql://", "mysql://", "sqlite://")):
raise ValueError("Invalid database URL format")
return v
@field_validator("api_key")
@classmethod
def validate_api_key(cls, v):
if len(v) < 20:
raise ValueError("API key must be at least 20 characters")
return v
@field_validator("port")
@classmethod
def validate_port(cls, v):
if not 1 <= v <= 65535:
raise ValueError("Port must be between 1 and 65535")
return v
settings = Settings()
8Secrets Management with .env
For sensitive data, use .env files that are never committed to git. In production, use environment variables or secret management systems.
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
# Secrets (from .env or environment)
database_password: str
api_key: str
jwt_secret: str
aws_access_key: Optional[str] = None
aws_secret_key: Optional[str] = None
# Non-secrets (can be in git)
app_name: str = "My API"
database_host: str = "localhost"
debug: bool = False
settings = Settings()
Production Secrets: Use AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, or similar for production. Never rely on .env files in production.
9Nested Configuration Objects
For complex configurations, use nested Pydantic models to organize settings.
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel
class DatabaseConfig(BaseModel):
url: str
pool_size: int = 5
max_overflow: int = 10
echo: bool = False
class CORSConfig(BaseModel):
origins: list[str] = ["localhost"]
allow_credentials: bool = True
allow_methods: list[str] = ["*"]
allow_headers: list[str] = ["*"]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
)
app_name: str = "My API"
debug: bool = False
database: DatabaseConfig
cors: CORSConfig
# .env file
# DATABASE__URL=postgresql://user:pass@localhost/db
# DATABASE__POOL_SIZE=10
# CORS__ORIGINS=["https://example.com"]
# CORS__ALLOW_CREDENTIALS=true
settings = Settings()
print(settings.database.url) # postgresql://user:pass@localhost/db
print(settings.cors.origins) # ["https://example.com"]
10Testing with Different Settings
Override settings in tests to avoid using production credentials.
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.config import settings
@pytest.fixture
def test_client():
# Create test client with test settings
with TestClient(app) as client:
yield client
def test_app_with_test_settings(monkeypatch):
# Override settings for this test
monkeypatch.setattr(settings, "debug", True)
monkeypatch.setattr(settings, "database_url", "sqlite:///:memory:")
assert settings.debug is True
assert "sqlite" in settings.database_url
def test_config_loaded():
# Test that settings loaded correctly
assert settings.app_name is not None
assert settings.database_url is not None
11Common Configuration Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Environment-based config | Different settings per environment | ENV=production DATABASE_URL=... |
| Nested models | Organize complex configurations | database.url, cors.origins |
| Custom validators | Validate sensitive settings | Ensure API key format |
| Optional secrets | Some features optional | AWS_KEY optional in development |
| Derived settings | Calculate from other settings | debug = (environment == development) |
12Configuration Best Practices
- Never commit .env to version control
- Use type hints for all settings
- Set reasonable defaults for optional settings
- Validate sensitive settings (API keys, URLs)
- Use environment-specific .env files (.env.production, .env.development)
- Hide sensitive data when logging settings
- Use secret management systems in production
- Document all required settings in README
- Test with different configurations
- Override settings in tests to avoid side effects
13Production Configuration Setup
In production, load settings from environment variables or secret management services.
import os
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env") # Usually absent in production
app_name: str = "Production API"
environment: str = os.getenv("ENV", "production")
database_url: str = "" # Required in production
api_key: str = "" # Required in production
jwt_secret: str = "" # Required in production
aws_access_key_id: str = "" # Reads AWS_ACCESS_KEY_ID automatically
log_level: str = "INFO"
@model_validator(mode="after")
def check_production_settings(self):
# Fail fast if a required secret is missing
if self.environment == "production":
required = ["database_url", "api_key", "jwt_secret"]
for name in required:
if not getattr(self, name):
raise ValueError(f"{name} must be set in production")
return self
settings = Settings()
Docker & Environment Variables: In Docker/Kubernetes, pass secrets as environment variables or use a secret manager. The application loads them at startup.
14Advanced Configuration Patterns
Use lru_cache so settings load only once. Then inject them into endpoints with Depends. This also makes settings easy to override in tests.
from functools import lru_cache
from fastapi import Depends, FastAPI
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str
api_key: str
debug: bool = False
# Cache the settings instance (created only once)
@lru_cache
def get_settings() -> Settings:
return Settings()
app = FastAPI()
@app.get("/info")
async def get_info(settings: Settings = Depends(get_settings)):
return {"debug": settings.debug}
15Summary & Next Steps
Master Pydantic Settings to build secure, flexible FastAPI applications that work across development, staging, and production environments.
🚀 Congratulations! You now understand how to manage configuration securely in FastAPI using Pydantic Settings. Build with confidence across all environments!