FastAPI

FastAPI Pydantic Settings — Environment Configuration Management

Thirdy Gayares
15 min read

🎓 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
ConfigurationPydanticEnvironmentSecrets

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)

app = FastAPI() @app.get("/") async def root(): DATABASE_URL = "postgresql://user:password@localhost/db" API_KEY = "secret123456" return {"db": DATABASE_URL}
Secrets exposed in code
Hard to change per environment
Can commit secrets to git

2Pydantic Settings Basics

Pydantic Settings loads configuration from environment variables and .env files. Install it first.

pip install pydantic-settings
app/config.py
from 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.

.env
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.

.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.

app/config.py
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.

app/config.py
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()
app/main.py
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.

app/config.py
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
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
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.

app/config.py
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.

app/config.py
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.

app/config.py
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.

tests/test_config.py
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

PatternUse CaseExample
Environment-based configDifferent settings per environmentENV=production DATABASE_URL=...
Nested modelsOrganize complex configurationsdatabase.url, cors.origins
Custom validatorsValidate sensitive settingsEnsure API key format
Optional secretsSome features optionalAWS_KEY optional in development
Derived settingsCalculate from other settingsdebug = (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.

app/config.py
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.

app/config.py
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!

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.